From 304bfae05ffd01f8fbb7148bfa477f332eb1c9b3 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:31:59 +0000 Subject: [PATCH 01/74] feat(session): add isolated browser evidence primitives Co-authored-by: Codex --- src/artifacts/evidence.ts | 8 ++ src/browser/evidence.ts | 285 ++++++++++++++++++++++++++++++++++++++ src/browser/provenance.ts | 156 +++++++++++++++++++++ src/browser/runtime.ts | 23 ++- src/session/lifecycle.ts | 20 +++ src/session/manifest.ts | 6 + src/session/registry.ts | 22 +++ src/session/selection.ts | 103 ++++++++++++++ src/session/state.ts | 15 ++ src/utils/exec.ts | 24 +++- 10 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 src/browser/evidence.ts create mode 100644 src/browser/provenance.ts create mode 100644 src/session/selection.ts diff --git a/src/artifacts/evidence.ts b/src/artifacts/evidence.ts index 03840ad..4235bb9 100644 --- a/src/artifacts/evidence.ts +++ b/src/artifacts/evidence.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { createHash, randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; import { PNG } from 'pngjs'; +import type { SanitizedNetworkSummary } from '../browser/evidence.js'; import type { SessionLogEntry } from '../commands/exec.js'; import { loadEvidenceEvents } from '../environment/evidence.js'; import type { @@ -59,6 +60,7 @@ export type CanonicalEvidence = { sources: EvidenceSourceSummary[]; incidents: EvidenceIncident[]; screenshots: ScreenshotIntegrity[]; + network?: SanitizedNetworkSummary | null; }; export type Verdict = { @@ -85,6 +87,8 @@ export type EvidenceBuildOptions = { consoleEntries: TimestampedLogEntry[]; serverEntries: TimestampedLogEntry[]; environment?: EnvironmentState | null; + networkSummary?: SanitizedNetworkSummary | null; + networkEvidenceRequired?: boolean; }; export function writeCanonicalEvidence( @@ -123,6 +127,7 @@ export function writeCanonicalEvidence( sources, incidents, screenshots, + network: options.networkSummary, }; const verdict = buildVerdict(options, evidence); writeJsonAtomically( @@ -528,6 +533,9 @@ function buildVerdict( evidence: CanonicalEvidence, ): Verdict { const missingArtifacts: string[] = []; + if (options.networkEvidenceRequired && !options.networkSummary) { + missingArtifacts.push('network-summary.json'); + } if (options.recordingWasActive && !fs.existsSync(options.videoPath)) { missingArtifacts.push(path.basename(options.videoPath)); } else if ( diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts new file mode 100644 index 0000000..71b3819 --- /dev/null +++ b/src/browser/evidence.ts @@ -0,0 +1,285 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { sanitizeDiagnosticMessage } from './provenance.js'; +import { ab, quoteShellArgument } from '../utils/exec.js'; + +export type AgentBrowserResultReceipt = { + success: boolean; + evidencePath: string; + error?: string; +}; + +export type PrivateNetworkEvidencePaths = { + privateDirectory: string; + harPath: string; + requestsPath: string; + summaryPath: string; +}; + +export type SanitizedNetworkRequest = { + endpoint: string; + method: string; + status: number; + durationMs: number | null; + error: string | null; +}; + +export type SanitizedNetworkSummary = { + version: 1; + requestCount: number; + requests: SanitizedNetworkRequest[]; +}; + +export function preparePrivateNetworkEvidence( + sessionDir: string, +): PrivateNetworkEvidencePaths { + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + preparePrivateDirectory(privateDirectory); + return { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; +} + +export function startPrivateNetworkCapture(sessionName: string): void { + ab('network har start --content text', { session: sessionName }); +} + +export function finalizePrivateNetworkCapture( + sessionName: string, + paths: PrivateNetworkEvidencePaths, +): SanitizedNetworkSummary { + preparePrivateDirectory(paths.privateDirectory); + const requests = ab('network requests --json', { session: sessionName }); + writePrivateTextFile( + paths.requestsPath, + requests.trim() ? `${requests.trim()}\n` : '{"success":true,"data":[]}\n', + ); + ab( + `network har stop ${quoteShellArgument(paths.harPath)} --json`, + { session: sessionName }, + ); + if (!fs.existsSync(paths.harPath)) { + throw new Error('agent-browser did not write the requested HAR evidence.'); + } + fs.chmodSync(paths.harPath, 0o600); + const summary = buildSanitizedNetworkSummary( + JSON.parse(fs.readFileSync(paths.harPath, 'utf-8')), + ); + writeJsonFile(paths.summaryPath, summary); + return summary; +} + +export function loadSanitizedNetworkSummary( + summaryPath: string | undefined, +): SanitizedNetworkSummary | null { + if (!summaryPath || !fs.existsSync(summaryPath)) { + return null; + } + try { + const value = JSON.parse(fs.readFileSync(summaryPath, 'utf-8')) as unknown; + return isSanitizedNetworkSummary(value) ? value : null; + } catch { + return null; + } +} + +export function buildSanitizedNetworkSummary( + value: unknown, +): SanitizedNetworkSummary { + const entries = readHarEntries(value); + const requests = entries.map(toSanitizedRequest).sort(compareRequests); + return { + version: 1, + requestCount: requests.length, + requests, + }; +} + +export function writePrivateAgentBrowserResult(options: { + sessionDir: string; + rawOutput: string; + success: boolean; + error?: string; +}): AgentBrowserResultReceipt { + const actionsDirectory = path.join( + options.sessionDir, + 'private', + 'agent-browser', + 'actions', + ); + preparePrivateDirectory(actionsDirectory); + const filePath = path.join( + actionsDirectory, + `${Date.now()}-${randomUUID()}.json`, + ); + const result = parseStructuredResult(options.rawOutput, options.success, options.error); + writeJsonFile(filePath, result); + const evidencePath = path + .relative(options.sessionDir, filePath) + .split(path.sep) + .join(path.posix.sep); + return { + success: readSuccess(result, options.success), + evidencePath, + ...(options.error + ? { error: sanitizeDiagnosticMessage(options.error) || 'agent-browser failed' } + : {}), + }; +} + +function parseStructuredResult( + rawOutput: string, + success: boolean, + error?: string, +): unknown { + if (rawOutput.trim()) { + try { + return JSON.parse(rawOutput); + } catch { + return { + success, + data: { text: rawOutput }, + ...(error ? { error } : {}), + }; + } + } + return { + success, + data: null, + ...(error ? { error } : {}), + }; +} + +function readSuccess(result: unknown, fallback: boolean): boolean { + if (typeof result !== 'object' || result === null) { + return fallback; + } + const success = (result as { success?: unknown }).success; + return typeof success === 'boolean' ? success : fallback; +} + +function preparePrivateDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + let current = directory; + while (current !== path.dirname(current)) { + fs.chmodSync(current, 0o700); + if (path.basename(current) === 'private') { + break; + } + current = path.dirname(current); + } +} + +function readHarEntries(value: unknown): Record[] { + if (typeof value !== 'object' || value === null) { + throw new Error('HAR evidence root must be an object.'); + } + const log = (value as { log?: unknown }).log; + if (typeof log !== 'object' || log === null) { + throw new Error('HAR evidence is missing its log object.'); + } + const entries = (log as { entries?: unknown }).entries; + if (!Array.isArray(entries)) { + throw new Error('HAR evidence is missing its entries array.'); + } + return entries.filter( + (entry): entry is Record => + typeof entry === 'object' && entry !== null, + ); +} + +function toSanitizedRequest(entry: Record): SanitizedNetworkRequest { + const request = readRecord(entry.request); + const response = readRecord(entry.response); + const rawUrl = typeof request.url === 'string' ? request.url : ''; + const rawDuration = typeof entry.time === 'number' ? entry.time : Number.NaN; + const rawError = + typeof entry._error === 'string' + ? entry._error + : typeof response._error === 'string' + ? response._error + : null; + return { + endpoint: sanitizeEndpoint(rawUrl), + method: typeof request.method === 'string' ? request.method : '', + status: typeof response.status === 'number' ? response.status : 0, + durationMs: Number.isFinite(rawDuration) + ? parseFloat(rawDuration.toFixed(3)) + : null, + error: rawError ? sanitizeDiagnosticMessage(rawError) || 'request failed' : null, + }; +} + +function sanitizeEndpoint(value: string): string { + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`; + } catch { + return sanitizeDiagnosticMessage(value) || ''; + } +} + +function compareRequests( + left: SanitizedNetworkRequest, + right: SanitizedNetworkRequest, +): number { + return ( + left.endpoint.localeCompare(right.endpoint) || + left.method.localeCompare(right.method) || + left.status - right.status || + (left.durationMs ?? -1) - (right.durationMs ?? -1) || + (left.error || '').localeCompare(right.error || '') + ); +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +function isSanitizedNetworkSummary( + value: unknown, +): value is SanitizedNetworkSummary { + if (typeof value !== 'object' || value === null) { + return false; + } + const summary = value as Partial; + return ( + summary.version === 1 && + typeof summary.requestCount === 'number' && + Array.isArray(summary.requests) + ); +} + +function writePrivateTextFile(filePath: string, contents: string): void { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, contents, { mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); + fs.chmodSync(filePath, 0o600); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } +} + +function writeJsonFile(filePath: string, value: unknown): void { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + '\n', { + mode: 0o600, + }); + fs.renameSync(temporaryPath, filePath); + fs.chmodSync(filePath, 0o600); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } +} diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts new file mode 100644 index 0000000..ddaeedf --- /dev/null +++ b/src/browser/provenance.ts @@ -0,0 +1,156 @@ +const REDACTED = '[REDACTED]'; +const SECRET_FLAG = /^(?:--)?(?:authorization|cookie|credential|password|secret|token|api[-_]?key|body)$/i; +const SECRET_QUERY_PARAMETER = /(?:auth|code|cookie|credential|key|password|secret|session|token)/i; + +export type InteractionCategory = + | 'observation' + | 'pointer-keyboard' + | 'hybrid' + | 'setup' + | 'synthetic-dom' + | 'unknown'; + +export type SanitizedCommandIntent = { + command: string; + summary: string; +}; + +export function buildSanitizedCommandIntent( + args: string[], +): SanitizedCommandIntent { + const command = args[0]?.toLowerCase() || 'unknown'; + const sanitizedArgs = sanitizeArguments(command, args); + return { + command, + summary: sanitizedArgs.join(' '), + }; +} + +export function classifyInteraction(args: string[]): InteractionCategory { + const command = args[0]?.toLowerCase(); + if (!command) { + return 'unknown'; + } + if ( + [ + 'assert-visible', + 'console', + 'errors', + 'get', + 'is', + 'read', + 'screenshot', + 'snapshot', + ].includes(command) + ) { + return 'observation'; + } + if ( + ['click', 'dblclick', 'drag', 'hover', 'key', 'mouse', 'press'].includes( + command, + ) + ) { + return 'pointer-keyboard'; + } + if (['check', 'fill', 'select', 'type', 'uncheck'].includes(command)) { + return 'hybrid'; + } + if (command === 'eval') { + return 'synthetic-dom'; + } + if ( + [ + 'close', + 'cookies', + 'find', + 'navigate', + 'network', + 'open', + 'record', + 'set', + 'storage', + 'tab', + 'upload', + 'wait', + ].includes(command) + ) { + return 'setup'; + } + return 'unknown'; +} + +export function sanitizePageUrl(value: string | undefined): string | undefined { + if (!value) { + return value; + } + try { + const url = new URL(value); + url.username = ''; + url.password = ''; + for (const key of url.searchParams.keys()) { + if (SECRET_QUERY_PARAMETER.test(key)) { + url.searchParams.set(key, REDACTED); + } + } + return url.toString(); + } catch { + return sanitizeDiagnosticMessage(value); + } +} + +export function sanitizeDiagnosticMessage(value: string | undefined): string | undefined { + if (!value) { + return value; + } + return value + .replace(/\bBearer\s+[^\s"']+/gi, `Bearer ${REDACTED}`) + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, REDACTED) + .replace( + /\b(authorization|cookie|credential|password|secret|token|api[-_]?key)\b\s*[:=]\s*([^\s,;]+)/gi, + `$1=${REDACTED}`, + ) + .replace(/https?:\/\/[^\s"']+/g, (url) => sanitizePageUrl(url) || REDACTED); +} + +function sanitizeArguments(command: string, args: string[]): string[] { + if (command === 'eval') { + return [command, '[REDACTED_SCRIPT]']; + } + if (['fill', 'type'].includes(command)) { + return [command, sanitizeArgument(args[1] || ''), REDACTED]; + } + if (command === 'set' && ['credentials', 'headers'].includes(args[1]?.toLowerCase())) { + return [command, args[1].toLowerCase(), REDACTED]; + } + if (command === 'cookies' && args[1]?.toLowerCase() === 'set') { + return [command, 'set', sanitizeArgument(args[2] || ''), REDACTED]; + } + if (command === 'upload') { + return [command, sanitizeArgument(args[1] || ''), '[LOCAL_FILE]']; + } + + const sanitized: string[] = []; + let redactNext = false; + for (const [index, argument] of args.entries()) { + if (redactNext) { + sanitized.push(REDACTED); + redactNext = false; + continue; + } + if (index > 0 && SECRET_FLAG.test(argument)) { + sanitized.push(argument); + redactNext = true; + continue; + } + sanitized.push(sanitizeArgument(argument)); + } + return sanitized; +} + +function sanitizeArgument(value: string): string { + const url = sanitizePageUrl(value); + if (url !== value) { + return url || REDACTED; + } + return sanitizeDiagnosticMessage(value) || ''; +} diff --git a/src/browser/runtime.ts b/src/browser/runtime.ts index ef99442..4248426 100644 --- a/src/browser/runtime.ts +++ b/src/browser/runtime.ts @@ -34,6 +34,7 @@ export function prepareAgentBrowserSocketDir( sessionName: string, env: NodeJS.ProcessEnv = process.env, accountHome = os.userInfo().homedir, + namespace?: string, ): string { const uid = process.getuid?.() ?? process.pid; const explicit = env.AGENT_BROWSER_SOCKET_DIR; @@ -58,7 +59,10 @@ export function prepareAgentBrowserSocketDir( fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); assertOwnedDirectory(directory); - const socketPath = path.join(directory, `${sessionName}.sock`); + const runtimeDirectory = resolveAgentBrowserRuntimeDir(directory, namespace); + fs.mkdirSync(runtimeDirectory, { recursive: true, mode: 0o700 }); + assertOwnedDirectory(runtimeDirectory); + const socketPath = path.join(runtimeDirectory, `${sessionName}.sock`); const byteLength = Buffer.byteLength(socketPath); if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) { throw new Error( @@ -70,6 +74,23 @@ export function prepareAgentBrowserSocketDir( return directory; } +export function resolveAgentBrowserRuntimeDir( + socketRoot: string, + namespace?: string, +): string { + if (!namespace) { + return socketRoot; + } + const normalizedNamespace = namespace + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!normalizedNamespace) { + throw new Error(`Unsafe agent-browser namespace: ${namespace}`); + } + return path.join(socketRoot, 'namespaces', normalizedNamespace, 'run'); +} + /** Read the exact daemon PID written for this isolated agent-browser session. */ export function captureAgentBrowserProcessIdentity( socketDir: string, diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts index 5d4727a..ecb52fb 100644 --- a/src/session/lifecycle.ts +++ b/src/session/lifecycle.ts @@ -1,4 +1,5 @@ import { stopRecording } from '../browser/capture.js'; +import { finalizePrivateNetworkCapture } from '../browser/evidence.js'; import { stopOwnedEnvironment } from '../environment/runtime.js'; import { clearAgentBrowserSessionFiles, @@ -126,6 +127,25 @@ export async function cleanupFailedStart(session: SessionState): Promise { // operations are session-scoped and best effort. Never address a session // name unless its daemon still has the identity captured by this start. if (canAddressOwnedBrowserSession(session)) { + if ( + session.networkCaptureActive && + session.privateEvidenceDir && + session.networkHarPath && + session.networkRequestsPath && + session.networkSummaryPath + ) { + try { + finalizePrivateNetworkCapture(session.sessionName, { + privateDirectory: session.privateEvidenceDir, + harPath: session.networkHarPath, + requestsPath: session.networkRequestsPath, + summaryPath: session.networkSummaryPath, + }); + } catch { + // Failed-start cleanup must still close exact owned processes. + } + session.networkCaptureActive = false; + } stopRecording(session.sessionName); } if (session.browserProcess || !session.browserLaunchAttempted) { diff --git a/src/session/manifest.ts b/src/session/manifest.ts index 100c92e..7f7a058 100644 --- a/src/session/manifest.ts +++ b/src/session/manifest.ts @@ -22,6 +22,7 @@ export type ManifestArtifactKind = | 'summary' | 'evidence' | 'verdict' + | 'network-summary' | 'log'; export type ManifestArtifact = { @@ -271,6 +272,7 @@ function isArtifactManifest(value: unknown): value is ArtifactManifest { 'summary', 'evidence', 'verdict', + 'network-summary', 'log', ].includes(artifact.kind), ) @@ -348,6 +350,7 @@ function listArtifactFiles( } function classifyArtifact(file: string): ManifestArtifactKind | null { + if (file.split(/[\\/]/)[0] === 'private') return null; const basename = path.basename(file); const isSessionRoot = path.dirname(file) === '.'; if (isSessionRoot && file.endsWith('.png')) return 'screenshot'; @@ -361,6 +364,9 @@ function classifyArtifact(file: string): ManifestArtifactKind | null { if (isSessionRoot && basename === 'SUMMARY.md') return 'summary'; if (isSessionRoot && basename === 'evidence.json') return 'evidence'; if (isSessionRoot && basename === 'verdict.json') return 'verdict'; + if (isSessionRoot && basename === 'network-summary.json') { + return 'network-summary'; + } if (file.endsWith('.log') || file.endsWith('.ndjson')) return 'log'; return null; } diff --git a/src/session/registry.ts b/src/session/registry.ts index 895a25e..7a62a63 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -134,11 +134,33 @@ function isSessionState(value: unknown): value is SessionState { (typeof session.serverCommand === 'string' || session.serverCommand === null) && typeof session.serverAlreadyRunning === 'boolean' && typeof session.recordingActive === 'boolean' && + isOptionalString(session.agentBrowserSocketDir) && + isOptionalString(session.agentBrowserSocketRoot) && + isOptionalString(session.agentBrowserNamespace) && + isOptionalString(session.agentBrowserConfigPath) && + isOptionalString(session.privateEvidenceDir) && + isOptionalString(session.networkHarPath) && + isOptionalString(session.networkRequestsPath) && + isOptionalString(session.networkSummaryPath) && + isOptionalBoolean(session.networkCaptureStarted) && + isOptionalBoolean(session.networkCaptureActive) && + isOptionalBoolean(session.networkEvidenceAvailable) && + (session.networkCaptureError === undefined || + session.networkCaptureError === null || + typeof session.networkCaptureError === 'string') && isOptionalProcessIdentity(session.serverProcess) && isOptionalProcessIdentity(session.browserProcess) ); } +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function isOptionalBoolean(value: unknown): boolean { + return value === undefined || typeof value === 'boolean'; +} + function isOptionalProcessIdentity(value: unknown): boolean { if (value === undefined || value === null) { return true; diff --git a/src/session/selection.ts b/src/session/selection.ts new file mode 100644 index 0000000..488671a --- /dev/null +++ b/src/session/selection.ts @@ -0,0 +1,103 @@ +import * as path from 'path'; +import type { EnvironmentState } from '../environment/types.js'; +import { ownedProcessTreeIsAlive, type ProcessIdentity } from '../utils/process.js'; +import { + getRegisteredSession, + listRegisteredSessions, +} from './registry.js'; +import type { SessionState } from './state.js'; + +type ResolveSessionOptions = { + controlDir: string; + sessionName?: string; +}; + +export function listSessionsForControlDir(controlDir: string): SessionState[] { + const resolvedControlDir = path.resolve(controlDir); + return listRegisteredSessions().filter((session) => + path.resolve(session.controlDir ?? session.outputDir) === resolvedControlDir, + ); +} + +export function resolveLiveSession( + options: ResolveSessionOptions, +): SessionState | null { + if (options.sessionName) { + const session = getRegisteredSession(options.sessionName); + if (!session) { + throw new Error( + `No registered ProofShot session named "${options.sessionName}".`, + ); + } + return session; + } + + const sessions = listSessionsForControlDir(options.controlDir).filter( + (session) => session.lifecycleStatus !== 'recovery', + ); + if (sessions.length === 0) { + return null; + } + if (sessions.length === 1) { + return sessions[0]; + } + + const choices = sessions.map((session) => ` ${session.sessionName}`).join('\n'); + throw new Error( + 'Multiple active ProofShot sessions match this worktree. ' + + 'Re-run with --session :\n' + + choices, + ); +} + +export function sessionHasVerifiedLiveOwnership( + session: SessionState, +): boolean { + return collectProcessIdentities(session).some(ownedProcessTreeIsAlive); +} + +function collectProcessIdentities(session: SessionState): ProcessIdentity[] { + const identities: ProcessIdentity[] = []; + appendIdentity(identities, session.browserProcess); + appendIdentity(identities, session.serverProcess); + appendEnvironmentIdentities(identities, session.environment); + return identities; +} + +function appendEnvironmentIdentities( + identities: ProcessIdentity[], + environment: EnvironmentState | null | undefined, +): void { + if (!environment) { + return; + } + switch (environment.kind) { + case 'tmux': + appendIdentity(identities, environment.serverProcess); + for (const capture of environment.captures) { + appendIdentity(identities, capture.process); + } + return; + case 'processes': + for (const capture of environment.processes) { + appendIdentity(identities, capture.process); + } + return; + case 'launcher': + appendIdentity(identities, environment.launcher.process); + return; + default: { + const exhaustiveEnvironment: never = environment; + return exhaustiveEnvironment; + } + } +} + +function appendIdentity( + identities: ProcessIdentity[], + identity: ProcessIdentity | null | undefined, +): void { + if (identity) { + identities.push(identity); + } +} diff --git a/src/session/state.ts b/src/session/state.ts index e006780..515d296 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -35,7 +35,17 @@ export interface SessionState { targetUrl?: string; headless?: boolean; agentBrowserSocketDir?: string; + agentBrowserSocketRoot?: string; + agentBrowserNamespace?: string; agentBrowserConfigPath?: string; + privateEvidenceDir?: string; + networkHarPath?: string; + networkRequestsPath?: string; + networkSummaryPath?: string; + networkCaptureStarted?: boolean; + networkCaptureActive?: boolean; + networkEvidenceAvailable?: boolean; + networkCaptureError?: string | null; serverProcess?: ProcessIdentity | null; browserProcess?: ProcessIdentity | null; environment?: EnvironmentState | null; @@ -125,3 +135,8 @@ export function generateAgentBrowserSessionName( return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`; } + +export function generateAgentBrowserNamespace(sessionName: string): string { + const digest = createHash('sha256').update(sessionName).digest('hex').slice(0, 12); + return `psn-${digest}`; +} diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 2eff055..8d70c6c 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -13,28 +13,38 @@ export class ProofShotError extends Error { export interface AgentBrowserCommandOptions { configPath?: string; + json?: boolean; + namespace?: string; session?: string; socketDir?: string; timeoutMs?: number; } -let defaultAgentBrowserOptions: Pick = {}; +let defaultAgentBrowserOptions: Pick< + AgentBrowserCommandOptions, + 'configPath' | 'namespace' | 'socketDir' +> = {}; export function setAgentBrowserDefaults( - options: Pick, + options: Pick< + AgentBrowserCommandOptions, + 'configPath' | 'namespace' | 'socketDir' + >, ): void { defaultAgentBrowserOptions = { ...options }; } export function getAgentBrowserEnvironment( - options: Pick = {}, + options: Pick = {}, ): NodeJS.ProcessEnv { const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir; + const namespace = options.namespace ?? defaultAgentBrowserOptions.namespace; return { ...process.env, AGENT_BROWSER_IDLE_TIMEOUT_MS: process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '1800000', ...(socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {}), + ...(namespace ? { AGENT_BROWSER_NAMESPACE: namespace } : {}), }; } @@ -45,7 +55,10 @@ export function quoteShellArgument(value: string): string { export function buildAgentBrowserCommand( command: string, - options: Pick = {}, + options: Pick< + AgentBrowserCommandOptions, + 'configPath' | 'json' | 'session' + > = {}, ): string { const mergedOptions = { ...defaultAgentBrowserOptions, @@ -57,7 +70,8 @@ export function buildAgentBrowserCommand( const sessionFlag = mergedOptions.session ? ` --session ${quoteShellArgument(mergedOptions.session)}` : ''; - return `agent-browser${configFlag}${sessionFlag} ${command}`; + const jsonFlag = mergedOptions.json ? ' --json' : ''; + return `agent-browser${configFlag}${sessionFlag}${jsonFlag} ${command}`; } /** From 60597c9abad9a818834cb0cb99c39e88b7566da4 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:32:04 +0000 Subject: [PATCH 02/74] feat(cli): support concurrent proof sessions Co-authored-by: Codex --- src/cli.ts | 11 ++- src/commands/clean.ts | 10 ++- src/commands/doctor.ts | 19 ++++- src/commands/exec.ts | 171 +++++++++++++++++++++++++++++++++++----- src/commands/session.ts | 52 ++---------- src/commands/start.ts | 138 +++++++++++++++++++++++--------- src/commands/stop.ts | 94 ++++++++++++++++------ 7 files changed, 357 insertions(+), 138 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4540cb2..c3f3259 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,9 +46,13 @@ export function createCLI(): Command { program .command('stop') .description('Stop session: stop recording, collect errors, bundle proof artifacts') + .option('--session ', 'Stop one exact registered session') .option('--no-close', 'Don\'t close the browser (keep it open for further use)') .action(async (options) => { - await stopCommand({ noClose: options.close === false }); + await stopCommand({ + noClose: options.close === false, + session: options.session, + }); }); program @@ -111,10 +115,11 @@ export function createCLI(): Command { program .command('exec') .description('Run an agent-browser command with logging (use instead of agent-browser directly)') + .option('--session ', 'Address one exact registered session') .argument('', 'agent-browser command and arguments') .allowUnknownOption() - .action(async (args) => { - await execCommand(args); + .action(async (args, options) => { + await execCommand(args, { session: options.session }); }); const session = program diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 72f1676..3587766 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -2,14 +2,20 @@ import * as fs from 'fs'; import * as path from 'path'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; -import { hasActiveSession, resolveSessionControlDir } from '../session/state.js'; +import { resolveSessionControlDir } from '../session/state.js'; +import { + listSessionsForControlDir, + sessionHasVerifiedLiveOwnership, +} from '../session/selection.js'; export async function cleanCommand(): Promise { const config = loadConfig(); const controlDir = resolveSessionControlDir(config.output); const outputDir = path.resolve(config.output); - if (hasActiveSession(controlDir)) { + if ( + listSessionsForControlDir(controlDir).some(sessionHasVerifiedLiveOwnership) + ) { console.error( chalk.red('✗') + ' Cannot clean while a ProofShot session owns browser or server processes.\n' + diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 96c6734..8286690 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,8 +2,12 @@ import chalk from 'chalk'; import { PROOFSHOT_VERSION } from '../version.js'; import { findConfigPath, loadConfig } from '../utils/config.js'; import { findExecutablePath, readCommandVersion } from '../utils/process.js'; -import { loadSession, resolveSessionControlDir } from '../session/state.js'; +import { resolveSessionControlDir } from '../session/state.js'; import { listRegisteredSessions } from '../session/registry.js'; +import { + listSessionsForControlDir, + sessionHasVerifiedLiveOwnership, +} from '../session/selection.js'; function statusLabel(ok: boolean, text: string): string { return ok ? `${chalk.green('✓')} ${text}` : `${chalk.yellow('⚠')} ${text}`; @@ -17,7 +21,10 @@ export async function doctorCommand(): Promise { const configPath = findConfigPath(); const config = loadConfig(); const controlDir = resolveSessionControlDir(config.output); - const session = loadSession(controlDir); + const activeSessions = listSessionsForControlDir(controlDir).filter( + sessionHasVerifiedLiveOwnership, + ); + const session = activeSessions.length === 1 ? activeSessions[0] : null; const registeredSessions = listRegisteredSessions(); const agentBrowserPath = findExecutablePath('agent-browser'); @@ -46,7 +53,8 @@ export async function doctorCommand(): Promise { printLine('Version', ffmpegVersion || chalk.dim('not available')); console.log(''); - console.log(statusLabel(Boolean(session), 'active session')); + console.log(statusLabel(activeSessions.length > 0, 'active session')); + printLine('Active here', String(activeSessions.length)); printLine('Sessions', String(registeredSessions.length)); if (session) { printLine('Session dir', session.sessionDir); @@ -54,6 +62,9 @@ export async function doctorCommand(): Promise { printLine('Port', String(session.port)); if (session.targetUrl) printLine('Target', session.targetUrl); } else { - printLine('Session dir', chalk.dim('none')); + printLine( + 'Session dir', + activeSessions.length > 1 ? chalk.dim('multiple; use session list') : chalk.dim('none'), + ); } } diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 7cb844a..267c9ad 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,6 +1,18 @@ import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; +import { + writePrivateAgentBrowserResult, + type AgentBrowserResultReceipt, +} from '../browser/evidence.js'; +import { + buildSanitizedCommandIntent, + classifyInteraction, + sanitizeDiagnosticMessage, + sanitizePageUrl, + type InteractionCategory, + type SanitizedCommandIntent, +} from '../browser/provenance.js'; import { loadConfig, normalizeViewport } from '../utils/config.js'; import { ab, @@ -9,14 +21,12 @@ import { setAgentBrowserDefaults, } from '../utils/exec.js'; import { - loadSession, resolveSessionControlDir, - saveSession, - type SessionState, } from '../session/state.js'; import { canAddressOwnedBrowserSession } from '../session/lifecycle.js'; import { getPageUrl } from '../browser/session.js'; import { registerSession } from '../session/registry.js'; +import { resolveLiveSession } from '../session/selection.js'; const SESSION_LOG_FILENAME = 'session-log.json'; const SESSION_LOG_LOCK_TIMEOUT_MS = 5000; @@ -24,12 +34,16 @@ const SESSION_LOG_STALE_LOCK_MS = 120000; export interface SessionLogEntry { action: string; + category?: InteractionCategory; + intent?: SanitizedCommandIntent; relativeTimeSec: number; timestamp: string; + durationMs?: number; outcome?: 'passed' | 'failed'; expectedSelector?: string; error?: string; pageUrl?: string; + agentBrowserResult?: AgentBrowserResultReceipt; element?: { label: string; bbox: { x: number; y: number; width: number; height: number }; @@ -37,6 +51,10 @@ export interface SessionLogEntry { }; } +type ExecOptions = { + session?: string; +}; + /** * Load existing session log entries from disk. */ @@ -79,11 +97,18 @@ function resolveScreenshotPath(args: string[], sessionDir: string): string[] { * to prevent the shell from interpreting parentheses, brackets, etc. * For other commands, simple joining is fine. */ -export function buildShellCommand(args: string[], sessionName?: string): string { +export function buildShellCommand( + args: string[], + sessionName?: string, + structuredOutput = false, +): string { if (args[0] === 'eval' && args.length > 1) { const jsCode = args.slice(1).join(' '); const escaped = jsCode.replace(/'/g, "'\\''"); - return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName }); + return buildAgentBrowserCommand(`eval '${escaped}'`, { + json: structuredOutput, + session: sessionName, + }); } const quotedArgs = args.map((arg) => { @@ -93,7 +118,10 @@ export function buildShellCommand(args: string[], sessionName?: string): string } return arg; }); - return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName }); + return buildAgentBrowserCommand(quotedArgs.join(' '), { + json: structuredOutput, + session: sessionName, + }); } export function translateProofShotExecArgs(args: string[]): { @@ -213,8 +241,12 @@ function isRefTargetedAction(args: string[]): boolean { * 6. Pass through to agent-browser and return its output * 7. If action was `set viewport`, update cached viewport in session state */ -export async function execCommand(args: string[]): Promise { - const action = args.join(' '); +export async function execCommand( + args: string[], + options: ExecOptions = {}, +): Promise { + const intent = buildSanitizedCommandIntent(args); + const action = intent.summary; const translated = translateProofShotExecArgs(args); let loggedEntry: SessionLogEntry | null = null; let sessionLogPath: string | null = null; @@ -222,10 +254,14 @@ export async function execCommand(args: string[]): Promise { // Load session state const config = loadConfig(); const controlDir = resolveSessionControlDir(config.output); - const session = loadSession(controlDir); + const session = resolveLiveSession({ + controlDir, + sessionName: options.session, + }); setAgentBrowserDefaults({ configPath: session?.agentBrowserConfigPath || config.browser.configPath, - socketDir: session?.agentBrowserSocketDir, + namespace: session?.agentBrowserNamespace, + socketDir: session?.agentBrowserSocketRoot || session?.agentBrowserSocketDir, }); if (session && !session.recordingActive) { @@ -271,9 +307,11 @@ export async function execCommand(args: string[]): Promise { const entry: SessionLogEntry = { action, + category: classifyInteraction(args), + intent, relativeTimeSec, timestamp: now.toISOString(), - expectedSelector: translated.expectedSelector, + expectedSelector: sanitizeDiagnosticMessage(translated.expectedSelector), }; if (elementData) { entry.element = elementData; @@ -288,7 +326,12 @@ export async function execCommand(args: string[]): Promise { } // Build shell command with proper quoting - const shellCmd = buildShellCommand(resolvedArgs, session?.sessionName); + const shellCmd = buildShellCommand( + resolvedArgs, + session?.sessionName, + Boolean(session), + ); + const executionStartedAt = Date.now(); // Pass through to agent-browser try { @@ -300,7 +343,7 @@ export async function execCommand(args: string[]): Promise { }); if ( translated.expectedSelector && - result.trim().toLowerCase() !== 'true' + !assertionPassed(result) ) { const assertionError = new Error( `Expected selector to be visible: ${translated.expectedSelector}`, @@ -308,15 +351,33 @@ export async function execCommand(args: string[]): Promise { assertionError.status = 1; throw assertionError; } - if (result.trim()) { - process.stdout.write(result); + const displayedResult = session ? unwrapStructuredOutput(result) : result; + if (displayedResult.trim()) { + process.stdout.write(displayedResult); // Ensure trailing newline - if (!result.endsWith('\n')) { + if (!displayedResult.endsWith('\n')) { process.stdout.write('\n'); } } - const pageUrl = session ? getPageUrl(session.sessionName) || undefined : undefined; - persistActionOutcome(loggedEntry, sessionLogPath, 'passed', undefined, pageUrl); + const pageUrl = session + ? sanitizePageUrl(getPageUrl(session.sessionName) || undefined) + : undefined; + const agentBrowserResult = session + ? writePrivateAgentBrowserResult({ + sessionDir: session.sessionDir, + rawOutput: result, + success: true, + }) + : undefined; + persistActionOutcome( + loggedEntry, + sessionLogPath, + 'passed', + undefined, + pageUrl, + Date.now() - executionStartedAt, + agentBrowserResult, + ); } catch (error: any) { // Print stderr and exit with the same code const stderr = error?.stderr?.toString?.() || ''; @@ -326,11 +387,26 @@ export async function execCommand(args: string[]): Promise { if (!stdout && !stderr && error?.message) { process.stderr.write(`${error.message}\n`); } + const rawErrorOutput = stdout || stderr; + const persistedError = sanitizeDiagnosticMessage( + stderr.trim() || stdout.trim() || `agent-browser exited with status ${error?.status || 1}`, + ); + const agentBrowserResult = session + ? writePrivateAgentBrowserResult({ + sessionDir: session.sessionDir, + rawOutput: rawErrorOutput, + success: false, + error: persistedError, + }) + : undefined; persistActionOutcome( loggedEntry, sessionLogPath, 'failed', - stderr.trim() || stdout.trim() || error?.message, + persistedError, + undefined, + Date.now() - executionStartedAt, + agentBrowserResult, ); process.exit(error?.status || 1); } @@ -344,7 +420,6 @@ export async function execCommand(args: string[]): Promise { const vp = normalizeViewport(JSON.parse(vpJson)); if (vp) { session.viewport = vp; - saveSession(session, controlDir); registerSession(session); } } catch { @@ -359,6 +434,8 @@ function persistActionOutcome( outcome: 'passed' | 'failed', error?: string, pageUrl?: string, + durationMs?: number, + agentBrowserResult?: AgentBrowserResultReceipt, ): void { if (!entry || !logPath) { return; @@ -370,6 +447,12 @@ function persistActionOutcome( if (pageUrl) { entry.pageUrl = pageUrl; } + if (durationMs !== undefined) { + entry.durationMs = durationMs; + } + if (agentBrowserResult) { + entry.agentBrowserResult = agentBrowserResult; + } updateSessionLog(logPath, (entries) => { const matchingEntry = [...entries] .reverse() @@ -386,10 +469,58 @@ function persistActionOutcome( if (pageUrl) { matchingEntry.pageUrl = pageUrl; } + if (durationMs !== undefined) { + matchingEntry.durationMs = durationMs; + } + if (agentBrowserResult) { + matchingEntry.agentBrowserResult = agentBrowserResult; + } } }); } +function assertionPassed(rawOutput: string): boolean { + if (rawOutput.trim().toLowerCase() === 'true') { + return true; + } + try { + const parsed = JSON.parse(rawOutput) as { + data?: boolean | { visible?: unknown }; + }; + if (typeof parsed.data === 'boolean') { + return parsed.data; + } + return parsed.data?.visible === true; + } catch { + return false; + } +} + +function unwrapStructuredOutput(rawOutput: string): string { + try { + const parsed = JSON.parse(rawOutput) as { data?: unknown }; + if ( + typeof parsed.data === 'string' || + typeof parsed.data === 'number' || + typeof parsed.data === 'boolean' + ) { + return String(parsed.data); + } + if (typeof parsed.data !== 'object' || parsed.data === null) { + return rawOutput; + } + const data = parsed.data as Record; + for (const key of ['url', 'title', 'text', 'value', 'snapshot']) { + if (typeof data[key] === 'string') { + return data[key]; + } + } + return rawOutput; + } catch { + return rawOutput; + } +} + function updateSessionLog( logPath: string, update: (entries: SessionLogEntry[]) => void, diff --git a/src/commands/session.ts b/src/commands/session.ts index e1f63a7..447f99f 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -7,14 +7,8 @@ import { registerSession, unregisterSession, } from '../session/registry.js'; -import { - clearSession, - hasActiveSession, - loadSession, - saveSession, - type SessionState, -} from '../session/state.js'; -import { ownedProcessTreeIsAlive } from '../utils/process.js'; +import { sessionHasVerifiedLiveOwnership } from '../session/selection.js'; +import type { SessionState } from '../session/state.js'; type SessionStatus = 'active' | 'starting' | 'recovery' | 'stale'; @@ -71,18 +65,17 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise for (const session of sessions) { setAgentBrowserDefaults({ configPath: session.agentBrowserConfigPath, - socketDir: session.agentBrowserSocketDir, + namespace: session.agentBrowserNamespace, + socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); try { await cleanupFailedStart(session); - clearMatchingControlState(session); unregisterSession(session.sessionName); console.log(`${chalk.green('✓')} Cleaned ${session.sessionName}`); } catch (error) { failures += 1; session.lifecycleStatus = 'recovery'; session.cleanupError = error instanceof Error ? error.message : String(error); - persistMatchingControlState(session); registerSession(session); console.error(`${chalk.red('✗')} Kept ${session.sessionName}: ${session.cleanupError}`); } @@ -93,38 +86,6 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise } } -function clearMatchingControlState(session: SessionState): void { - const controlDir = session.controlDir ?? session.outputDir; - if (!hasActiveSession(controlDir)) return; - const activeSession = loadControlSessionSafely(controlDir); - if (activeSession?.sessionName === session.sessionName) { - clearSession(controlDir); - return; - } - throw new Error( - `Control state at ${controlDir} is corrupt or belongs to another session; it was not removed.`, - ); -} - -function persistMatchingControlState(session: SessionState): void { - const controlDir = session.controlDir ?? session.outputDir; - const activeSession = loadControlSessionSafely(controlDir); - if ( - !hasActiveSession(controlDir) || - activeSession?.sessionName === session.sessionName - ) { - saveSession(session, controlDir); - } -} - -function loadControlSessionSafely(controlDir: string): SessionState | null { - try { - return loadSession(controlDir); - } catch { - return null; - } -} - function selectSessionsToClean(options: SessionCleanOptions): SessionState[] { if (options.session) { const session = getRegisteredSession(options.session); @@ -162,10 +123,7 @@ function getSessionStatus(session: SessionState): SessionStatus { if (session.lifecycleStatus === 'starting') { return 'starting'; } - if ( - (session.browserProcess && ownedProcessTreeIsAlive(session.browserProcess)) || - (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) - ) { + if (sessionHasVerifiedLiveOwnership(session)) { return 'active'; } return 'stale'; diff --git a/src/commands/start.ts b/src/commands/start.ts index 948b3bc..b2ace86 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -4,24 +4,30 @@ import { loadConfig } from '../utils/config.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { ensureDevServer } from '../server/start.js'; import { getPageUrl, openBrowser } from '../browser/session.js'; +import { + preparePrivateNetworkEvidence, + startPrivateNetworkCapture, +} from '../browser/evidence.js'; import { startRecording } from '../browser/capture.js'; import { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js'; import { captureAgentBrowserProcessIdentity, prepareAgentBrowserSocketDir, + resolveAgentBrowserRuntimeDir, } from '../browser/runtime.js'; import { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js'; import { - saveSession, - loadSession, - hasActiveSession, - clearSession, + generateAgentBrowserNamespace, generateAgentBrowserSessionName, resolveSessionControlDir, type SessionState, } from '../session/state.js'; import { cleanupFailedStart } from '../session/lifecycle.js'; import { registerSession, unregisterSession } from '../session/registry.js'; +import { + listSessionsForControlDir, + sessionHasVerifiedLiveOwnership, +} from '../session/selection.js'; import { writeMetadata } from '../session/metadata.js'; import { captureGitProvenance } from '../session/manifest.js'; import { startOwnedEnvironment } from '../environment/runtime.js'; @@ -41,25 +47,49 @@ export async function startCommand(options: StartOptions): Promise { const config = loadConfig(); const controlDir = resolveSessionControlDir(config.output); - if (hasActiveSession(controlDir)) { - if (options.force) { - const existingSession = loadSession(controlDir); - if (existingSession) { - setAgentBrowserDefaults({ - configPath: existingSession.agentBrowserConfigPath || config.browser.configPath, - socketDir: existingSession.agentBrowserSocketDir, - }); + if (options.force) { + const existingSessions = listSessionsForControlDir(controlDir); + const liveSessions = existingSessions.filter(sessionHasVerifiedLiveOwnership); + if (liveSessions.length > 0) { + console.error( + chalk.red('✗') + + ' --force cannot clean a verified live ProofShot session.\n' + + chalk.dim( + ` Stop it explicitly with --session ${liveSessions[0].sessionName}.`, + ), + ); + process.exit(1); + return; + } + for (const existingSession of existingSessions) { + setAgentBrowserDefaults({ + configPath: + existingSession.agentBrowserConfigPath || config.browser.configPath, + namespace: existingSession.agentBrowserNamespace, + socketDir: + existingSession.agentBrowserSocketRoot || + existingSession.agentBrowserSocketDir, + }); + try { await cleanupFailedStart(existingSession); unregisterSession(existingSession.sessionName); + } catch (error) { + existingSession.lifecycleStatus = 'recovery'; + existingSession.cleanupError = + error instanceof Error ? error.message : String(error); + registerSession(existingSession); + console.error( + chalk.red('✗') + + ` Could not recover ${existingSession.sessionName}: ${existingSession.cleanupError}`, + ); + process.exit(1); + return; } - clearSession(controlDir); - console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session')); - } else { + } + if (existingSessions.length > 0) { console.log( - chalk.yellow('⚠ A session is already active.') + - chalk.dim(' Run "proofshot stop" first, or use --force to override.'), + chalk.yellow('⚠') + chalk.dim(' Cleaned up stale session state'), ); - return; } } @@ -69,14 +99,25 @@ export async function startCommand(options: StartOptions): Promise { const outputDir = path.resolve(config.output); const timestamp = generateTimestamp(); - const sessionDirName = generateSessionDirName(timestamp, options.description || null); - const sessionDir = path.join(outputDir, sessionDirName); const sessionName = generateAgentBrowserSessionName(timestamp); + const sessionDirName = generateSessionDirName(timestamp, options.description || null); + const sessionDir = path.join(outputDir, `${sessionDirName}_${sessionName}`); + const agentBrowserNamespace = generateAgentBrowserNamespace(sessionName); + let socketRoot: string; let socketDir: string; let browserExecutable: string | null; try { - socketDir = prepareAgentBrowserSocketDir(sessionName); + socketRoot = prepareAgentBrowserSocketDir( + sessionName, + process.env, + undefined, + agentBrowserNamespace, + ); + socketDir = resolveAgentBrowserRuntimeDir( + socketRoot, + agentBrowserNamespace, + ); browserExecutable = discoverBrowserExecutable({ configuredPath: options.browserExecutable || config.browser.executablePath, }); @@ -94,13 +135,18 @@ export async function startCommand(options: StartOptions): Promise { } if (browserExecutable) config.browser.executablePath = browserExecutable; - setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir }); + setAgentBrowserDefaults({ + configPath: config.browser.configPath, + namespace: agentBrowserNamespace, + socketDir: socketRoot, + }); ensureOutputDir(outputDir); ensureOutputDir(sessionDir); const videoPath = path.join(sessionDir, 'session.webm'); const serverErrorLog = path.join(sessionDir, 'server.log'); + const networkEvidence = preparePrivateNetworkEvidence(sessionDir); const provenance = captureGitProvenance(process.cwd(), [outputDir]); @@ -140,14 +186,24 @@ export async function startCommand(options: StartOptions): Promise { targetUrl: openUrl, headless: config.headless, agentBrowserSocketDir: socketDir, + agentBrowserSocketRoot: socketRoot, + agentBrowserNamespace, agentBrowserConfigPath: config.browser.configPath, + privateEvidenceDir: networkEvidence.privateDirectory, + networkHarPath: networkEvidence.harPath, + networkRequestsPath: networkEvidence.requestsPath, + networkSummaryPath: networkEvidence.summaryPath, + networkCaptureStarted: false, + networkCaptureActive: false, + networkEvidenceAvailable: false, + networkCaptureError: null, serverProcess: null, browserProcess: null, environment: null, viewport: { width: config.viewport.width, height: config.viewport.height }, }; - persistOwnedSession(session, controlDir); - const signalHandlers = installStartSignalHandlers(session, controlDir); + persistOwnedSession(session); + const signalHandlers = installStartSignalHandlers(session); let failureContext = 'start the session'; try { @@ -164,7 +220,7 @@ export async function startCommand(options: StartOptions): Promise { new Date(session.startedAt).getTime(), (environmentState) => { session.environment = environmentState; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); }, ); console.log(chalk.green('✓') + ' Environment and log capture started'); @@ -180,12 +236,12 @@ export async function startCommand(options: StartOptions): Promise { (startedServer) => { session.serverAlreadyRunning = false; session.serverProcess = startedServer.process; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); }, ); session.serverAlreadyRunning = false; session.serverProcess = server.process; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`); console.log(chalk.dim(` Server logs → ${serverErrorLog}`)); } else if (!config.environment) { @@ -195,7 +251,7 @@ export async function startCommand(options: StartOptions): Promise { failureContext = 'open browser'; console.log(chalk.dim('Opening browser...')); session.browserLaunchAttempted = true; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser); session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName); if (!session.browserProcess) { @@ -204,9 +260,16 @@ export async function startCommand(options: StartOptions): Promise { ); } session.targetUrl = getPageUrl(sessionName) || openUrl; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); console.log(chalk.green('✓') + ' Browser ready'); + failureContext = 'start private network capture'; + startPrivateNetworkCapture(sessionName); + session.networkCaptureStarted = true; + session.networkCaptureActive = true; + persistOwnedSession(session); + console.log(chalk.green('✓') + ' Private network capture started'); + failureContext = 'initialize recording'; const RECORDING_RETRIES = 3; const RETRY_DELAY_MS = 2000; @@ -245,7 +308,7 @@ export async function startCommand(options: StartOptions): Promise { const interruptionSignal = getTerminationSignal(error); try { await cleanupFailedStart(session); - clearOwnedSession(session, controlDir); + clearOwnedSession(session); console.error( chalk.red('✗') + ` Failed to ${failureContext}: ${error.message}\n` + @@ -255,7 +318,7 @@ export async function startCommand(options: StartOptions): Promise { session.lifecycleStatus = 'recovery'; session.cleanupError = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - persistOwnedSession(session, controlDir); + persistOwnedSession(session); console.error( chalk.red('✗') + ` Failed to ${failureContext}: ${error.message}\n` + @@ -275,7 +338,7 @@ export async function startCommand(options: StartOptions): Promise { session.recordingActive = true; session.lifecycleStatus = 'active'; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); signalHandlers.remove(); console.log(''); @@ -302,19 +365,16 @@ export async function startCommand(options: StartOptions): Promise { console.log(`When done, run: ${chalk.white('proofshot stop')}`); } -function persistOwnedSession(session: SessionState, controlDir: string): void { - saveSession(session, controlDir); +function persistOwnedSession(session: SessionState): void { registerSession(session); } -function clearOwnedSession(session: SessionState, controlDir: string): void { - clearSession(controlDir); +function clearOwnedSession(session: SessionState): void { unregisterSession(session.sessionName); } function installStartSignalHandlers( session: SessionState, - controlDir: string, ): { isHandling: () => boolean; remove: () => void } { let handlingSignal = false; const handlers = new Map void>(); @@ -327,13 +387,13 @@ function installStartSignalHandlers( handlingSignal = true; void cleanupFailedStart(session) .then(() => { - clearOwnedSession(session, controlDir); + clearOwnedSession(session); process.exit(signal === 'SIGINT' ? 130 : 143); }) .catch((error) => { session.lifecycleStatus = 'recovery'; session.cleanupError = error instanceof Error ? error.message : String(error); - persistOwnedSession(session, controlDir); + persistOwnedSession(session); process.exit(1); }); }; diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 4940de2..b789735 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -8,10 +8,13 @@ import { setAgentBrowserDefaults } from '../utils/exec.js'; import { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js'; import { stopRecording } from '../browser/capture.js'; import { - loadSession, - clearSession, + finalizePrivateNetworkCapture, + loadSanitizedNetworkSummary, + type SanitizedNetworkSummary, +} from '../browser/evidence.js'; +import { sanitizeDiagnosticMessage } from '../browser/provenance.js'; +import { resolveSessionControlDir, - saveSession, type SessionState, } from '../session/state.js'; import { @@ -20,6 +23,7 @@ import { stopOwnedServer, } from '../session/lifecycle.js'; import { registerSession, unregisterSession } from '../session/registry.js'; +import { resolveLiveSession } from '../session/selection.js'; import { stopOwnedEnvironment } from '../environment/runtime.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; import { @@ -72,6 +76,7 @@ function parseTimestampedServerLog( interface StopOptions { noClose?: boolean; + session?: string; } export async function stopCommand(options: StopOptions): Promise { @@ -79,7 +84,10 @@ export async function stopCommand(options: StopOptions): Promise { const controlDir = resolveSessionControlDir(config.output); // Load session state - const session = loadSession(controlDir); + const session = resolveLiveSession({ + controlDir, + sessionName: options.session, + }); if (!session) { console.log( chalk.dim('No active session found; all owned processes are already stopped.'), @@ -88,7 +96,8 @@ export async function stopCommand(options: StopOptions): Promise { } setAgentBrowserDefaults({ configPath: session.agentBrowserConfigPath || config.browser.configPath, - socketDir: session.agentBrowserSocketDir, + namespace: session.agentBrowserNamespace, + socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); if (session.bundleComplete) { @@ -97,7 +106,7 @@ export async function stopCommand(options: StopOptions): Promise { const browserSessionAddressable = canAddressOwnedBrowserSession(session); await stopOwnedBrowser(session); session.browserRetained = false; - clearOwnedSession(session, controlDir); + clearOwnedSession(session); if (browserSessionAddressable) { console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.'); } else { @@ -111,7 +120,7 @@ export async function stopCommand(options: StopOptions): Promise { chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'), ); } else { - clearOwnedSession(session, controlDir); + clearOwnedSession(session); console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.')); } return; @@ -122,7 +131,7 @@ export async function stopCommand(options: StopOptions): Promise { session.lifecycleStatus = 'stopping'; session.cleanupError = null; session.stoppedAt ||= new Date().toISOString(); - persistOwnedSession(session, controlDir); + persistOwnedSession(session); const retryingStoppedSession = !session.recordingActive; const recordingWasActive = session.recordingActive || Boolean(session.recordingStartedAt); @@ -192,7 +201,7 @@ export async function stopCommand(options: StopOptions): Promise { : 0; // Persist evidence before any cleanup step can fail. A retry must not turn // successfully collected browser facts into an "unavailable" claim. - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } else if (priorConsoleEvidenceAvailable) { if (fs.existsSync(consoleErrorsPath)) { consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8'); @@ -211,7 +220,46 @@ export async function stopCommand(options: StopOptions): Promise { } else { session.consoleEvidenceAvailable = false; session.consoleErrorCount = 0; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); + } + + let networkSummary: SanitizedNetworkSummary | null = + loadSanitizedNetworkSummary(session.networkSummaryPath); + if ( + browserSessionAvailable && + session.networkCaptureActive && + session.privateEvidenceDir && + session.networkHarPath && + session.networkRequestsPath && + session.networkSummaryPath + ) { + console.log(chalk.dim('Collecting private network evidence...')); + try { + networkSummary = finalizePrivateNetworkCapture(session.sessionName, { + privateDirectory: session.privateEvidenceDir, + harPath: session.networkHarPath, + requestsPath: session.networkRequestsPath, + summaryPath: session.networkSummaryPath, + }); + session.networkEvidenceAvailable = true; + session.networkCaptureError = null; + } catch (error) { + session.networkEvidenceAvailable = false; + session.networkCaptureError = + sanitizeDiagnosticMessage( + error instanceof Error ? error.message : String(error), + ) || 'network capture failed'; + console.log( + chalk.yellow('⚠') + + ` Private network evidence was incomplete: ${session.networkCaptureError}`, + ); + } + session.networkCaptureActive = false; + persistOwnedSession(session); + } else if (session.networkCaptureStarted && !networkSummary) { + session.networkEvidenceAvailable = false; + session.networkCaptureActive = false; + persistOwnedSession(session); } // Step 2: Stop recording @@ -220,7 +268,7 @@ export async function stopCommand(options: StopOptions): Promise { stopRecording(session.sessionName); } session.recordingActive = false; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); // Step 3: Close browser (unless --no-close) const cleanupErrors: unknown[] = []; @@ -245,7 +293,7 @@ export async function stopCommand(options: StopOptions): Promise { session.environment.healthFailures = captures .filter((capture) => !processIdentityMatches(capture.process)) .map((capture) => capture.sourceId); - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } const finalizedEnvironment = session.environment; if (session.environment && !session.environmentStopped) { @@ -253,7 +301,7 @@ export async function stopCommand(options: StopOptions): Promise { try { await stopOwnedEnvironment(session.environment); session.environmentStopped = true; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } catch (error) { cleanupErrors.push(error); } @@ -278,7 +326,7 @@ export async function stopCommand(options: StopOptions): Promise { session.lifecycleStatus = 'recovery'; session.cleanupError = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - persistOwnedSession(session, controlDir); + persistOwnedSession(session); throw cleanupError; } @@ -326,7 +374,7 @@ export async function stopCommand(options: StopOptions): Promise { trimOffsetSec = recordingStartOffsetSec + videoTrimOffsetSec; session.videoTrimComplete = true; session.trimOffsetSec = trimOffsetSec; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } // Step 6: Count errors @@ -345,7 +393,7 @@ export async function stopCommand(options: StopOptions): Promise { if (browserSessionAvailable) { session.consoleEvidenceAvailable = true; session.consoleErrorCount = consoleErrorCount; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } // Extract errors from server log using multi-language patterns @@ -400,7 +448,7 @@ export async function stopCommand(options: StopOptions): Promise { } if (!session.sessionLogAdjusted) { session.sessionLogAdjusted = true; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } // Apply trimOffsetSec to log entries (same adjustment as session log) @@ -425,6 +473,8 @@ export async function stopCommand(options: StopOptions): Promise { consoleEntries: viewerConsoleEntries, serverEntries: viewerServerEntries, environment: finalizedEnvironment, + networkSummary, + networkEvidenceRequired: session.networkCaptureStarted === true, }); const viewerPath = writeViewer(sessionDir, { @@ -468,9 +518,9 @@ export async function stopCommand(options: StopOptions): Promise { session.browserRetained = Boolean(options.noClose); if (session.browserRetained) { session.lifecycleStatus = 'active'; - persistOwnedSession(session, controlDir); + persistOwnedSession(session); } else { - clearOwnedSession(session, controlDir); + clearOwnedSession(session); } // Step 9: Print results @@ -598,13 +648,11 @@ function writeTextFileAtomically(filePath: string, contents: string): void { } } -function persistOwnedSession(session: SessionState, controlDir: string): void { - saveSession(session, controlDir); +function persistOwnedSession(session: SessionState): void { registerSession(session); } -function clearOwnedSession(session: SessionState, controlDir: string): void { - clearSession(controlDir); +function clearOwnedSession(session: SessionState): void { unregisterSession(session.sessionName); } From c59c4014619700644405212f531bbfe1be2f865f Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:32:10 +0000 Subject: [PATCH 03/74] test(session): cover concurrent private evidence Co-authored-by: Codex --- src/commands/clean.test.ts | 15 +- src/commands/lifecycle.integration.test.ts | 267 +++++++++++++++++++-- src/commands/session.test.ts | 18 +- src/commands/start.test.ts | 44 +++- src/commands/stop.test.ts | 15 +- 5 files changed, 307 insertions(+), 52 deletions(-) diff --git a/src/commands/clean.test.ts b/src/commands/clean.test.ts index 9355030..3fe89d0 100644 --- a/src/commands/clean.test.ts +++ b/src/commands/clean.test.ts @@ -3,8 +3,16 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ loadConfig: vi.fn() })); +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + listSessionsForControlDir: vi.fn(), + sessionHasVerifiedLiveOwnership: vi.fn(), +})); vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); +vi.mock('../session/selection.js', () => ({ + listSessionsForControlDir: mocks.listSessionsForControlDir, + sessionHasVerifiedLiveOwnership: mocks.sessionHasVerifiedLiveOwnership, +})); import { cleanCommand } from './clean.js'; @@ -15,6 +23,8 @@ beforeEach(() => { fs.mkdirSync(cache, { recursive: true }); root = fs.mkdtempSync(path.join(cache, 'proofshot-clean-test-')); mocks.loadConfig.mockReturnValue({ output: root }); + mocks.listSessionsForControlDir.mockReturnValue([{ sessionName: 'ps-live' }]); + mocks.sessionHasVerifiedLiveOwnership.mockReturnValue(true); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 0}`); @@ -29,14 +39,11 @@ afterEach(() => { describe('cleanCommand', () => { it('refuses to discard active exact-process ownership metadata', async () => { - const controlPath = path.join(root, '.session.json'); const evidencePath = path.join(root, 'evidence.txt'); - fs.writeFileSync(controlPath, JSON.stringify({ browserRetained: true })); fs.writeFileSync(evidencePath, 'keep'); await expect(cleanCommand()).rejects.toThrow('process.exit:1'); - expect(fs.readFileSync(controlPath, 'utf-8')).toContain('browserRetained'); expect(fs.readFileSync(evidencePath, 'utf-8')).toBe('keep'); expect(console.error).toHaveBeenCalledWith( expect.stringContaining('Run "proofshot stop" first'), diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index b594d43..f960957 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -114,11 +114,21 @@ if (sessionIndex >= 0) { } const configIndex = args.indexOf('--config'); if (configIndex >= 0) args.splice(configIndex, 2); -const socketDir = process.env.AGENT_BROWSER_SOCKET_DIR; -if (!socketDir) { +const jsonIndex = args.indexOf('--json'); +const jsonOutput = jsonIndex >= 0; +if (jsonIndex >= 0) args.splice(jsonIndex, 1); +const socketRoot = process.env.AGENT_BROWSER_SOCKET_DIR; +if (!socketRoot) { process.stderr.write('missing AGENT_BROWSER_SOCKET_DIR\\n'); process.exit(2); } +const namespace = (process.env.AGENT_BROWSER_NAMESPACE || '') + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +const socketDir = namespace + ? path.join(socketRoot, 'namespaces', namespace, 'run') + : socketRoot; fs.mkdirSync(socketDir, { recursive: true }); const pidPath = path.join(socketDir, session + '.pid'); const statePath = path.join(socketDir, session + '.fake.json'); @@ -127,6 +137,7 @@ const detail = args.slice(1); fs.appendFileSync(process.env.FAKE_AGENT_BROWSER_LOG, JSON.stringify({ pid: process.pid, session, + namespace, socketDir, home: process.env.HOME, command, @@ -164,7 +175,11 @@ if (command === 'open') { } if (command === 'get' && detail[0] === 'url') { const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); - process.stdout.write(state.url + '\\n'); + process.stdout.write( + jsonOutput + ? JSON.stringify({ success: true, data: { url: state.url } }) + '\\n' + : state.url + '\\n', + ); process.exit(0); } if (command === 'console' && detail.includes('--json')) { @@ -179,6 +194,40 @@ if (command === 'errors') { process.stdout.write('No errors\\n'); process.exit(0); } +if (command === 'network' && detail[0] === 'har' && detail[1] === 'start') { + process.exit(0); +} +if (command === 'network' && detail[0] === 'har' && detail[1] === 'stop') { + const harPath = detail[2]; + fs.writeFileSync(harPath, JSON.stringify({ + log: { + entries: [ + { + time: 12.5, + request: { + method: 'GET', + url: 'https://example.invalid/api/items?token=secret-token', + headers: [{ name: 'authorization', value: 'Bearer secret-token' }], + }, + response: { + status: 200, + content: { + mimeType: 'application/json', + text: 'private-response-body', + }, + }, + }, + ], + }, + }, null, 2)); + if (jsonOutput) { + process.stdout.write(JSON.stringify({ + success: true, + data: { path: harPath, requestCount: 1 }, + }) + '\\n'); + } + process.exit(0); +} if (command === 'close') { try { const pid = Number(fs.readFileSync(pidPath, 'utf8')); @@ -188,6 +237,9 @@ if (command === 'close') { try { fs.unlinkSync(statePath); } catch {} process.exit(0); } +if (jsonOutput) { + process.stdout.write(JSON.stringify({ success: true, data: { command } }) + '\\n'); +} process.exit(0); `, ); @@ -222,6 +274,7 @@ function isolatedEnvironment( ...process.env, HOME: path.join(audit, 'isolated-home'), XDG_CACHE_HOME: path.join(audit, 'isolated-cache'), + XDG_STATE_HOME: path.join(audit, 'isolated-home', '.local', 'state'), PATH: `${tools.binDir}${path.delimiter}${process.env.PATH || ''}`, FAKE_AGENT_BROWSER_LOG: tools.browserLog, ...overrides, @@ -244,6 +297,20 @@ function runCli( }); } +function registryDirectory(env: NodeJS.ProcessEnv): string { + return path.join(env.XDG_STATE_HOME!, 'proofshot', 'sessions'); +} + +function readRegisteredSessions(env: NodeJS.ProcessEnv): any[] { + const directory = registryDirectory(env); + if (!fs.existsSync(directory)) { + return []; + } + return fs.readdirSync(directory).map((file) => + JSON.parse(fs.readFileSync(path.join(directory, file), 'utf-8')), + ); +} + beforeAll(() => { execFileSync('npm', ['run', 'build'], { cwd: repoRoot, stdio: 'ignore' }); }, 30000); @@ -258,6 +325,167 @@ afterEach(async () => { }); describe('isolated CLI lifecycle', () => { + it('isolates two sessions and requires an exact target only while ambiguous', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const firstStart = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/first', + '--browser-executable', + tools.browserPath, + ]); + expect(firstStart.status, `${firstStart.stdout}\n${firstStart.stderr}`).toBe(0); + + const secondStart = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/second', + '--browser-executable', + tools.browserPath, + ]); + expect(secondStart.status, `${secondStart.stdout}\n${secondStart.stderr}`).toBe(0); + + const registryDir = registryDirectory(env); + const sessions = readRegisteredSessions(env) + .sort((left, right) => left.targetUrl.localeCompare(right.targetUrl)); + expect(sessions).toHaveLength(2); + cleanupProcesses.push(...sessions.map((session) => session.browserProcess)); + expect(sessions[0].sessionDir).not.toBe(sessions[1].sessionDir); + expect(sessions[0].sessionName).not.toBe(sessions[1].sessionName); + expect(sessions[0].agentBrowserNamespace).toEqual(expect.any(String)); + expect(sessions[0].agentBrowserNamespace).not.toBe( + sessions[1].agentBrowserNamespace, + ); + + const ambiguousExec = runCli(audit, env, ['exec', 'get', 'url']); + expect(ambiguousExec.status).toBe(1); + expect(ambiguousExec.stderr).toContain('Multiple active ProofShot sessions'); + + for (const session of sessions) { + const explicitExec = runCli(audit, env, [ + 'exec', + '--session', + session.sessionName, + 'get', + 'url', + ]); + expect(explicitExec.status, `${explicitExec.stdout}\n${explicitExec.stderr}`).toBe(0); + expect(explicitExec.stdout).toContain(session.targetUrl); + } + + const secret = 'proofshot-secret-value'; + const secretExec = runCli(audit, env, [ + 'exec', + '--session', + sessions[0].sessionName, + 'fill', + '@e1', + secret, + ]); + expect(secretExec.status, `${secretExec.stdout}\n${secretExec.stderr}`).toBe(0); + const actionLogPath = path.join(sessions[0].sessionDir, 'session-log.json'); + const actionLogText = fs.readFileSync(actionLogPath, 'utf-8'); + const actionEntries = JSON.parse(actionLogText); + const secretEntry = actionEntries.at(-1); + expect(actionLogText).not.toContain(secret); + expect(secretEntry).toMatchObject({ + action: 'fill @e1 [REDACTED]', + category: 'hybrid', + intent: { + command: 'fill', + summary: 'fill @e1 [REDACTED]', + }, + outcome: 'passed', + durationMs: expect.any(Number), + agentBrowserResult: { + success: true, + evidencePath: expect.stringMatching(/^private\/agent-browser\/actions\//), + }, + }); + const privateResultPath = path.join( + sessions[0].sessionDir, + secretEntry.agentBrowserResult.evidencePath, + ); + expect(fs.statSync(privateResultPath).mode & 0o777).toBe(0o600); + + const ambiguousStop = runCli(audit, env, ['stop']); + expect(ambiguousStop.status).toBe(1); + expect(ambiguousStop.stderr).toContain('Multiple active ProofShot sessions'); + + const explicitStop = runCli(audit, env, [ + 'stop', + '--session', + sessions[0].sessionName, + ]); + expect(explicitStop.status, `${explicitStop.stdout}\n${explicitStop.stderr}`).toBe(0); + await waitForProcessExit(sessions[0].browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[0].browserProcess), 1); + + const implicitStop = runCli(audit, env, ['stop']); + expect(implicitStop.status, `${implicitStop.stdout}\n${implicitStop.stderr}`).toBe(0); + await waitForProcessExit(sessions[1].browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[1].browserProcess), 1); + expect(fs.readdirSync(registryDir)).toEqual([]); + + for (const session of sessions) { + const privateEvidenceDir = path.join( + session.sessionDir, + 'private', + 'agent-browser', + ); + const harPath = path.join(privateEvidenceDir, 'network.har'); + const requestsPath = path.join(privateEvidenceDir, 'requests.json'); + const summaryPath = path.join(session.sessionDir, 'network-summary.json'); + expect(fs.statSync(privateEvidenceDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(harPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(requestsPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(harPath, 'utf-8')).toContain( + 'private-response-body', + ); + const summaryText = fs.readFileSync(summaryPath, 'utf-8'); + expect(summaryText).not.toContain('private-response-body'); + expect(summaryText).not.toContain('secret-token'); + expect(JSON.parse(summaryText)).toEqual({ + version: 1, + requestCount: 1, + requests: [ + { + endpoint: 'https://example.invalid/api/items', + method: 'GET', + status: 200, + durationMs: 12.5, + error: null, + }, + ], + }); + const manifest = JSON.parse( + fs.readFileSync( + path.join(session.sessionDir, 'artifact-manifest.json'), + 'utf-8', + ), + ); + expect(manifest.artifacts).toContainEqual( + expect.objectContaining({ + kind: 'network-summary', + path: 'network-summary.json', + }), + ); + expect( + manifest.artifacts.some((artifact: { path: string }) => + artifact.path.startsWith('private/'), + ), + ).toBe(false); + } + }, 30000); + it('shares custom-output control across processes and stops idempotently', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -307,9 +535,9 @@ describe('isolated CLI lifecycle', () => { expect(start.stdout).toContain(`Target: ${intendedUrl}`); const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); - expect(fs.existsSync(controlPath)).toBe(true); + expect(fs.existsSync(controlPath)).toBe(false); expect(fs.existsSync(path.join(customOutput, '.session.json'))).toBe(false); - const state = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + const [state] = readRegisteredSessions(env); expect(state).toMatchObject({ outputDir: customOutput, targetUrl: intendedUrl, @@ -351,7 +579,11 @@ describe('isolated CLI lifecycle', () => { startTime: `${state.browserProcess.startTime}-recycled`, }, }; - fs.writeFileSync(controlPath, JSON.stringify(mismatchedState, null, 2) + '\n'); + const registryPath = path.join( + registryDirectory(env), + `${state.sessionName}.json`, + ); + fs.writeFileSync(registryPath, JSON.stringify(mismatchedState, null, 2) + '\n'); const mismatchedExec = runCli(nestedCwd, env, ['exec', 'get', 'url']); expect(mismatchedExec.status).toBe(1); expect(mismatchedExec.stderr).toContain( @@ -360,11 +592,12 @@ describe('isolated CLI lifecycle', () => { expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe( browserLogBeforeMismatchedExec, ); - fs.writeFileSync(controlPath, JSON.stringify(state, null, 2) + '\n'); + fs.writeFileSync(registryPath, JSON.stringify(state, null, 2) + '\n'); const stop = runCli(nestedCwd, env, ['stop']); expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); expect(fs.existsSync(controlPath)).toBe(false); + expect(fs.existsSync(registryPath)).toBe(false); await waitForProcessExit(ownedServerPid); await waitForProcessExit(state.serverProcess.pid); await waitForProcessExit(state.browserProcess.pid); @@ -499,13 +732,16 @@ describe('isolated CLI lifecycle', () => { tools.browserPath, ]); expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); - const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); - const initialState = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + const [initialState] = readRegisteredSessions(env); + const registryPath = path.join( + registryDirectory(env), + `${initialState.sessionName}.json`, + ); cleanupProcesses.push(initialState.browserProcess); const retainedStop = runCli(audit, env, ['stop', '--no-close']); expect(retainedStop.status, `${retainedStop.stdout}\n${retainedStop.stderr}`).toBe(0); - const retainedState = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + const retainedState = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(retainedState).toMatchObject({ recordingActive: false, bundleComplete: true, @@ -521,7 +757,7 @@ describe('isolated CLI lifecycle', () => { expect(finalStop.status, `${finalStop.stdout}\n${finalStop.stderr}`).toBe(0); expect(finalStop.stdout).toContain('Retained browser closed'); await waitForProcessExit(initialState.browserProcess.pid); - expect(fs.existsSync(controlPath)).toBe(false); + expect(fs.existsSync(registryPath)).toBe(false); expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); cleanupProcesses.splice(cleanupProcesses.indexOf(initialState.browserProcess), 1); @@ -557,17 +793,16 @@ describe('isolated CLI lifecycle', () => { tools.browserPath, ]); expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); - const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); - const state = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + const [state] = readRegisteredSessions(env); const stop = runCli(audit, env, ['stop']); expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); await waitForProcessExit(state.browserProcess.pid); - expect(fs.existsSync(controlPath)).toBe(false); + expect(readRegisteredSessions(env)).toEqual([]); expect(processIsAlive(unrelated.pid!)).toBe(true); } - const registryDir = path.join(env.HOME!, '.local', 'state', 'proofshot', 'sessions'); + const registryDir = registryDirectory(env); expect(fs.existsSync(registryDir) ? fs.readdirSync(registryDir) : []).toEqual([]); }, 60000); @@ -626,7 +861,7 @@ describe('isolated CLI lifecycle', () => { await waitForProcessExit(daemon.daemonPid); const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); expect(fs.existsSync(controlPath)).toBe(false); - const registryDir = path.join(env.HOME!, '.local', 'state', 'proofshot', 'sessions'); + const registryDir = registryDirectory(env); expect(fs.existsSync(registryDir) ? fs.readdirSync(registryDir) : []).toEqual([]); if (daemonIdentity) { cleanupProcesses.splice(cleanupProcesses.indexOf(daemonIdentity), 1); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index d06befb..a1e27dd 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -68,48 +68,42 @@ describe('session commands', () => { ); }); - it('cleans one exact registered session and removes both state records', async () => { + it('cleans one exact registered session and removes its registry record', async () => { const session = buildSession({ lifecycleStatus: 'recovery' }); mocks.getRegisteredSession.mockReturnValue(session); - mocks.loadSession.mockReturnValue(session); await sessionCleanCommand({ session: session.sessionName }); expect(mocks.cleanupFailedStart).toHaveBeenCalledWith(session); expect(mocks.setAgentBrowserDefaults).toHaveBeenCalledWith({ configPath: session.agentBrowserConfigPath, + namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketDir, }); - expect(mocks.clearSession).toHaveBeenCalledWith(session.controlDir); expect(mocks.unregisterSession).toHaveBeenCalledWith(session.sessionName); }); it('retains recovery state when exact cleanup still fails', async () => { const session = buildSession({ lifecycleStatus: 'recovery' }); mocks.getRegisteredSession.mockReturnValue(session); - mocks.loadSession.mockReturnValue(session); mocks.cleanupFailedStart.mockRejectedValue(new Error('identity mismatch')); await sessionCleanCommand({ session: session.sessionName }); expect(session.cleanupError).toBe('identity mismatch'); - expect(mocks.saveSession).toHaveBeenCalledWith(session, session.controlDir); expect(mocks.registerSession).toHaveBeenCalledWith(session); expect(mocks.unregisterSession).not.toHaveBeenCalled(); }); - it('does not clear or overwrite a newer session control record', async () => { + it('does not unregister another concurrent session', async () => { const session = buildSession({ lifecycleStatus: 'recovery' }); mocks.getRegisteredSession.mockReturnValue(session); - mocks.loadSession.mockReturnValue(buildSession({ sessionName: 'ps-newer-session' })); await sessionCleanCommand({ session: session.sessionName }); - expect(mocks.clearSession).not.toHaveBeenCalled(); - expect(mocks.saveSession).not.toHaveBeenCalled(); - expect(mocks.unregisterSession).not.toHaveBeenCalled(); - expect(mocks.registerSession).toHaveBeenCalledWith(session); - expect(session.cleanupError).toMatch(/belongs to another session/); + expect(mocks.unregisterSession).toHaveBeenCalledTimes(1); + expect(mocks.unregisterSession).toHaveBeenCalledWith(session.sessionName); + expect(mocks.unregisterSession).not.toHaveBeenCalledWith('ps-newer-session'); }); }); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 31c3026..ea39d77 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ getPageUrl: vi.fn(), closeBrowser: vi.fn(), startRecording: vi.fn(), + preparePrivateNetworkEvidence: vi.fn(), + startPrivateNetworkCapture: vi.fn(), ensureOutputDir: vi.fn(), generateTimestamp: vi.fn(), generateSessionDirName: vi.fn(), @@ -15,12 +17,14 @@ const mocks = vi.hoisted(() => ({ loadSession: vi.fn(), hasActiveSession: vi.fn(), clearSession: vi.fn(), + generateAgentBrowserNamespace: vi.fn(), generateAgentBrowserSessionName: vi.fn(), resolveSessionControlDir: vi.fn(), writeMetadata: vi.fn(), discoverBrowserExecutable: vi.fn(), browserSetupError: vi.fn(), prepareAgentBrowserSocketDir: vi.fn(), + resolveAgentBrowserRuntimeDir: vi.fn(), captureAgentBrowserProcessIdentity: vi.fn(), cleanupFailedStart: vi.fn(), startOwnedEnvironment: vi.fn(), @@ -46,6 +50,10 @@ vi.mock('../browser/session.js', () => ({ vi.mock('../browser/capture.js', () => ({ startRecording: mocks.startRecording, })); +vi.mock('../browser/evidence.js', () => ({ + preparePrivateNetworkEvidence: mocks.preparePrivateNetworkEvidence, + startPrivateNetworkCapture: mocks.startPrivateNetworkCapture, +})); vi.mock('../browser/discovery.js', () => ({ discoverBrowserExecutable: mocks.discoverBrowserExecutable, @@ -54,6 +62,7 @@ vi.mock('../browser/discovery.js', () => ({ vi.mock('../browser/runtime.js', () => ({ prepareAgentBrowserSocketDir: mocks.prepareAgentBrowserSocketDir, + resolveAgentBrowserRuntimeDir: mocks.resolveAgentBrowserRuntimeDir, captureAgentBrowserProcessIdentity: mocks.captureAgentBrowserProcessIdentity, })); @@ -68,6 +77,7 @@ vi.mock('../session/state.js', () => ({ loadSession: mocks.loadSession, hasActiveSession: mocks.hasActiveSession, clearSession: mocks.clearSession, + generateAgentBrowserNamespace: mocks.generateAgentBrowserNamespace, generateAgentBrowserSessionName: mocks.generateAgentBrowserSessionName, resolveSessionControlDir: mocks.resolveSessionControlDir, })); @@ -117,7 +127,11 @@ describe('startCommand', () => { mocks.generateTimestamp.mockReturnValue('2026-04-08_07-28-00'); mocks.generateSessionDirName.mockReturnValue('2026-04-08_07-28-00_test'); mocks.generateAgentBrowserSessionName.mockReturnValue('ps-2026-04-deadbeef1234'); + mocks.generateAgentBrowserNamespace.mockReturnValue('psn-deadbeef1234'); mocks.prepareAgentBrowserSocketDir.mockReturnValue('/run/user/1000/proofshot'); + mocks.resolveAgentBrowserRuntimeDir.mockReturnValue( + '/run/user/1000/proofshot/namespaces/psn-deadbeef1234/run', + ); mocks.discoverBrowserExecutable.mockReturnValue('/usr/bin/chromium'); mocks.getPageUrl.mockReturnValue(''); mocks.captureAgentBrowserProcessIdentity.mockReturnValue({ @@ -128,6 +142,12 @@ describe('startCommand', () => { }); mocks.cleanupFailedStart.mockResolvedValue(undefined); mocks.startOwnedEnvironment.mockResolvedValue(null); + mocks.preparePrivateNetworkEvidence.mockReturnValue({ + privateDirectory: '/audit/private/agent-browser', + harPath: '/audit/private/agent-browser/network.har', + requestsPath: '/audit/private/agent-browser/requests.json', + summaryPath: '/audit/network-summary.json', + }); mocks.execSync.mockImplementation((command: string) => { if (command === 'git branch --show-current') return 'main'; if (command === 'git rev-parse HEAD') return 'deadbeef'; @@ -152,13 +172,11 @@ describe('startCommand', () => { await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); expect(mocks.startRecording).toHaveBeenCalledTimes(3); expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); - expect(mocks.saveSession).toHaveBeenCalled(); expect(mocks.registerSession).toHaveBeenCalled(); - expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); expect(mocks.unregisterSession).toHaveBeenCalledWith('ps-2026-04-deadbeef1234'); }); - it('clears discoverable control state when recording never starts', async () => { + it('clears discoverable registry state when recording never starts', async () => { mocks.startRecording.mockImplementation(() => { throw new Error('Recording already active'); }); @@ -169,7 +187,7 @@ describe('startCommand', () => { await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); expect(mocks.startRecording).toHaveBeenCalledTimes(3); expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); - expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); + expect(mocks.unregisterSession).toHaveBeenCalledWith('ps-2026-04-deadbeef1234'); }); it('closes the session-scoped browser when browser open fails', async () => { @@ -182,7 +200,7 @@ describe('startCommand', () => { await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); expect(mocks.startRecording).not.toHaveBeenCalled(); - expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); + expect(mocks.unregisterSession).toHaveBeenCalledWith('ps-2026-04-deadbeef1234'); }); it('persists the intended target and stable control path with custom evidence output', async () => { @@ -198,15 +216,18 @@ describe('startCommand', () => { 'ps-2026-04-deadbeef1234', expect.objectContaining({ executablePath: '/usr/bin/chromium' }), ); - const finalState = mocks.saveSession.mock.calls.at(-1)?.[0]; + const finalState = mocks.registerSession.mock.calls.at(-1)?.[0]; expect(finalState).toMatchObject({ outputDir: '/audit/custom-evidence', targetUrl: 'http://127.0.0.1:43171/getting-started', recordingActive: true, lifecycleStatus: 'active', - agentBrowserSocketDir: '/run/user/1000/proofshot', + agentBrowserSocketDir: + '/run/user/1000/proofshot/namespaces/psn-deadbeef1234/run', + agentBrowserSocketRoot: '/run/user/1000/proofshot', + agentBrowserNamespace: 'psn-deadbeef1234', }); - expect(mocks.saveSession.mock.calls.every((call) => call[1] === '/project/proofshot-artifacts')).toBe(true); + expect(finalState.controlDir).toBe('/project/proofshot-artifacts'); }); it('persists server ownership before waiting for readiness', async () => { @@ -233,9 +254,8 @@ describe('startCommand', () => { await vi.runAllTimersAsync(); await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); - expect(mocks.saveSession).toHaveBeenCalledWith( + expect(mocks.registerSession).toHaveBeenCalledWith( expect.objectContaining({ serverProcess }), - '/project/proofshot-artifacts', ); expect(mocks.cleanupFailedStart).toHaveBeenCalledWith( expect.objectContaining({ serverProcess }), @@ -251,7 +271,6 @@ describe('startCommand', () => { const commandPromise = startCommand({}).catch((error) => error); await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); - expect(mocks.clearSession).not.toHaveBeenCalled(); expect(mocks.unregisterSession).not.toHaveBeenCalled(); expect(mocks.registerSession).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -300,12 +319,11 @@ describe('startCommand', () => { expect(mocks.registerSession).toHaveBeenCalledWith( expect.objectContaining({ environment: environmentState }), ); - expect(mocks.saveSession).toHaveBeenLastCalledWith( + expect(mocks.registerSession).toHaveBeenLastCalledWith( expect.objectContaining({ recordingStartedAt: expect.any(String), recordingActive: true, }), - '/project/proofshot-artifacts', ); }); }); diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 2d690e6..0b6e89c 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -51,6 +51,9 @@ vi.mock('../session/registry.js', () => ({ registerSession: mocks.registerSession, unregisterSession: mocks.unregisterSession, })); +vi.mock('../session/selection.js', () => ({ + resolveLiveSession: mocks.loadSession, +})); vi.mock('../environment/runtime.js', () => ({ stopOwnedEnvironment: mocks.stopOwnedEnvironment, })); @@ -174,8 +177,8 @@ describe('stopCommand retryability', () => { await expect(stopCommand({})).rejects.toThrow('simulated viewer write failure'); expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); expect(mocks.stopOwnedServer).toHaveBeenCalledWith(session); - expect(mocks.clearSession).not.toHaveBeenCalled(); - expect(mocks.saveSession).toHaveBeenCalledWith( + expect(mocks.unregisterSession).not.toHaveBeenCalled(); + expect(mocks.registerSession).toHaveBeenCalledWith( expect.objectContaining({ recordingActive: false, bundleComplete: false, @@ -184,7 +187,6 @@ describe('stopCommand retryability', () => { sessionLogAdjusted: true, videoPath: path.join(session.sessionDir, 'session.mp4'), }), - path.join(root, 'proofshot-artifacts'), ); expect(trimCalls).toBe(1); expect(conversionCalls).toBe(1); @@ -212,7 +214,7 @@ describe('stopCommand retryability', () => { consoleOutput: 'console evidence', viewport: session.viewport, }); - expect(mocks.clearSession).toHaveBeenCalledWith(path.join(root, 'proofshot-artifacts')); + expect(mocks.unregisterSession).toHaveBeenCalledWith(session.sessionName); expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); }); @@ -285,7 +287,7 @@ describe('stopCommand retryability', () => { expect(mocks.stopRecording).not.toHaveBeenCalled(); expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); expect(mocks.stopOwnedServer).toHaveBeenCalledWith(session); - expect(mocks.clearSession).toHaveBeenCalled(); + expect(mocks.unregisterSession).toHaveBeenCalledWith(session.sessionName); expect(mocks.writeViewer).toHaveBeenCalledWith( session.sessionDir, expect.objectContaining({ consoleEvidenceAvailable: false }), @@ -307,7 +309,7 @@ describe('stopCommand retryability', () => { await stopCommand({}); expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); - expect(mocks.clearSession).toHaveBeenCalled(); + expect(mocks.unregisterSession).toHaveBeenCalledWith(session.sessionName); expect(console.log).toHaveBeenCalledWith( expect.stringContaining('skipped session-name close'), ); @@ -331,7 +333,6 @@ describe('stopCommand retryability', () => { lifecycleStatus: 'recovery', cleanupError: 'Cleanup failed: environment identity mismatch', }); - expect(mocks.clearSession).not.toHaveBeenCalled(); expect(mocks.unregisterSession).not.toHaveBeenCalled(); }); }); From 44c92e5e08447809ec660395462d42297598d459 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:33:40 +0000 Subject: [PATCH 04/74] feat(browser): contain sessions to target domains Co-authored-by: Codex --- src/commands/exec.ts | 1 + src/commands/session.ts | 1 + src/commands/start.ts | 22 ++++++++++++++++++++-- src/commands/stop.ts | 1 + src/session/registry.ts | 8 ++++++++ src/session/state.ts | 1 + src/utils/config.ts | 2 ++ src/utils/exec.ts | 15 ++++++++++++--- 8 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 267c9ad..fdb7c71 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -259,6 +259,7 @@ export async function execCommand( sessionName: options.session, }); setAgentBrowserDefaults({ + allowedDomains: session?.agentBrowserAllowedDomains, configPath: session?.agentBrowserConfigPath || config.browser.configPath, namespace: session?.agentBrowserNamespace, socketDir: session?.agentBrowserSocketRoot || session?.agentBrowserSocketDir, diff --git a/src/commands/session.ts b/src/commands/session.ts index 447f99f..52eee3a 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -64,6 +64,7 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise let failures = 0; for (const session of sessions) { setAgentBrowserDefaults({ + allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, diff --git a/src/commands/start.ts b/src/commands/start.ts index b2ace86..c053842 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -63,6 +63,7 @@ export async function startCommand(options: StartOptions): Promise { } for (const existingSession of existingSessions) { setAgentBrowserDefaults({ + allowedDomains: existingSession.agentBrowserAllowedDomains, configPath: existingSession.agentBrowserConfigPath || config.browser.configPath, namespace: existingSession.agentBrowserNamespace, @@ -97,6 +98,12 @@ export async function startCommand(options: StartOptions): Promise { if (options.output) config.output = options.output; if (options.headed !== undefined) config.headless = !options.headed; + const baseUrl = `http://localhost:${config.devServer.port}`; + const openUrl = options.url || baseUrl; + const agentBrowserAllowedDomains = resolveAgentBrowserAllowedDomains( + openUrl, + config.browser.allowedDomains, + ); const outputDir = path.resolve(config.output); const timestamp = generateTimestamp(); const sessionName = generateAgentBrowserSessionName(timestamp); @@ -136,6 +143,7 @@ export async function startCommand(options: StartOptions): Promise { if (browserExecutable) config.browser.executablePath = browserExecutable; setAgentBrowserDefaults({ + allowedDomains: agentBrowserAllowedDomains, configPath: config.browser.configPath, namespace: agentBrowserNamespace, socketDir: socketRoot, @@ -157,8 +165,6 @@ export async function startCommand(options: StartOptions): Promise { description: options.description || null, }); - const baseUrl = `http://localhost:${config.devServer.port}`; - const openUrl = options.url || baseUrl; const session: SessionState = { startedAt: new Date().toISOString(), startDirectory: process.cwd(), @@ -188,6 +194,7 @@ export async function startCommand(options: StartOptions): Promise { agentBrowserSocketDir: socketDir, agentBrowserSocketRoot: socketRoot, agentBrowserNamespace, + agentBrowserAllowedDomains, agentBrowserConfigPath: config.browser.configPath, privateEvidenceDir: networkEvidence.privateDirectory, networkHarPath: networkEvidence.harPath, @@ -365,6 +372,17 @@ export async function startCommand(options: StartOptions): Promise { console.log(`When done, run: ${chalk.white('proofshot stop')}`); } +export function resolveAgentBrowserAllowedDomains( + targetUrl: string, + configuredDomains: string[] = [], +): string[] { + const normalizedTarget = targetUrl.includes('://') + ? targetUrl + : `http://${targetUrl}`; + const targetDomain = new URL(normalizedTarget).hostname; + return [...new Set([targetDomain, ...configuredDomains])].sort(); +} + function persistOwnedSession(session: SessionState): void { registerSession(session); } diff --git a/src/commands/stop.ts b/src/commands/stop.ts index b789735..237dec0 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -95,6 +95,7 @@ export async function stopCommand(options: StopOptions): Promise { return; } setAgentBrowserDefaults({ + allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath || config.browser.configPath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, diff --git a/src/session/registry.ts b/src/session/registry.ts index 7a62a63..4c1e21d 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -137,6 +137,7 @@ function isSessionState(value: unknown): value is SessionState { isOptionalString(session.agentBrowserSocketDir) && isOptionalString(session.agentBrowserSocketRoot) && isOptionalString(session.agentBrowserNamespace) && + isOptionalStringArray(session.agentBrowserAllowedDomains) && isOptionalString(session.agentBrowserConfigPath) && isOptionalString(session.privateEvidenceDir) && isOptionalString(session.networkHarPath) && @@ -157,6 +158,13 @@ function isOptionalString(value: unknown): boolean { return value === undefined || typeof value === 'string'; } +function isOptionalStringArray(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) + ); +} + function isOptionalBoolean(value: unknown): boolean { return value === undefined || typeof value === 'boolean'; } diff --git a/src/session/state.ts b/src/session/state.ts index 515d296..c0df731 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -37,6 +37,7 @@ export interface SessionState { agentBrowserSocketDir?: string; agentBrowserSocketRoot?: string; agentBrowserNamespace?: string; + agentBrowserAllowedDomains?: string[]; agentBrowserConfigPath?: string; privateEvidenceDir?: string; networkHarPath?: string; diff --git a/src/utils/config.ts b/src/utils/config.ts index f7ef1ee..91af041 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -17,6 +17,7 @@ export interface ViewportConfig { } export interface BrowserConfig { + allowedDomains?: string[]; configPath?: string; executablePath?: string; ignoreHttpsErrors: boolean; @@ -153,6 +154,7 @@ function validateConfig(value: unknown): void { assertOptionalString(value.browser.configPath, 'browser.configPath'); assertOptionalString(value.browser.executablePath, 'browser.executablePath'); assertOptionalBoolean(value.browser.ignoreHttpsErrors, 'browser.ignoreHttpsErrors'); + assertOptionalStringArray(value.browser.allowedDomains, 'browser.allowedDomains'); } validateEnvironment(value.environment); validateLogs(value.logs); diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 8d70c6c..f2f2cf0 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -12,6 +12,7 @@ export class ProofShotError extends Error { } export interface AgentBrowserCommandOptions { + allowedDomains?: string[]; configPath?: string; json?: boolean; namespace?: string; @@ -22,21 +23,26 @@ export interface AgentBrowserCommandOptions { let defaultAgentBrowserOptions: Pick< AgentBrowserCommandOptions, - 'configPath' | 'namespace' | 'socketDir' + 'allowedDomains' | 'configPath' | 'namespace' | 'socketDir' > = {}; export function setAgentBrowserDefaults( options: Pick< AgentBrowserCommandOptions, - 'configPath' | 'namespace' | 'socketDir' + 'allowedDomains' | 'configPath' | 'namespace' | 'socketDir' >, ): void { defaultAgentBrowserOptions = { ...options }; } export function getAgentBrowserEnvironment( - options: Pick = {}, + options: Pick< + AgentBrowserCommandOptions, + 'allowedDomains' | 'namespace' | 'socketDir' + > = {}, ): NodeJS.ProcessEnv { + const allowedDomains = + options.allowedDomains ?? defaultAgentBrowserOptions.allowedDomains; const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir; const namespace = options.namespace ?? defaultAgentBrowserOptions.namespace; return { @@ -45,6 +51,9 @@ export function getAgentBrowserEnvironment( process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '1800000', ...(socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {}), ...(namespace ? { AGENT_BROWSER_NAMESPACE: namespace } : {}), + ...(allowedDomains && allowedDomains.length > 0 + ? { AGENT_BROWSER_ALLOWED_DOMAINS: allowedDomains.join(',') } + : {}), }; } From f57989535c63ee1321b06f1d680a6050601cd629 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:33:40 +0000 Subject: [PATCH 05/74] test(browser): verify per-session domain containment Co-authored-by: Codex --- src/commands/lifecycle.integration.test.ts | 13 +++++++++++++ src/commands/session.test.ts | 1 + src/commands/start.test.ts | 1 + 3 files changed, 15 insertions(+) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index f960957..61dfe9e 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -138,6 +138,7 @@ fs.appendFileSync(process.env.FAKE_AGENT_BROWSER_LOG, JSON.stringify({ pid: process.pid, session, namespace, + allowedDomains: process.env.AGENT_BROWSER_ALLOWED_DOMAINS, socketDir, home: process.env.HOME, command, @@ -364,6 +365,18 @@ describe('isolated CLI lifecycle', () => { expect(sessions[0].agentBrowserNamespace).not.toBe( sessions[1].agentBrowserNamespace, ); + expect(sessions[0].agentBrowserAllowedDomains).toEqual(['example.invalid']); + expect(sessions[1].agentBrowserAllowedDomains).toEqual(['example.invalid']); + const openCalls = fs + .readFileSync(tools.browserLog, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((call) => call.command === 'open'); + expect(openCalls).toHaveLength(2); + expect(openCalls.every((call) => call.allowedDomains === 'example.invalid')).toBe( + true, + ); const ambiguousExec = runCli(audit, env, ['exec', 'get', 'url']); expect(ambiguousExec.status).toBe(1); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index a1e27dd..04d9786 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -76,6 +76,7 @@ describe('session commands', () => { expect(mocks.cleanupFailedStart).toHaveBeenCalledWith(session); expect(mocks.setAgentBrowserDefaults).toHaveBeenCalledWith({ + allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketDir, diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index ea39d77..7eabf76 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -226,6 +226,7 @@ describe('startCommand', () => { '/run/user/1000/proofshot/namespaces/psn-deadbeef1234/run', agentBrowserSocketRoot: '/run/user/1000/proofshot', agentBrowserNamespace: 'psn-deadbeef1234', + agentBrowserAllowedDomains: ['127.0.0.1'], }); expect(finalState.controlDir).toBe('/project/proofshot-artifacts'); }); From bc1916ce44e1f8ffc56aa933fc830d3db312a269 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:34:07 +0000 Subject: [PATCH 06/74] test(session): refuse force cleanup of live ownership Co-authored-by: Codex --- src/commands/lifecycle.integration.test.ts | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 61dfe9e..f906407 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -326,6 +326,55 @@ afterEach(async () => { }); describe('isolated CLI lifecycle', () => { + it('refuses --force while exact live ownership is verified', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/live', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + const registryPath = path.join( + registryDirectory(env), + `${session.sessionName}.json`, + ); + const registryBefore = fs.readFileSync(registryPath, 'utf-8'); + const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); + + const forcedStart = runCli(audit, env, [ + 'start', + '--force', + '--url', + 'https://example.invalid/replacement', + '--browser-executable', + tools.browserPath, + ]); + expect(forcedStart.status).toBe(1); + expect(forcedStart.stderr).toContain( + '--force cannot clean a verified live ProofShot session', + ); + expect(fs.readFileSync(registryPath, 'utf-8')).toBe(registryBefore); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBefore); + expect(processIsAlive(session.browserProcess.pid)).toBe(true); + + const stop = runCli(audit, env, ['stop']); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + }, 30000); + it('isolates two sessions and requires an exact target only while ambiguous', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); From acbcc3e606230d450983dc784d4dbd6ef08bc460 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:34:47 +0000 Subject: [PATCH 07/74] test(evidence): cover redaction and network summaries Co-authored-by: Codex --- src/browser/evidence.test.ts | 59 ++++++++++++++++++++++++++++++++++ src/browser/provenance.test.ts | 55 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/browser/evidence.test.ts create mode 100644 src/browser/provenance.test.ts diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts new file mode 100644 index 0000000..d3340d1 --- /dev/null +++ b/src/browser/evidence.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildSanitizedNetworkSummary } from './evidence.js'; + +describe('private browser evidence', () => { + it('produces a deterministic metadata-only network summary', () => { + const entries = [ + { + time: 20.12345, + request: { + method: 'POST', + url: 'https://example.com/z?token=private-token', + headers: [{ name: 'authorization', value: 'private-auth' }], + }, + response: { + status: 500, + content: { text: 'private-response-body' }, + _error: 'request failed with token=private-error', + }, + }, + { + time: 5, + request: { + method: 'GET', + url: 'https://example.com/a?customer=private-customer', + }, + response: { status: 200 }, + }, + ]; + + const summary = buildSanitizedNetworkSummary({ log: { entries } }); + const reversed = buildSanitizedNetworkSummary({ + log: { entries: [...entries].reverse() }, + }); + + expect(summary).toEqual(reversed); + expect(summary.requests).toEqual([ + { + endpoint: 'https://example.com/a', + method: 'GET', + status: 200, + durationMs: 5, + error: null, + }, + { + endpoint: 'https://example.com/z', + method: 'POST', + status: 500, + durationMs: 20.123, + error: 'request failed with token=[REDACTED]', + }, + ]); + const serialized = JSON.stringify(summary); + expect(serialized).not.toContain('private-token'); + expect(serialized).not.toContain('private-auth'); + expect(serialized).not.toContain('private-response-body'); + expect(serialized).not.toContain('private-customer'); + expect(serialized).not.toContain('private-error'); + }); +}); diff --git a/src/browser/provenance.test.ts b/src/browser/provenance.test.ts new file mode 100644 index 0000000..e483c10 --- /dev/null +++ b/src/browser/provenance.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSanitizedCommandIntent, + classifyInteraction, + sanitizeDiagnosticMessage, + sanitizePageUrl, +} from './provenance.js'; + +describe('browser interaction provenance', () => { + it('classifies user-equivalent, hybrid, diagnostic, and observation commands honestly', () => { + expect(classifyInteraction(['click', '@e1'])).toBe('pointer-keyboard'); + expect(classifyInteraction(['fill', '@e2', 'value'])).toBe('hybrid'); + expect(classifyInteraction(['eval', 'document.body.click()'])).toBe( + 'synthetic-dom', + ); + expect(classifyInteraction(['snapshot', '-i'])).toBe('observation'); + expect(classifyInteraction(['open', 'https://example.com'])).toBe('setup'); + }); + + it('redacts typed values, credentials, scripts, and sensitive URL parameters', () => { + expect( + buildSanitizedCommandIntent(['fill', '@e2', 'private-value']).summary, + ).toBe('fill @e2 [REDACTED]'); + expect( + buildSanitizedCommandIntent([ + 'set', + 'credentials', + 'private-user', + 'private-password', + ]).summary, + ).toBe('set credentials [REDACTED]'); + expect( + buildSanitizedCommandIntent([ + 'eval', + "document.querySelector('#secret').value", + ]).summary, + ).toBe('eval [REDACTED_SCRIPT]'); + + const url = sanitizePageUrl( + 'https://user:pass@example.com/callback?token=private&tab=profile', + ); + expect(url).toBe( + 'https://example.com/callback?token=%5BREDACTED%5D&tab=profile', + ); + }); + + it('redacts secret-shaped diagnostics before persistence', () => { + const message = sanitizeDiagnosticMessage( + 'authorization: Bearer private-token token=another-private-value', + ); + expect(message).not.toContain('private-token'); + expect(message).not.toContain('another-private-value'); + expect(message).toContain('[REDACTED]'); + }); +}); From 742a9094bb3023c9d7353bd87c6580c4624e4aee Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:36:46 +0000 Subject: [PATCH 08/74] fix(exec): refuse commands without an active proof session Co-authored-by: Codex --- src/commands/exec.ts | 13 +++++++++++-- src/commands/lifecycle.integration.test.ts | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/exec.ts b/src/commands/exec.ts index fdb7c71..25c6639 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -265,7 +265,16 @@ export async function execCommand( socketDir: session?.agentBrowserSocketRoot || session?.agentBrowserSocketDir, }); - if (session && !session.recordingActive) { + if (!session) { + console.error( + 'Error: No active ProofShot session matches this worktree.\n' + + 'Run "proofshot start" first, or inspect recovery state with "proofshot session list".', + ); + process.exit(1); + return; + } + + if (!session.recordingActive) { console.error( 'Error: Session has no active recording. Video capture is required.\n' + 'Run "proofshot stop" to end this session, then start a new one.', @@ -273,7 +282,7 @@ export async function execCommand( process.exit(1); } - if (session && !canAddressOwnedBrowserSession(session)) { + if (!canAddressOwnedBrowserSession(session)) { console.error( 'Error: Browser ownership no longer matches this ProofShot session.\n' + 'Refusing to address a possibly reused agent-browser session name.', diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index f906407..da661e6 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -496,6 +496,13 @@ describe('isolated CLI lifecycle', () => { await waitForProcessExit(sessions[1].browserProcess.pid); cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[1].browserProcess), 1); expect(fs.readdirSync(registryDir)).toEqual([]); + const browserLogAfterStops = fs.readFileSync(tools.browserLog, 'utf-8'); + const execWithoutSession = runCli(audit, env, ['exec', 'get', 'url']); + expect(execWithoutSession.status).toBe(1); + expect(execWithoutSession.stderr).toContain( + 'No active ProofShot session matches this worktree', + ); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogAfterStops); for (const session of sessions) { const privateEvidenceDir = path.join( From d57e0280b835ec46a294f6d6e1f56e1c89085050 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:37:36 +0000 Subject: [PATCH 09/74] test(session): cover stale force recovery and inventory Co-authored-by: Codex --- src/commands/lifecycle.integration.test.ts | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index da661e6..96aeb8f 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -375,6 +375,54 @@ describe('isolated CLI lifecycle', () => { cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); }, 30000); + it('allows --force to replace only proven stale ownership', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/stale', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [staleSession] = readRegisteredSessions(env); + await terminateOwnedProcessTree(staleSession.browserProcess, { graceMs: 300 }); + await waitForProcessExit(staleSession.browserProcess.pid); + + const replacement = runCli(audit, env, [ + 'start', + '--force', + '--url', + 'https://example.invalid/replacement', + '--browser-executable', + tools.browserPath, + ]); + expect(replacement.status, `${replacement.stdout}\n${replacement.stderr}`).toBe(0); + expect(replacement.stdout).toContain('Cleaned up stale session state'); + const [replacementSession] = readRegisteredSessions(env); + expect(replacementSession.sessionName).not.toBe(staleSession.sessionName); + expect(replacementSession.targetUrl).toBe( + 'https://example.invalid/replacement', + ); + cleanupProcesses.push(replacementSession.browserProcess); + + const stop = runCli(audit, env, ['stop']); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(replacementSession.browserProcess.pid); + cleanupProcesses.splice( + cleanupProcesses.indexOf(replacementSession.browserProcess), + 1, + ); + }, 30000); + it('isolates two sessions and requires an exact target only while ambiguous', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -426,6 +474,11 @@ describe('isolated CLI lifecycle', () => { expect(openCalls.every((call) => call.allowedDomains === 'example.invalid')).toBe( true, ); + const inventory = runCli(audit, env, ['session', 'list', '--json']); + expect(inventory.status, `${inventory.stdout}\n${inventory.stderr}`).toBe(0); + expect( + JSON.parse(inventory.stdout).sessions.map((entry: { id: string }) => entry.id), + ).toEqual(expect.arrayContaining(sessions.map((session) => session.sessionName))); const ambiguousExec = runCli(audit, env, ['exec', 'get', 'url']); expect(ambiguousExec.status).toBe(1); From cae305a4bd838ad74b68e14ee51b272b1a0a320c Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:38:37 +0000 Subject: [PATCH 10/74] fix(evidence): reject synthetic DOM proof Co-authored-by: Codex --- src/artifacts/evidence.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/artifacts/evidence.ts b/src/artifacts/evidence.ts index 4235bb9..be8113c 100644 --- a/src/artifacts/evidence.ts +++ b/src/artifacts/evidence.ts @@ -590,6 +590,10 @@ function buildVerdict( const pendingExpectedSelectors = options.actions.filter( (action) => action.expectedSelector && action.outcome === undefined, ); + const hasSyntheticDomMutation = options.actions.some( + (action) => + action.category === 'synthetic-dom' || /^eval\b/i.test(action.action), + ); const fatalIncidentCount = evidence.incidents.filter( (incident) => incident.severity === 'fatal', ).length; @@ -625,6 +629,11 @@ function buildVerdict( ...(reusedScreenshotPaths > 0 ? ['One or more screenshot paths were reused by multiple actions.'] : []), + ...(hasSyntheticDomMutation + ? [ + 'Synthetic DOM mutation is diagnostic-only and cannot serve as final behavioral proof.', + ] + : []), ]; const status: VerdictStatus = blockingReasons.length > 0 From d1ecdc8ba3c4468cc5f8d4b61e8292d4bdebcf11 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:38:37 +0000 Subject: [PATCH 11/74] test(evidence): cover diagnostic-only DOM mutation Co-authored-by: Codex --- src/artifacts/evidence.test.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/artifacts/evidence.test.ts b/src/artifacts/evidence.test.ts index f02a933..7aeecd8 100644 --- a/src/artifacts/evidence.test.ts +++ b/src/artifacts/evidence.test.ts @@ -238,6 +238,42 @@ describe('canonical evidence and verdicts', () => { }); expect(verdict.status).toBe('INCOMPLETE'); }); + + it('does not accept synthetic DOM mutation as final behavioral proof', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-synthetic-action-'), + ); + temporaryDirectories.push(sessionDir); + + const { verdict } = writeCanonicalEvidence({ + sessionId: 'synthetic-action', + sessionDir, + durationSec: 1, + videoPath: path.join(sessionDir, 'unused.webm'), + recordingWasActive: false, + consoleEvidenceAvailable: true, + actions: [ + { + action: 'eval [REDACTED_SCRIPT]', + category: 'synthetic-dom', + intent: { + command: 'eval', + summary: 'eval [REDACTED_SCRIPT]', + }, + relativeTimeSec: 0, + timestamp: '2026-08-09T00:00:00.000Z', + outcome: 'passed', + }, + ], + consoleEntries: [], + serverEntries: [], + }); + + expect(verdict.status).toBe('INCOMPLETE'); + expect(verdict.reasons).toContain( + 'Synthetic DOM mutation is diagnostic-only and cannot serve as final behavioral proof.', + ); + }); }); function environmentEvent( From 46044895773e7eab67ecc6cf7ef94cfffcb8f18b Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:40:25 +0000 Subject: [PATCH 12/74] fix(evidence): redact sensitive browser results Co-authored-by: Codex --- src/browser/evidence.ts | 48 ++++++++++++++++++++++++++++++++++++++++- src/commands/exec.ts | 2 ++ src/commands/start.ts | 6 ++++-- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index 71b3819..da2b2f2 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -100,6 +100,7 @@ export function buildSanitizedNetworkSummary( } export function writePrivateAgentBrowserResult(options: { + command: string; sessionDir: string; rawOutput: string; success: boolean; @@ -116,7 +117,10 @@ export function writePrivateAgentBrowserResult(options: { actionsDirectory, `${Date.now()}-${randomUUID()}.json`, ); - const result = parseStructuredResult(options.rawOutput, options.success, options.error); + const result = sanitizeStructuredResult( + parseStructuredResult(options.rawOutput, options.success, options.error), + options.command, + ); writeJsonFile(filePath, result); const evidencePath = path .relative(options.sessionDir, filePath) @@ -131,6 +135,48 @@ export function writePrivateAgentBrowserResult(options: { }; } +function sanitizeStructuredResult(value: unknown, command: string): unknown { + if (['auth', 'cookies', 'eval', 'storage'].includes(command)) { + const record = readRecord(value); + return { + success: + typeof record.success === 'boolean' ? record.success : true, + data: '[REDACTED]', + ...(typeof record.error === 'string' + ? { error: sanitizeDiagnosticMessage(record.error) } + : {}), + }; + } + return sanitizeStructuredValue(value); +} + +function sanitizeStructuredValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeDiagnosticMessage(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeStructuredValue); + } + if (typeof value !== 'object' || value === null) { + return value; + } + const record = value as Record; + const namedSecret = + typeof record.name === 'string' && + /authorization|cookie|credential|password|secret|token|api[-_]?key/i.test( + record.name, + ); + return Object.fromEntries( + Object.entries(record).map(([key, entry]) => { + const isSensitive = + /authorization|cookie|credential|password|secret|token|api[-_]?key/i.test( + key, + ) || (namedSecret && key === 'value'); + return [key, isSensitive ? '[REDACTED]' : sanitizeStructuredValue(entry)]; + }), + ); +} + function parseStructuredResult( rawOutput: string, success: boolean, diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 25c6639..e77526f 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -374,6 +374,7 @@ export async function execCommand( : undefined; const agentBrowserResult = session ? writePrivateAgentBrowserResult({ + command: intent.command, sessionDir: session.sessionDir, rawOutput: result, success: true, @@ -403,6 +404,7 @@ export async function execCommand( ); const agentBrowserResult = session ? writePrivateAgentBrowserResult({ + command: intent.command, sessionDir: session.sessionDir, rawOutput: rawErrorOutput, success: false, diff --git a/src/commands/start.ts b/src/commands/start.ts index c053842..1742717 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -8,6 +8,7 @@ import { preparePrivateNetworkEvidence, startPrivateNetworkCapture, } from '../browser/evidence.js'; +import { sanitizePageUrl } from '../browser/provenance.js'; import { startRecording } from '../browser/capture.js'; import { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js'; import { @@ -189,7 +190,7 @@ export async function startCommand(options: StartOptions): Promise { sessionLogAdjusted: false, consoleEvidenceAvailable: false, consoleErrorCount: 0, - targetUrl: openUrl, + targetUrl: sanitizePageUrl(openUrl) || openUrl, headless: config.headless, agentBrowserSocketDir: socketDir, agentBrowserSocketRoot: socketRoot, @@ -266,7 +267,8 @@ export async function startCommand(options: StartOptions): Promise { `Could not record the exact agent-browser daemon identity for session ${sessionName}.`, ); } - session.targetUrl = getPageUrl(sessionName) || openUrl; + session.targetUrl = + sanitizePageUrl(getPageUrl(sessionName) || openUrl) || openUrl; persistOwnedSession(session); console.log(chalk.green('✓') + ' Browser ready'); From a8949231c796bc812b2a803a60472a4d92503689 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:40:25 +0000 Subject: [PATCH 13/74] test(evidence): cover private result redaction Co-authored-by: Codex --- src/browser/evidence.test.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index d3340d1..58d018b 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -1,5 +1,11 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { describe, expect, it } from 'vitest'; -import { buildSanitizedNetworkSummary } from './evidence.js'; +import { + buildSanitizedNetworkSummary, + writePrivateAgentBrowserResult, +} from './evidence.js'; describe('private browser evidence', () => { it('produces a deterministic metadata-only network summary', () => { @@ -56,4 +62,30 @@ describe('private browser evidence', () => { expect(serialized).not.toContain('private-customer'); expect(serialized).not.toContain('private-error'); }); + + it('redacts sensitive structured results before writing local action evidence', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-private-result-'), + ); + try { + const receipt = writePrivateAgentBrowserResult({ + command: 'cookies', + sessionDir, + rawOutput: JSON.stringify({ + success: true, + data: { + cookies: [{ name: 'session', value: 'private-cookie' }], + }, + }), + success: true, + }); + const evidencePath = path.join(sessionDir, receipt.evidencePath); + const evidence = fs.readFileSync(evidencePath, 'utf-8'); + expect(evidence).not.toContain('private-cookie'); + expect(evidence).toContain('[REDACTED]'); + expect(fs.statSync(evidencePath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); }); From 2d50148dc69d1ab8d013fe8d0e3f9dd1cd490bbe Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:41:50 +0000 Subject: [PATCH 14/74] refactor(session): remove singleton control state Co-authored-by: Codex --- src/index.ts | 8 ++++++- src/session/state.ts | 51 -------------------------------------------- 2 files changed, 7 insertions(+), 52 deletions(-) diff --git a/src/index.ts b/src/index.ts index fb2a7e3..a7eaf32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,13 @@ export { } from './utils/config.js'; export { ab, ProofShotError } from './utils/exec.js'; export { isPortOpen, waitForPort } from './utils/port.js'; -export { saveSession, loadSession, type SessionState } from './session/state.js'; +export type { SessionState } from './session/state.js'; +export { + getRegisteredSession, + listRegisteredSessions, + registerSession, + unregisterSession, +} from './session/registry.js'; export { writeViewer, generateViewer } from './artifacts/viewer.js'; export { writeCanonicalEvidence } from './artifacts/evidence.js'; export type { diff --git a/src/session/state.ts b/src/session/state.ts index c0df731..7e5598b 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -1,11 +1,8 @@ -import * as fs from 'fs'; import * as path from 'path'; import { createHash, randomUUID } from 'crypto'; import type { EnvironmentState } from '../environment/types.js'; import type { ProcessIdentity } from '../utils/process.js'; -const SESSION_FILENAME = '.session.json'; - export interface SessionState { startedAt: string; recordingStartedAt?: string; @@ -68,54 +65,6 @@ export function resolveSessionControlDir( return path.resolve(cwd, configuredOutput); } -/** - * Write session state to disk. - */ -export function saveSession(state: SessionState, controlDir = state.outputDir): void { - fs.mkdirSync(controlDir, { recursive: true }); - const sessionPath = path.join(controlDir, SESSION_FILENAME); - const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`; - fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\n', { - mode: 0o600, - }); - fs.renameSync(temporaryPath, sessionPath); -} - -/** - * Read session state from disk. - * Returns null if no active session. - */ -export function loadSession(controlDir: string): SessionState | null { - const sessionPath = path.join(controlDir, SESSION_FILENAME); - if (!fs.existsSync(sessionPath)) return null; - try { - return JSON.parse(fs.readFileSync(sessionPath, 'utf-8')); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `ProofShot session state is corrupt: ${sessionPath}\n${message}\n` + - 'Use "proofshot session list" to inspect durable recovery records.', - ); - } -} - -/** - * Check if a session is currently active. - */ -export function hasActiveSession(controlDir: string): boolean { - return fs.existsSync(path.join(controlDir, SESSION_FILENAME)); -} - -/** - * Delete the session state file (called after stop). - */ -export function clearSession(controlDir: string): void { - const sessionPath = path.join(controlDir, SESSION_FILENAME); - if (fs.existsSync(sessionPath)) { - fs.unlinkSync(sessionPath); - } -} - /** * Generate a deterministic agent-browser session name for a ProofShot run. */ From 513d8bf85f43253f1c19c4d093b22f0bd5c9dc67 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:41:50 +0000 Subject: [PATCH 15/74] test(session): align state coverage with registry control Co-authored-by: Codex --- specs/visual-overlays.md | 2 +- src/session/state.test.ts | 21 +++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/specs/visual-overlays.md b/specs/visual-overlays.md index c323032..fd52e08 100644 --- a/specs/visual-overlays.md +++ b/specs/visual-overlays.md @@ -103,7 +103,7 @@ For ref-targeted actions (`click @e3`, `fill @e5 "text"`, `type @e4 "hello"`): - Toast works from the action string alone - No ripple rendered (except scroll arrow for `scroll`) -**Viewport caching:** Query viewport once at session start (in `startCommand`) and store in session state. In `execCommand()`, if the action starts with `set viewport`, re-query viewport via eval after execution and update the cached value in session state (call `saveSession()` with the new viewport). +**Viewport caching:** Query viewport once at session start (in `startCommand`) and store it in the registered session state. In `execCommand()`, if the action starts with `set viewport`, re-query viewport via eval after execution and update that exact registry record. ### Failure handling diff --git a/src/session/state.test.ts b/src/session/state.test.ts index 5f27b71..e9b16ca 100644 --- a/src/session/state.test.ts +++ b/src/session/state.test.ts @@ -1,10 +1,7 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; import { describe, expect, it } from 'vitest'; import { + generateAgentBrowserNamespace, generateAgentBrowserSessionName, - loadSession, } from './state.js'; describe('generateAgentBrowserSessionName', () => { @@ -33,13 +30,13 @@ describe('generateAgentBrowserSessionName', () => { ); }); - it('reports corrupt control state instead of treating it as absence', () => { - const controlDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proofshot-state-test-')); - try { - fs.writeFileSync(path.join(controlDir, '.session.json'), '{not-json'); - expect(() => loadSession(controlDir)).toThrow(/session state is corrupt/i); - } finally { - fs.rmSync(controlDir, { recursive: true, force: true }); - } + it('derives a compact isolated namespace from the immutable session name', () => { + const namespace = generateAgentBrowserNamespace( + 'ps-2026-04-deadbeef1234', + ); + expect(namespace).toMatch(/^psn-[a-f0-9]{12}$/); + expect(namespace).toBe( + generateAgentBrowserNamespace('ps-2026-04-deadbeef1234'), + ); }); }); From 27a7b926fa9e1bf9ba7dbbd69e19cca39319c075 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:42:34 +0000 Subject: [PATCH 16/74] fix(evidence): flush HAR after request inventory failure Co-authored-by: Codex --- src/browser/evidence.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index da2b2f2..3e6cfb8 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -53,7 +53,18 @@ export function finalizePrivateNetworkCapture( paths: PrivateNetworkEvidencePaths, ): SanitizedNetworkSummary { preparePrivateDirectory(paths.privateDirectory); - const requests = ab('network requests --json', { session: sessionName }); + let requests: string; + try { + requests = ab('network requests --json', { session: sessionName }); + } catch (error) { + requests = JSON.stringify({ + success: false, + data: null, + error: sanitizeDiagnosticMessage( + error instanceof Error ? error.message : String(error), + ), + }); + } writePrivateTextFile( paths.requestsPath, requests.trim() ? `${requests.trim()}\n` : '{"success":true,"data":[]}\n', From 4f6323bd89261e3f94a71754ec2c5ac815c928dd Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:42:34 +0000 Subject: [PATCH 17/74] test(evidence): cover HAR flush fallback Co-authored-by: Codex --- src/browser/evidence.test.ts | 50 +++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index 58d018b..fa70080 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -1,12 +1,24 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildSanitizedNetworkSummary, + finalizePrivateNetworkCapture, writePrivateAgentBrowserResult, } from './evidence.js'; +const mocks = vi.hoisted(() => ({ ab: vi.fn() })); + +vi.mock('../utils/exec.js', async (importOriginal) => ({ + ...(await importOriginal()), + ab: mocks.ab, +})); + +afterEach(() => { + mocks.ab.mockReset(); +}); + describe('private browser evidence', () => { it('produces a deterministic metadata-only network summary', () => { const entries = [ @@ -88,4 +100,40 @@ describe('private browser evidence', () => { fs.rmSync(sessionDir, { recursive: true, force: true }); } }); + + it('still stops HAR capture when the request inventory command fails', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-network-finalize-'), + ); + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + const paths = { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; + mocks.ab.mockImplementation((command: string) => { + if (command === 'network requests --json') { + throw new Error('request inventory failed'); + } + if (command.startsWith('network har stop ')) { + fs.writeFileSync(paths.harPath, JSON.stringify({ log: { entries: [] } })); + return JSON.stringify({ success: true }); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + const summary = finalizePrivateNetworkCapture('ps-test', paths); + + expect(summary.requestCount).toBe(0); + expect(mocks.ab).toHaveBeenCalledTimes(2); + expect(mocks.ab.mock.calls[1][0]).toContain('network har stop'); + expect(fs.readFileSync(paths.requestsPath, 'utf-8')).toContain( + 'request inventory failed', + ); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); }); From a2170de3ab93ee942badb99796549054aaefc4d5 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:44:14 +0000 Subject: [PATCH 18/74] refactor(session): isolate action-log persistence Co-authored-by: Codex --- src/artifacts/evidence.ts | 2 +- src/artifacts/viewer.ts | 2 +- src/commands/exec.ts | 125 ++---------------------------------- src/commands/stop.test.ts | 4 +- src/commands/stop.ts | 5 +- src/index.ts | 2 +- src/session/action-log.ts | 131 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 148 insertions(+), 123 deletions(-) create mode 100644 src/session/action-log.ts diff --git a/src/artifacts/evidence.ts b/src/artifacts/evidence.ts index be8113c..eb40071 100644 --- a/src/artifacts/evidence.ts +++ b/src/artifacts/evidence.ts @@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; import { PNG } from 'pngjs'; import type { SanitizedNetworkSummary } from '../browser/evidence.js'; -import type { SessionLogEntry } from '../commands/exec.js'; +import type { SessionLogEntry } from '../session/action-log.js'; import { loadEvidenceEvents } from '../environment/evidence.js'; import type { EnvironmentState, diff --git a/src/artifacts/viewer.ts b/src/artifacts/viewer.ts index 6942193..7f07e96 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { SessionLogEntry } from '../commands/exec.js'; +import type { SessionLogEntry } from '../session/action-log.js'; import type { CanonicalEvidence, EvidenceSourceSummary, diff --git a/src/commands/exec.ts b/src/commands/exec.ts index e77526f..bc59d2d 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; import { @@ -10,8 +9,6 @@ import { classifyInteraction, sanitizeDiagnosticMessage, sanitizePageUrl, - type InteractionCategory, - type SanitizedCommandIntent, } from '../browser/provenance.js'; import { loadConfig, normalizeViewport } from '../utils/config.js'; import { @@ -27,52 +24,16 @@ import { canAddressOwnedBrowserSession } from '../session/lifecycle.js'; import { getPageUrl } from '../browser/session.js'; import { registerSession } from '../session/registry.js'; import { resolveLiveSession } from '../session/selection.js'; - -const SESSION_LOG_FILENAME = 'session-log.json'; -const SESSION_LOG_LOCK_TIMEOUT_MS = 5000; -const SESSION_LOG_STALE_LOCK_MS = 120000; - -export interface SessionLogEntry { - action: string; - category?: InteractionCategory; - intent?: SanitizedCommandIntent; - relativeTimeSec: number; - timestamp: string; - durationMs?: number; - outcome?: 'passed' | 'failed'; - expectedSelector?: string; - error?: string; - pageUrl?: string; - agentBrowserResult?: AgentBrowserResultReceipt; - element?: { - label: string; - bbox: { x: number; y: number; width: number; height: number }; - viewport: { width: number; height: number }; - }; -} +import { + appendSessionLogEntry, + persistSessionLogEntry, + type SessionLogEntry, +} from '../session/action-log.js'; type ExecOptions = { session?: string; }; -/** - * Load existing session log entries from disk. - */ -export function loadSessionLog(sessionDir: string): SessionLogEntry[] { - const logPath = path.join(sessionDir, SESSION_LOG_FILENAME); - if (!fs.existsSync(logPath)) return []; - try { - const parsed: unknown = JSON.parse(fs.readFileSync(logPath, 'utf-8')); - if (!Array.isArray(parsed)) { - throw new Error('session log root must be an array'); - } - return parsed as SessionLogEntry[]; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`ProofShot session action log is corrupt: ${logPath}\n${message}`); - } -} - /** * For screenshot commands, resolve relative paths into the session directory * so agents can just say `proofshot exec screenshot step-name.png`. @@ -327,10 +288,7 @@ export async function execCommand( entry.element = elementData; } - const logPath = path.join(session.sessionDir, SESSION_LOG_FILENAME); - updateSessionLog(logPath, (entries) => { - entries.push(entry); - }); + const logPath = appendSessionLogEntry(session.sessionDir, entry); loggedEntry = entry; sessionLogPath = logPath; } @@ -465,30 +423,7 @@ function persistActionOutcome( if (agentBrowserResult) { entry.agentBrowserResult = agentBrowserResult; } - updateSessionLog(logPath, (entries) => { - const matchingEntry = [...entries] - .reverse() - .find( - (candidate) => - candidate.timestamp === entry.timestamp && - candidate.action === entry.action, - ); - if (matchingEntry) { - matchingEntry.outcome = outcome; - if (error) { - matchingEntry.error = error; - } - if (pageUrl) { - matchingEntry.pageUrl = pageUrl; - } - if (durationMs !== undefined) { - matchingEntry.durationMs = durationMs; - } - if (agentBrowserResult) { - matchingEntry.agentBrowserResult = agentBrowserResult; - } - } - }); + persistSessionLogEntry(logPath, entry); } function assertionPassed(rawOutput: string): boolean { @@ -532,49 +467,3 @@ function unwrapStructuredOutput(rawOutput: string): string { return rawOutput; } } - -function updateSessionLog( - logPath: string, - update: (entries: SessionLogEntry[]) => void, -): void { - const lockPath = `${logPath}.lock`; - const deadline = Date.now() + SESSION_LOG_LOCK_TIMEOUT_MS; - let lockFd: number | null = null; - while (lockFd === null) { - try { - lockFd = fs.openSync(lockPath, 'wx', 0o600); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - try { - if (Date.now() - fs.statSync(lockPath).mtimeMs > SESSION_LOG_STALE_LOCK_MS) { - fs.unlinkSync(lockPath); - continue; - } - } catch (statError) { - if ((statError as NodeJS.ErrnoException).code === 'ENOENT') continue; - throw statError; - } - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for session log lock: ${lockPath}`); - } - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); - } - } - - try { - const entries = loadSessionLog(path.dirname(logPath)); - update(entries); - const temporaryPath = `${logPath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(temporaryPath, JSON.stringify(entries, null, 2) + '\n', { - mode: 0o600, - }); - fs.renameSync(temporaryPath, logPath); - } finally { - fs.closeSync(lockFd); - try { - fs.unlinkSync(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - } -} diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 0b6e89c..f4ae13e 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -59,7 +59,9 @@ vi.mock('../environment/runtime.js', () => ({ })); vi.mock('../artifacts/viewer.js', () => ({ writeViewer: mocks.writeViewer })); vi.mock('../utils/error-patterns.js', () => ({ extractServerErrors: mocks.extractServerErrors })); -vi.mock('./exec.js', () => ({ loadSessionLog: mocks.loadSessionLog })); +vi.mock('../session/action-log.js', () => ({ + loadSessionLog: mocks.loadSessionLog, +})); vi.mock('../utils/token-usage.js', () => ({ estimateTokenUsage: mocks.estimateTokenUsage })); vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 237dec0..3aa07a2 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -34,7 +34,10 @@ import { loadMetadata } from '../session/metadata.js'; import { writeArtifactManifest } from '../session/manifest.js'; import { extractServerErrors } from '../utils/error-patterns.js'; import { processIdentityMatches } from '../utils/process.js'; -import { loadSessionLog, type SessionLogEntry } from './exec.js'; +import { + loadSessionLog, + type SessionLogEntry, +} from '../session/action-log.js'; import { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js'; /** diff --git a/src/index.ts b/src/index.ts index a7eaf32..c18e188 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ export type { GitProvenance, ManifestArtifact, } from './session/manifest.js'; -export type { SessionLogEntry } from './commands/exec.js'; +export type { SessionLogEntry } from './session/action-log.js'; export { convertVideoToMp4, trimVideo } from './commands/stop.js'; export { writeMetadata, loadMetadata, findSessionsForBranch, type SessionMetadata } from './session/metadata.js'; export { formatPRComment, type PRCommentData } from './artifacts/pr-format.js'; diff --git a/src/session/action-log.ts b/src/session/action-log.ts new file mode 100644 index 0000000..49491d9 --- /dev/null +++ b/src/session/action-log.ts @@ -0,0 +1,131 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { AgentBrowserResultReceipt } from '../browser/evidence.js'; +import type { + InteractionCategory, + SanitizedCommandIntent, +} from '../browser/provenance.js'; + +const SESSION_LOG_FILENAME = 'session-log.json'; +const SESSION_LOG_LOCK_TIMEOUT_MS = 5000; +const SESSION_LOG_STALE_LOCK_MS = 120000; + +export type SessionLogEntry = { + action: string; + category?: InteractionCategory; + intent?: SanitizedCommandIntent; + relativeTimeSec: number; + timestamp: string; + durationMs?: number; + outcome?: 'passed' | 'failed'; + expectedSelector?: string; + error?: string; + pageUrl?: string; + agentBrowserResult?: AgentBrowserResultReceipt; + element?: { + label: string; + bbox: { x: number; y: number; width: number; height: number }; + viewport: { width: number; height: number }; + }; +}; + +export function loadSessionLog(sessionDir: string): SessionLogEntry[] { + const logPath = path.join(sessionDir, SESSION_LOG_FILENAME); + if (!fs.existsSync(logPath)) { + return []; + } + try { + const parsed: unknown = JSON.parse(fs.readFileSync(logPath, 'utf-8')); + if (!Array.isArray(parsed)) { + throw new Error('session log root must be an array'); + } + return parsed as SessionLogEntry[]; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`ProofShot session action log is corrupt: ${logPath}\n${message}`); + } +} + +export function appendSessionLogEntry( + sessionDir: string, + entry: SessionLogEntry, +): string { + const logPath = path.join(sessionDir, SESSION_LOG_FILENAME); + updateSessionLog(logPath, (entries) => { + entries.push(entry); + }); + return logPath; +} + +export function persistSessionLogEntry( + logPath: string, + entry: SessionLogEntry, +): void { + updateSessionLog(logPath, (entries) => { + const matchingEntry = [...entries] + .reverse() + .find( + (candidate) => + candidate.timestamp === entry.timestamp && + candidate.action === entry.action, + ); + if (matchingEntry) { + Object.assign(matchingEntry, entry); + } + }); +} + +function updateSessionLog( + logPath: string, + update: (entries: SessionLogEntry[]) => void, +): void { + const lockPath = `${logPath}.lock`; + const deadline = Date.now() + SESSION_LOG_LOCK_TIMEOUT_MS; + let lockDescriptor: number | null = null; + while (lockDescriptor === null) { + try { + lockDescriptor = fs.openSync(lockPath, 'wx', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + try { + if ( + Date.now() - fs.statSync(lockPath).mtimeMs > + SESSION_LOG_STALE_LOCK_MS + ) { + fs.unlinkSync(lockPath); + continue; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code === 'ENOENT') { + continue; + } + throw statError; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for session log lock: ${lockPath}`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + + try { + const entries = loadSessionLog(path.dirname(logPath)); + update(entries); + const temporaryPath = `${logPath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(entries, null, 2) + '\n', { + mode: 0o600, + }); + fs.renameSync(temporaryPath, logPath); + } finally { + fs.closeSync(lockDescriptor); + try { + fs.unlinkSync(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} From 9bc639eb7529880f06a59dfa770e8ce86025153e Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:45:22 +0000 Subject: [PATCH 19/74] fix(evidence): mark interrupted actions incomplete Co-authored-by: Codex --- src/artifacts/evidence.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/artifacts/evidence.ts b/src/artifacts/evidence.ts index eb40071..632c25d 100644 --- a/src/artifacts/evidence.ts +++ b/src/artifacts/evidence.ts @@ -587,8 +587,8 @@ function buildVerdict( action.expectedSelector && action.outcome === 'failed', ) .map((action) => action.expectedSelector!); - const pendingExpectedSelectors = options.actions.filter( - (action) => action.expectedSelector && action.outcome === undefined, + const pendingActions = options.actions.filter( + (action) => action.outcome === undefined, ); const hasSyntheticDomMutation = options.actions.some( (action) => @@ -621,9 +621,9 @@ function buildVerdict( ...(evidence.sources.some((source) => source.truncationCount > 0) ? ['One or more evidence sources were truncated.'] : []), - ...(pendingExpectedSelectors.length > 0 + ...(pendingActions.length > 0 ? [ - `${pendingExpectedSelectors.length} expected selector assertion(s) had no recorded outcome.`, + `${pendingActions.length} browser action(s) had no recorded outcome.`, ] : []), ...(reusedScreenshotPaths > 0 From b9943e7e9df02e73da97c505cbdab7619bcc3839 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:45:22 +0000 Subject: [PATCH 20/74] test(evidence): cover interrupted action receipts Co-authored-by: Codex --- src/artifacts/evidence.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/artifacts/evidence.test.ts b/src/artifacts/evidence.test.ts index 7aeecd8..773a9e1 100644 --- a/src/artifacts/evidence.test.ts +++ b/src/artifacts/evidence.test.ts @@ -274,6 +274,37 @@ describe('canonical evidence and verdicts', () => { 'Synthetic DOM mutation is diagnostic-only and cannot serve as final behavioral proof.', ); }); + + it('marks interrupted browser actions without an outcome as incomplete', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-interrupted-action-'), + ); + temporaryDirectories.push(sessionDir); + + const { verdict } = writeCanonicalEvidence({ + sessionId: 'interrupted-action', + sessionDir, + durationSec: 1, + videoPath: path.join(sessionDir, 'unused.webm'), + recordingWasActive: false, + consoleEvidenceAvailable: true, + actions: [ + { + action: 'click @e1', + category: 'pointer-keyboard', + relativeTimeSec: 0, + timestamp: '2026-08-09T00:00:00.000Z', + }, + ], + consoleEntries: [], + serverEntries: [], + }); + + expect(verdict.status).toBe('INCOMPLETE'); + expect(verdict.reasons).toContain( + '1 browser action(s) had no recorded outcome.', + ); + }); }); function environmentEvent( From f207baef7b5b987c5514a703cdb9a66f8d1c61ee Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:46:27 +0000 Subject: [PATCH 21/74] test(session): preserve isolation across interrupted exec Co-authored-by: Codex --- src/commands/lifecycle.integration.test.ts | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 96aeb8f..a7f20d2 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -229,6 +229,12 @@ if (command === 'network' && detail[0] === 'har' && detail[1] === 'stop') { } process.exit(0); } +if (command === 'hang') { + spawnSync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + process.exit(0); +} if (command === 'close') { try { const pid = Number(fs.readFileSync(pidPath, 'utf8')); @@ -608,6 +614,109 @@ describe('isolated CLI lifecycle', () => { } }, 30000); + it('keeps both owned browsers isolated when one exec process is interrupted', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + for (const target of ['first', 'second']) { + const start = runCli(audit, env, [ + 'start', + '--url', + `https://example.invalid/${target}`, + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + } + const sessions = readRegisteredSessions(env).sort((left, right) => + left.targetUrl.localeCompare(right.targetUrl), + ); + cleanupProcesses.push(...sessions.map((session) => session.browserProcess)); + + const interruptedExec = spawn( + process.execPath, + [ + cliPath, + 'exec', + '--session', + sessions[0].sessionName, + 'hang', + ], + { + cwd: audit, + env, + detached: true, + stdio: 'ignore', + }, + ); + const interruptedIdentity = captureProcessIdentity(interruptedExec.pid!); + if (interruptedIdentity) { + cleanupProcesses.push(interruptedIdentity); + } + const interruptedLogPath = path.join( + sessions[0].sessionDir, + 'session-log.json', + ); + for ( + let attempt = 0; + attempt < 80 && !fs.existsSync(interruptedLogPath); + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(fs.existsSync(interruptedLogPath)).toBe(true); + + process.kill(-interruptedExec.pid!, 'SIGTERM'); + await new Promise((resolve) => interruptedExec.once('close', () => resolve())); + if (interruptedIdentity) { + cleanupProcesses.splice(cleanupProcesses.indexOf(interruptedIdentity), 1); + } + expect(processIsAlive(sessions[0].browserProcess.pid)).toBe(true); + expect(processIsAlive(sessions[1].browserProcess.pid)).toBe(true); + const pendingActions = JSON.parse( + fs.readFileSync(interruptedLogPath, 'utf-8'), + ); + expect(pendingActions.at(-1).action).toBe('hang'); + expect('outcome' in pendingActions.at(-1)).toBe(false); + + const unaffectedExec = runCli(audit, env, [ + 'exec', + '--session', + sessions[1].sessionName, + 'get', + 'url', + ]); + expect(unaffectedExec.status, `${unaffectedExec.stdout}\n${unaffectedExec.stderr}`).toBe(0); + expect(unaffectedExec.stdout.trim()).toBe(sessions[1].targetUrl); + + for (const session of sessions) { + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + } + const interruptedVerdict = JSON.parse( + fs.readFileSync( + path.join(sessions[0].sessionDir, 'verdict.json'), + 'utf-8', + ), + ); + expect(interruptedVerdict).toMatchObject({ status: 'INCOMPLETE' }); + expect(interruptedVerdict.reasons).toContain( + '1 browser action(s) had no recorded outcome.', + ); + }, 30000); + it('shares custom-output control across processes and stops idempotently', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); From c02c0b04ba536a4e7a2ea7386888efe085eb1dd7 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:24:23 +0000 Subject: [PATCH 22/74] fix(session): serialize lifecycle operations Co-authored-by: Codex --- src/commands/exec.ts | 16 +- src/commands/lifecycle.integration.test.ts | 281 ++++++++++++++++++++- src/commands/session.test.ts | 22 ++ src/commands/session.ts | 19 ++ src/commands/start.test.ts | 26 ++ src/commands/start.ts | 54 +++- src/commands/stop.test.ts | 22 ++ src/commands/stop.ts | 18 +- src/session/registry.test.ts | 77 ++++++ src/session/registry.ts | 223 +++++++++++++++- src/session/state.ts | 10 + 11 files changed, 757 insertions(+), 11 deletions(-) diff --git a/src/commands/exec.ts b/src/commands/exec.ts index bc59d2d..acb19c4 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -22,7 +22,11 @@ import { } from '../session/state.js'; import { canAddressOwnedBrowserSession } from '../session/lifecycle.js'; import { getPageUrl } from '../browser/session.js'; -import { registerSession } from '../session/registry.js'; +import { + claimSessionOperation, + registerSession, + releaseSessionOperation, +} from '../session/registry.js'; import { resolveLiveSession } from '../session/selection.js'; import { appendSessionLogEntry, @@ -252,6 +256,8 @@ export async function execCommand( return; } + const execLease = claimSessionOperation(session, 'exec'); + try { // Resolve args (screenshot path rewriting) let resolvedArgs = translated.agentBrowserArgs; if (session) { @@ -378,7 +384,8 @@ export async function execCommand( Date.now() - executionStartedAt, agentBrowserResult, ); - process.exit(error?.status || 1); + process.exitCode = error?.status || 1; + return; } // If the action was `set viewport`, update cached viewport in session state @@ -396,6 +403,11 @@ export async function execCommand( // Non-critical — viewport cache stays stale } } + } finally { + if (session.operationLease?.id === execLease.id) { + releaseSessionOperation(session, execLease); + } + } } function persistActionOutcome( diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index a7f20d2..ef935c5 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -229,6 +229,14 @@ if (command === 'network' && detail[0] === 'har' && detail[1] === 'stop') { } process.exit(0); } +if ( + command === 'record' && + detail[0] === 'stop' && + process.env.FAKE_AGENT_BROWSER_DELAY_RECORD_STOP === '1' +) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000); + process.exit(0); +} if (command === 'hang') { spawnSync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', @@ -242,6 +250,9 @@ if (command === 'close') { } catch {} try { fs.unlinkSync(pidPath); } catch {} try { fs.unlinkSync(statePath); } catch {} + if (process.env.FAKE_AGENT_BROWSER_DELAY_CLOSE_AFTER_KILL === '1') { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500); + } process.exit(0); } if (jsonOutput) { @@ -313,9 +324,12 @@ function readRegisteredSessions(env: NodeJS.ProcessEnv): any[] { if (!fs.existsSync(directory)) { return []; } - return fs.readdirSync(directory).map((file) => - JSON.parse(fs.readFileSync(path.join(directory, file), 'utf-8')), - ); + return fs + .readdirSync(directory) + .filter((file) => file.endsWith('.json')) + .map((file) => + JSON.parse(fs.readFileSync(path.join(directory, file), 'utf-8')), + ); } beforeAll(() => { @@ -332,6 +346,92 @@ afterEach(async () => { }); describe('isolated CLI lifecycle', () => { + it('refuses --force while another start owns the lifecycle operation', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const startEnv = isolatedEnvironment(audit, tools, { + FAKE_AGENT_BROWSER_HANG_OPEN: '1', + }); + const forceEnv = isolatedEnvironment(audit, tools); + fs.mkdirSync(startEnv.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = spawn( + process.execPath, + [ + cliPath, + 'start', + '--url', + 'https://example.invalid/starting', + '--browser-executable', + tools.browserPath, + ], + { + cwd: audit, + env: startEnv, + detached: true, + stdio: 'pipe', + }, + ); + let startOutput = ''; + start.stdout?.on('data', (chunk) => { + startOutput += chunk.toString(); + }); + start.stderr?.on('data', (chunk) => { + startOutput += chunk.toString(); + }); + const daemon = await waitForLatestDaemon(tools.browserLog); + const daemonIdentity = captureProcessIdentity(daemon.daemonPid); + if (daemonIdentity) { + cleanupProcesses.push(daemonIdentity); + } + const [startingSession] = readRegisteredSessions(startEnv); + expect(startingSession.operationLease).toMatchObject({ kind: 'start' }); + + const forcedStart = runCli(audit, forceEnv, [ + 'start', + '--force', + '--url', + 'https://example.invalid/replacement', + '--browser-executable', + tools.browserPath, + ]); + + expect(forcedStart.status, `${forcedStart.stdout}\n${forcedStart.stderr}`).toBe(1); + expect(forcedStart.stderr).toContain( + '--force cannot take over a live ProofShot operation', + ); + const openCalls = fs + .readFileSync(tools.browserLog, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((call) => call.command === 'open'); + expect(openCalls).toHaveLength(1); + + process.kill(-start.pid!, 'SIGTERM'); + const exitCode = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('interrupted start did not finish')), + 10000, + ); + start.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + expect(exitCode, startOutput).toBe(143); + await waitForProcessExit(daemon.daemonPid); + expect(readRegisteredSessions(startEnv)).toEqual([]); + expect(fs.readdirSync(registryDirectory(startEnv))).toEqual([]); + if (daemonIdentity) { + cleanupProcesses.splice(cleanupProcesses.indexOf(daemonIdentity), 1); + } + }, 30000); + it('refuses --force while exact live ownership is verified', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -381,6 +481,76 @@ describe('isolated CLI lifecycle', () => { cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); }, 30000); + it('refuses --force while stop finalizes after exact browser cleanup', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/stopping', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + + const stop = spawn(process.execPath, [cliPath, 'stop'], { + cwd: audit, + env: { + ...env, + FAKE_AGENT_BROWSER_DELAY_CLOSE_AFTER_KILL: '1', + }, + stdio: 'pipe', + }); + let stopOutput = ''; + stop.stdout?.on('data', (chunk) => { + stopOutput += chunk.toString(); + }); + stop.stderr?.on('data', (chunk) => { + stopOutput += chunk.toString(); + }); + await waitForProcessExit(session.browserProcess.pid); + const [stoppingSession] = readRegisteredSessions(env); + expect(stoppingSession.operationLease).toMatchObject({ kind: 'stop' }); + expect(stoppingSession.lifecycleStatus).toBe('stopping'); + + const forcedStart = runCli(audit, env, [ + 'start', + '--force', + '--url', + 'https://example.invalid/replacement', + '--browser-executable', + tools.browserPath, + ]); + + expect(forcedStart.status, `${forcedStart.stdout}\n${forcedStart.stderr}`).toBe(1); + expect(forcedStart.stderr).toContain( + '--force cannot take over a live ProofShot operation', + ); + const stopExitCode = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('delayed stop did not finish')), + 10000, + ); + stop.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + expect(stopExitCode, stopOutput).toBe(0); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + expect(readRegisteredSessions(env)).toEqual([]); + expect(fs.readdirSync(registryDirectory(env))).toEqual([]); + }, 30000); + it('allows --force to replace only proven stale ownership', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -717,6 +887,111 @@ describe('isolated CLI lifecycle', () => { ); }, 30000); + it('finishes exact recording teardown after stop is interrupted', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + for (const target of ['first-stop', 'second-stop']) { + const start = runCli(audit, env, [ + 'start', + '--url', + `https://example.invalid/${target}`, + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + } + const sessions = readRegisteredSessions(env).sort((left, right) => + left.targetUrl.localeCompare(right.targetUrl), + ); + cleanupProcesses.push(...sessions.map((session) => session.browserProcess)); + + const interruptedStop = spawn( + process.execPath, + [ + cliPath, + 'stop', + '--session', + sessions[0].sessionName, + ], + { + cwd: audit, + env: { + ...env, + FAKE_AGENT_BROWSER_DELAY_RECORD_STOP: '1', + }, + detached: true, + stdio: 'pipe', + }, + ); + let stopOutput = ''; + interruptedStop.stdout?.on('data', (chunk) => { + stopOutput += chunk.toString(); + }); + interruptedStop.stderr?.on('data', (chunk) => { + stopOutput += chunk.toString(); + }); + let recordingStopStarted = false; + for (let attempt = 0; attempt < 80; attempt += 1) { + const calls = fs + .readFileSync(tools.browserLog, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + if ( + calls.some( + (call) => + call.session === sessions[0].sessionName && + call.command === 'record' && + call.detail[0] === 'stop', + ) + ) { + recordingStopStarted = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(recordingStopStarted).toBe(true); + process.kill(interruptedStop.pid!, 'SIGTERM'); + const stopExitCode = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('interrupted stop did not finish')), + 10000, + ); + interruptedStop.once('close', (code) => { + clearTimeout(timeout); + resolve(code); + }); + }); + + expect(stopExitCode, stopOutput).toBe(143); + await waitForProcessExit(sessions[0].browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[0].browserProcess), 1); + expect(processIsAlive(sessions[1].browserProcess.pid)).toBe(true); + expect( + readRegisteredSessions(env).map((session) => session.sessionName), + ).toEqual([sessions[1].sessionName]); + + const unaffectedExec = runCli(audit, env, [ + 'exec', + '--session', + sessions[1].sessionName, + 'get', + 'url', + ]); + expect(unaffectedExec.status, `${unaffectedExec.stdout}\n${unaffectedExec.stderr}`).toBe(0); + const finalStop = runCli(audit, env, ['stop']); + expect(finalStop.status, `${finalStop.stdout}\n${finalStop.stderr}`).toBe(0); + await waitForProcessExit(sessions[1].browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[1].browserProcess), 1); + }, 30000); + it('shares custom-output control across processes and stops idempotently', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index 04d9786..2c398eb 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + claimSessionOperation: vi.fn(), cleanupFailedStart: vi.fn(), getRegisteredSession: vi.fn(), listRegisteredSessions: vi.fn(), registerSession: vi.fn(), + releaseSessionOperation: vi.fn(), unregisterSession: vi.fn(), clearSession: vi.fn(), hasActiveSession: vi.fn(), @@ -18,9 +20,11 @@ vi.mock('../session/lifecycle.js', () => ({ cleanupFailedStart: mocks.cleanupFailedStart, })); vi.mock('../session/registry.js', () => ({ + claimSessionOperation: mocks.claimSessionOperation, getRegisteredSession: mocks.getRegisteredSession, listRegisteredSessions: mocks.listRegisteredSessions, registerSession: mocks.registerSession, + releaseSessionOperation: mocks.releaseSessionOperation, unregisterSession: mocks.unregisterSession, })); vi.mock('../session/state.js', () => ({ @@ -43,6 +47,24 @@ beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); mocks.cleanupFailedStart.mockResolvedValue(undefined); + mocks.claimSessionOperation.mockImplementation((session) => { + const lease = { + id: 'recovery-lease', + kind: 'recovery', + owner: { + pid: process.pid, + processGroupId: process.pid, + sessionId: process.pid, + startTime: 'test', + }, + startedAt: new Date().toISOString(), + }; + session.operationLease = lease; + return lease; + }); + mocks.releaseSessionOperation.mockImplementation((session) => { + delete session.operationLease; + }); mocks.hasActiveSession.mockReturnValue(true); mocks.ownedProcessTreeIsAlive.mockReturnValue(false); }); diff --git a/src/commands/session.ts b/src/commands/session.ts index 52eee3a..47b1757 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -2,9 +2,11 @@ import chalk from 'chalk'; import { cleanupFailedStart } from '../session/lifecycle.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { + claimSessionOperation, getRegisteredSession, listRegisteredSessions, registerSession, + releaseSessionOperation, unregisterSession, } from '../session/registry.js'; import { sessionHasVerifiedLiveOwnership } from '../session/selection.js'; @@ -63,6 +65,18 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise let failures = 0; for (const session of sessions) { + let recoveryLease; + try { + recoveryLease = claimSessionOperation(session, 'recovery'); + } catch (error) { + failures += 1; + console.error( + `${chalk.red('✗')} Kept ${session.sessionName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + continue; + } setAgentBrowserDefaults({ allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath, @@ -71,6 +85,7 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise }); try { await cleanupFailedStart(session); + releaseSessionOperation(session, recoveryLease); unregisterSession(session.sessionName); console.log(`${chalk.green('✓')} Cleaned ${session.sessionName}`); } catch (error) { @@ -79,6 +94,10 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise session.cleanupError = error instanceof Error ? error.message : String(error); registerSession(session); console.error(`${chalk.red('✗')} Kept ${session.sessionName}: ${session.cleanupError}`); + } finally { + if (session.operationLease?.id === recoveryLease.id) { + releaseSessionOperation(session, recoveryLease); + } } } diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 7eabf76..484d766 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -28,7 +28,10 @@ const mocks = vi.hoisted(() => ({ captureAgentBrowserProcessIdentity: vi.fn(), cleanupFailedStart: vi.fn(), startOwnedEnvironment: vi.fn(), + claimSessionOperation: vi.fn(), registerSession: vi.fn(), + releaseSessionOperation: vi.fn(), + sessionHasLiveOperation: vi.fn(), unregisterSession: vi.fn(), execSync: vi.fn(), })); @@ -90,7 +93,10 @@ vi.mock('../environment/runtime.js', () => ({ })); vi.mock('../session/registry.js', () => ({ + claimSessionOperation: mocks.claimSessionOperation, registerSession: mocks.registerSession, + releaseSessionOperation: mocks.releaseSessionOperation, + sessionHasLiveOperation: mocks.sessionHasLiveOperation, unregisterSession: mocks.unregisterSession, })); @@ -141,6 +147,26 @@ describe('startCommand', () => { startTime: '12345', }); mocks.cleanupFailedStart.mockResolvedValue(undefined); + mocks.claimSessionOperation.mockImplementation((session, kind) => { + const lease = { + id: `${kind}-lease`, + kind, + owner: { + pid: process.pid, + processGroupId: process.pid, + sessionId: process.pid, + startTime: 'test', + }, + startedAt: new Date().toISOString(), + }; + session.operationLease = lease; + mocks.registerSession(session); + return lease; + }); + mocks.releaseSessionOperation.mockImplementation((session) => { + delete session.operationLease; + }); + mocks.sessionHasLiveOperation.mockReturnValue(false); mocks.startOwnedEnvironment.mockResolvedValue(null); mocks.preparePrivateNetworkEvidence.mockReturnValue({ privateDirectory: '/audit/private/agent-browser', diff --git a/src/commands/start.ts b/src/commands/start.ts index 1742717..786ab39 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -24,7 +24,13 @@ import { type SessionState, } from '../session/state.js'; import { cleanupFailedStart } from '../session/lifecycle.js'; -import { registerSession, unregisterSession } from '../session/registry.js'; +import { + claimSessionOperation, + registerSession, + releaseSessionOperation, + sessionHasLiveOperation, + unregisterSession, +} from '../session/registry.js'; import { listSessionsForControlDir, sessionHasVerifiedLiveOwnership, @@ -50,6 +56,20 @@ export async function startCommand(options: StartOptions): Promise { if (options.force) { const existingSessions = listSessionsForControlDir(controlDir); + const operationSessions = existingSessions.filter((session) => + sessionHasLiveOperation(session), + ); + if (operationSessions.length > 0) { + console.error( + chalk.red('✗') + + ' --force cannot take over a live ProofShot operation.\n' + + chalk.dim( + ` ${operationSessions[0].sessionName} is still ${operationSessions[0].operationLease?.kind || 'running'}.`, + ), + ); + process.exit(1); + return; + } const liveSessions = existingSessions.filter(sessionHasVerifiedLiveOwnership); if (liveSessions.length > 0) { console.error( @@ -63,6 +83,19 @@ export async function startCommand(options: StartOptions): Promise { return; } for (const existingSession of existingSessions) { + let recoveryLease; + try { + recoveryLease = claimSessionOperation(existingSession, 'recovery'); + } catch (error) { + console.error( + chalk.red('✗') + + ` Could not claim recovery for ${existingSession.sessionName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exit(1); + return; + } setAgentBrowserDefaults({ allowedDomains: existingSession.agentBrowserAllowedDomains, configPath: @@ -74,6 +107,7 @@ export async function startCommand(options: StartOptions): Promise { }); try { await cleanupFailedStart(existingSession); + releaseSessionOperation(existingSession, recoveryLease); unregisterSession(existingSession.sessionName); } catch (error) { existingSession.lifecycleStatus = 'recovery'; @@ -86,6 +120,10 @@ export async function startCommand(options: StartOptions): Promise { ); process.exit(1); return; + } finally { + if (existingSession.operationLease?.id === recoveryLease.id) { + releaseSessionOperation(existingSession, recoveryLease); + } } } if (existingSessions.length > 0) { @@ -210,8 +248,8 @@ export async function startCommand(options: StartOptions): Promise { environment: null, viewport: { width: config.viewport.width, height: config.viewport.height }, }; - persistOwnedSession(session); - const signalHandlers = installStartSignalHandlers(session); + const startLease = claimSessionOperation(session, 'start'); + const signalHandlers = installStartSignalHandlers(session, startLease); let failureContext = 'start the session'; try { @@ -317,6 +355,7 @@ export async function startCommand(options: StartOptions): Promise { const interruptionSignal = getTerminationSignal(error); try { await cleanupFailedStart(session); + releaseSessionOperation(session, startLease); clearOwnedSession(session); console.error( chalk.red('✗') + @@ -328,6 +367,9 @@ export async function startCommand(options: StartOptions): Promise { session.cleanupError = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); persistOwnedSession(session); + if (session.operationLease?.id === startLease.id) { + releaseSessionOperation(session, startLease); + } console.error( chalk.red('✗') + ` Failed to ${failureContext}: ${error.message}\n` + @@ -348,6 +390,7 @@ export async function startCommand(options: StartOptions): Promise { session.recordingActive = true; session.lifecycleStatus = 'active'; persistOwnedSession(session); + releaseSessionOperation(session, startLease); signalHandlers.remove(); console.log(''); @@ -395,6 +438,7 @@ function clearOwnedSession(session: SessionState): void { function installStartSignalHandlers( session: SessionState, + lease: NonNullable, ): { isHandling: () => boolean; remove: () => void } { let handlingSignal = false; const handlers = new Map void>(); @@ -407,6 +451,7 @@ function installStartSignalHandlers( handlingSignal = true; void cleanupFailedStart(session) .then(() => { + releaseSessionOperation(session, lease); clearOwnedSession(session); process.exit(signal === 'SIGINT' ? 130 : 143); }) @@ -414,6 +459,9 @@ function installStartSignalHandlers( session.lifecycleStatus = 'recovery'; session.cleanupError = error instanceof Error ? error.message : String(error); persistOwnedSession(session); + if (session.operationLease?.id === lease.id) { + releaseSessionOperation(session, lease); + } process.exit(1); }); }; diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index f4ae13e..7b5f899 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -16,7 +16,9 @@ const mocks = vi.hoisted(() => ({ stopOwnedBrowser: vi.fn(), stopOwnedServer: vi.fn(), canAddressOwnedBrowserSession: vi.fn(), + claimSessionOperation: vi.fn(), registerSession: vi.fn(), + releaseSessionOperation: vi.fn(), unregisterSession: vi.fn(), stopOwnedEnvironment: vi.fn(), writeViewer: vi.fn(), @@ -48,7 +50,9 @@ vi.mock('../session/lifecycle.js', () => ({ stopOwnedServer: mocks.stopOwnedServer, })); vi.mock('../session/registry.js', () => ({ + claimSessionOperation: mocks.claimSessionOperation, registerSession: mocks.registerSession, + releaseSessionOperation: mocks.releaseSessionOperation, unregisterSession: mocks.unregisterSession, })); vi.mock('../session/selection.js', () => ({ @@ -131,6 +135,24 @@ beforeEach(() => { mocks.stopOwnedServer.mockResolvedValue(undefined); mocks.stopOwnedEnvironment.mockResolvedValue(undefined); mocks.canAddressOwnedBrowserSession.mockReturnValue(true); + mocks.claimSessionOperation.mockImplementation((claimedSession) => { + const lease = { + id: 'stop-lease', + kind: 'stop', + owner: { + pid: process.pid, + processGroupId: process.pid, + sessionId: process.pid, + startTime: 'test', + }, + startedAt: new Date().toISOString(), + }; + claimedSession.operationLease = lease; + return lease; + }); + mocks.releaseSessionOperation.mockImplementation((claimedSession) => { + delete claimedSession.operationLease; + }); vi.spyOn(console, 'log').mockImplementation(() => {}); }); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 3aa07a2..b0434e8 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -22,7 +22,12 @@ import { stopOwnedBrowser, stopOwnedServer, } from '../session/lifecycle.js'; -import { registerSession, unregisterSession } from '../session/registry.js'; +import { + claimSessionOperation, + registerSession, + releaseSessionOperation, + unregisterSession, +} from '../session/registry.js'; import { resolveLiveSession } from '../session/selection.js'; import { stopOwnedEnvironment } from '../environment/runtime.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; @@ -104,6 +109,8 @@ export async function stopCommand(options: StopOptions): Promise { socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); + const stopLease = claimSessionOperation(session, 'stop'); + try { if (session.bundleComplete) { if (session.browserRetained && !options.noClose) { console.log(chalk.dim('Closing retained browser...')); @@ -585,12 +592,21 @@ export async function stopCommand(options: StopOptions): Promise { console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`)); } } + // Most browser finalization calls are synchronous. Yield once before + // removing handlers so a signal received during them is reflected in the + // command's final exit status after exact teardown finishes. + await new Promise((resolve) => setImmediate(resolve)); } finally { const interruptedBy = stopSignals.remove(); if (interruptedBy) { process.exitCode = interruptedBy === 'SIGINT' ? 130 : 143; } } + } finally { + if (session.operationLease?.id === stopLease.id) { + releaseSessionOperation(session, stopLease); + } + } } function installStopSignalHandlers(): { diff --git a/src/session/registry.test.ts b/src/session/registry.test.ts index cfa73b6..ee95fb1 100644 --- a/src/session/registry.test.ts +++ b/src/session/registry.test.ts @@ -3,9 +3,12 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + claimSessionOperation, getRegisteredSession, listRegisteredSessions, registerSession, + releaseSessionOperation, + sessionHasLiveOperation, unregisterSession, } from './registry.js'; import type { SessionState } from './state.js'; @@ -55,6 +58,80 @@ describe('session registry', () => { expect(getRegisteredSession(state.sessionName, registryDir)).toBeNull(); }); + + it('claims and releases one immutable operation owner', () => { + const state = buildSession('ps-owned-operation'); + + const lease = claimSessionOperation(state, 'start', registryDir); + + expect(sessionHasLiveOperation(state, registryDir)).toBe(true); + expect(getRegisteredSession(state.sessionName, registryDir)?.operationLease).toEqual( + lease, + ); + expect(() => claimSessionOperation(state, 'stop', registryDir)).toThrow( + /live start operation/, + ); + const lockPath = path.join( + registryDir, + `${state.sessionName}.operation.lock`, + ); + expect(fs.statSync(lockPath).mode & 0o777).toBe(0o600); + + releaseSessionOperation(state, lease, registryDir); + + expect(fs.existsSync(lockPath)).toBe(false); + expect(state.operationLease).toBeUndefined(); + expect( + getRegisteredSession(state.sessionName, registryDir)?.operationLease, + ).toBeUndefined(); + }); + + it('reclaims a lease only after its immutable owner is gone', () => { + const state = buildSession('ps-stale-operation'); + const staleLease = claimSessionOperation(state, 'start', registryDir); + const deadOwner = { + ...staleLease.owner, + pid: 2_000_000_000, + processGroupId: 2_000_000_000, + sessionId: 2_000_000_000, + }; + const staleState = { + ...state, + operationLease: { ...staleLease, owner: deadOwner }, + }; + registerSession(staleState, registryDir); + fs.writeFileSync( + path.join(registryDir, `${state.sessionName}.operation.lock`), + JSON.stringify(staleState.operationLease, null, 2) + '\n', + ); + + const replacement = claimSessionOperation( + staleState, + 'recovery', + registryDir, + ); + + expect(replacement.id).not.toBe(staleLease.id); + expect(replacement.kind).toBe('recovery'); + releaseSessionOperation(staleState, replacement, registryDir); + }); + + it('fails closed when an operation lock is corrupt', () => { + const state = buildSession('ps-corrupt-operation'); + registerSession(state, registryDir); + fs.writeFileSync( + path.join(registryDir, `${state.sessionName}.operation.lock`), + '{', + { mode: 0o600 }, + ); + + expect(() => claimSessionOperation(state, 'recovery', registryDir)).toThrow( + /corrupt or unsafe/, + ); + expect(() => sessionHasLiveOperation(state, registryDir)).toThrow( + /corrupt or unsafe/, + ); + }); }); function buildSession(sessionName: string): SessionState { diff --git a/src/session/registry.ts b/src/session/registry.ts index 4c1e21d..3c3376a 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -2,8 +2,16 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { randomUUID } from 'crypto'; -import type { ProcessIdentity } from '../utils/process.js'; -import type { SessionState } from './state.js'; +import { + captureProcessIdentity, + processIdentityMatches, + type ProcessIdentity, +} from '../utils/process.js'; +import type { + SessionOperationKind, + SessionOperationLease, + SessionState, +} from './state.js'; const SESSION_REGISTRY_DIRECTORY = 'sessions'; @@ -70,6 +78,122 @@ export function getRegisteredSession( return readRegisteredSession(getRegistryPath(sessionName, registryDir)); } +export function claimSessionOperation( + session: SessionState, + kind: SessionOperationKind, + registryDir = getSessionRegistryDir(), +): SessionOperationLease { + validateSessionName(session.sessionName); + if ( + session.operationLease && + processIdentityMatches(session.operationLease.owner) + ) { + throw new Error( + `ProofShot session ${session.sessionName} already has a live ${session.operationLease.kind} operation.`, + ); + } + const owner = captureProcessIdentity(process.pid); + if (!owner) { + throw new Error('Could not capture immutable ownership for this ProofShot operation.'); + } + const lease: SessionOperationLease = { + id: randomUUID(), + kind, + owner, + startedAt: new Date().toISOString(), + }; + prepareRegistryDirectory(registryDir); + const lockPath = getOperationLockPath(session.sessionName, registryDir); + for (let attempt = 0; attempt < 3; attempt += 1) { + let lockDescriptor: number; + try { + lockDescriptor = fs.openSync(lockPath, 'wx', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + const existingLease = readOperationLease(lockPath); + if (!existingLease) { + throw new Error( + `ProofShot operation lock is corrupt or unsafe: ${lockPath}`, + ); + } + if (processIdentityMatches(existingLease.owner)) { + throw new Error( + `ProofShot session ${session.sessionName} already has a live ${existingLease.kind} operation.`, + ); + } + reclaimStaleOperationLock(lockPath, existingLease); + continue; + } + + try { + fs.writeFileSync(lockDescriptor, JSON.stringify(lease, null, 2) + '\n'); + } finally { + fs.closeSync(lockDescriptor); + } + try { + session.operationLease = lease; + registerSession(session, registryDir); + return lease; + } catch (error) { + removeOwnedOperationLock(lockPath, lease); + throw error; + } + } + throw new Error(`Could not claim ProofShot operation lock: ${lockPath}`); +} + +export function releaseSessionOperation( + session: SessionState, + lease: SessionOperationLease, + registryDir = getSessionRegistryDir(), +): void { + validateSessionName(session.sessionName); + const lockPath = getOperationLockPath(session.sessionName, registryDir); + if (!fs.existsSync(lockPath)) { + throw new Error(`ProofShot operation lock disappeared: ${lockPath}`); + } + const currentLease = readOperationLease(lockPath); + if (!currentLease || currentLease.id !== lease.id) { + throw new Error( + `ProofShot operation lock no longer belongs to ${lease.id}: ${lockPath}`, + ); + } + + const registeredSession = getRegisteredSession(session.sessionName, registryDir); + if (registeredSession?.operationLease?.id === lease.id) { + delete registeredSession.operationLease; + registerSession(registeredSession, registryDir); + } else if (registeredSession?.operationLease) { + throw new Error( + `ProofShot session operation no longer belongs to ${lease.id}: ${session.sessionName}`, + ); + } + fs.unlinkSync(lockPath); + delete session.operationLease; +} + +export function sessionHasLiveOperation( + session: SessionState, + registryDir = getSessionRegistryDir(), +): boolean { + validateSessionName(session.sessionName); + const lockPath = getOperationLockPath(session.sessionName, registryDir); + if (fs.existsSync(lockPath)) { + const lease = readOperationLease(lockPath); + if (!lease) { + throw new Error(`ProofShot operation lock is corrupt or unsafe: ${lockPath}`); + } + if (processIdentityMatches(lease.owner)) { + return true; + } + } + return Boolean( + session.operationLease && processIdentityMatches(session.operationLease.owner), + ); +} + function prepareRegistryDirectory(registryDir: string): void { fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 }); assertOwnedDirectory(registryDir); @@ -97,6 +221,56 @@ function getRegistryPath(sessionName: string, registryDir: string): string { return path.join(registryDir, `${sessionName}.json`); } +function getOperationLockPath(sessionName: string, registryDir: string): string { + return path.join(registryDir, `${sessionName}.operation.lock`); +} + +function reclaimStaleOperationLock( + lockPath: string, + expectedLease: SessionOperationLease, +): void { + const reclaimPath = `${lockPath}.reclaim`; + try { + fs.mkdirSync(reclaimPath, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`ProofShot operation lock is already being recovered: ${lockPath}`); + } + throw error; + } + + try { + const currentLease = readOperationLease(lockPath); + if (!currentLease) { + throw new Error(`ProofShot operation lock is corrupt or unsafe: ${lockPath}`); + } + if (currentLease.id !== expectedLease.id) { + return; + } + if (processIdentityMatches(currentLease.owner)) { + throw new Error( + `ProofShot session already has a live ${currentLease.kind} operation.`, + ); + } + fs.unlinkSync(lockPath); + } finally { + fs.rmdirSync(reclaimPath); + } +} + +function removeOwnedOperationLock( + lockPath: string, + expectedLease: SessionOperationLease, +): void { + if (!fs.existsSync(lockPath)) { + return; + } + const currentLease = readOperationLease(lockPath); + if (currentLease?.id === expectedLease.id) { + fs.unlinkSync(lockPath); + } +} + function validateSessionName(sessionName: string): void { if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) { throw new Error(`Invalid ProofShot session name: ${sessionName}`); @@ -116,6 +290,32 @@ function readRegisteredSession(registryPath: string): SessionState | null { } } +function readOperationLease(lockPath: string): SessionOperationLease | null { + try { + const stat = fs.lstatSync(lockPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + return null; + } + const value = JSON.parse(fs.readFileSync(lockPath, 'utf-8')) as unknown; + if (typeof value !== 'object' || value === null) { + return null; + } + const lease = value as Partial; + return ( + typeof lease.id === 'string' && + ['exec', 'recovery', 'start', 'stop'].includes(lease.kind || '') && + isOptionalProcessIdentity(lease.owner) && + lease.owner !== undefined && + lease.owner !== null && + typeof lease.startedAt === 'string' + ) + ? (lease as SessionOperationLease) + : null; + } catch { + return null; + } +} + function isSessionState(value: unknown): value is SessionState { if (typeof value !== 'object' || value === null) { return false; @@ -143,6 +343,7 @@ function isSessionState(value: unknown): value is SessionState { isOptionalString(session.networkHarPath) && isOptionalString(session.networkRequestsPath) && isOptionalString(session.networkSummaryPath) && + isOptionalOperationLease(session.operationLease) && isOptionalBoolean(session.networkCaptureStarted) && isOptionalBoolean(session.networkCaptureActive) && isOptionalBoolean(session.networkEvidenceAvailable) && @@ -169,6 +370,24 @@ function isOptionalBoolean(value: unknown): boolean { return value === undefined || typeof value === 'boolean'; } +function isOptionalOperationLease(value: unknown): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null) { + return false; + } + const lease = value as Partial; + return ( + typeof lease.id === 'string' && + ['exec', 'recovery', 'start', 'stop'].includes(lease.kind || '') && + typeof lease.startedAt === 'string' && + isOptionalProcessIdentity(lease.owner) && + lease.owner !== undefined && + lease.owner !== null + ); +} + function isOptionalProcessIdentity(value: unknown): boolean { if (value === undefined || value === null) { return true; diff --git a/src/session/state.ts b/src/session/state.ts index 7e5598b..6812414 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -3,6 +3,15 @@ import { createHash, randomUUID } from 'crypto'; import type { EnvironmentState } from '../environment/types.js'; import type { ProcessIdentity } from '../utils/process.js'; +export type SessionOperationKind = 'exec' | 'recovery' | 'start' | 'stop'; + +export type SessionOperationLease = { + id: string; + kind: SessionOperationKind; + owner: ProcessIdentity; + startedAt: string; +}; + export interface SessionState { startedAt: string; recordingStartedAt?: string; @@ -11,6 +20,7 @@ export interface SessionState { controlDir?: string; lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery'; cleanupError?: string | null; + operationLease?: SessionOperationLease; description: string | null; outputDir: string; sessionDir: string; From 80a32e46df52f9ac25c0fe4e32eafa0330921fe1 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:27:00 +0000 Subject: [PATCH 23/74] fix(session): ignore stale implicit targets Co-authored-by: Codex --- src/commands/clean.test.ts | 36 ++++-- src/commands/clean.ts | 15 +-- src/commands/exec.ts | 1 + src/commands/lifecycle.integration.test.ts | 60 ++++++++- src/commands/stop.ts | 1 + src/session/selection.test.ts | 142 +++++++++++++++++++++ src/session/selection.ts | 40 +++++- 7 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 src/session/selection.test.ts diff --git a/src/commands/clean.test.ts b/src/commands/clean.test.ts index 3fe89d0..b48b83d 100644 --- a/src/commands/clean.test.ts +++ b/src/commands/clean.test.ts @@ -6,12 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(), listSessionsForControlDir: vi.fn(), - sessionHasVerifiedLiveOwnership: vi.fn(), })); vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); vi.mock('../session/selection.js', () => ({ listSessionsForControlDir: mocks.listSessionsForControlDir, - sessionHasVerifiedLiveOwnership: mocks.sessionHasVerifiedLiveOwnership, })); import { cleanCommand } from './clean.js'; @@ -23,8 +21,7 @@ beforeEach(() => { fs.mkdirSync(cache, { recursive: true }); root = fs.mkdtempSync(path.join(cache, 'proofshot-clean-test-')); mocks.loadConfig.mockReturnValue({ output: root }); - mocks.listSessionsForControlDir.mockReturnValue([{ sessionName: 'ps-live' }]); - mocks.sessionHasVerifiedLiveOwnership.mockReturnValue(true); + vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 0}`); @@ -38,15 +35,30 @@ afterEach(() => { }); describe('cleanCommand', () => { - it('refuses to discard active exact-process ownership metadata', async () => { - const evidencePath = path.join(root, 'evidence.txt'); - fs.writeFileSync(evidencePath, 'keep'); + it.each(['starting', 'active', 'stopping', 'recovery', undefined])( + 'refuses to discard evidence while %s registry state remains', + async (lifecycleStatus) => { + mocks.listSessionsForControlDir.mockReturnValue([ + { lifecycleStatus, sessionName: 'ps-registered' }, + ]); + const evidencePath = path.join(root, 'evidence.txt'); + fs.writeFileSync(evidencePath, 'keep'); - await expect(cleanCommand()).rejects.toThrow('process.exit:1'); + await expect(cleanCommand()).rejects.toThrow('process.exit:1'); - expect(fs.readFileSync(evidencePath, 'utf-8')).toBe('keep'); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('Run "proofshot stop" first'), - ); + expect(fs.readFileSync(evidencePath, 'utf-8')).toBe('keep'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Run "proofshot stop" or "proofshot session clean"'), + ); + }, + ); + + it('removes evidence after all registry state is cleared', async () => { + mocks.listSessionsForControlDir.mockReturnValue([]); + fs.writeFileSync(path.join(root, 'evidence.txt'), 'remove'); + + await cleanCommand(); + + expect(fs.existsSync(root)).toBe(false); }); }); diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 3587766..2e0446b 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -3,23 +3,20 @@ import * as path from 'path'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; import { resolveSessionControlDir } from '../session/state.js'; -import { - listSessionsForControlDir, - sessionHasVerifiedLiveOwnership, -} from '../session/selection.js'; +import { listSessionsForControlDir } from '../session/selection.js'; export async function cleanCommand(): Promise { const config = loadConfig(); const controlDir = resolveSessionControlDir(config.output); const outputDir = path.resolve(config.output); - if ( - listSessionsForControlDir(controlDir).some(sessionHasVerifiedLiveOwnership) - ) { + if (listSessionsForControlDir(controlDir).length > 0) { console.error( chalk.red('✗') + - ' Cannot clean while a ProofShot session owns browser or server processes.\n' + - chalk.dim('Run "proofshot stop" first so exact cleanup metadata is preserved.'), + ' Cannot clean while ProofShot has registered session state for this output.\n' + + chalk.dim( + 'Run "proofshot stop" or "proofshot session clean" first so evidence and exact cleanup metadata are preserved.', + ), ); process.exit(1); return; diff --git a/src/commands/exec.ts b/src/commands/exec.ts index acb19c4..0fea961 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -221,6 +221,7 @@ export async function execCommand( const controlDir = resolveSessionControlDir(config.output); const session = resolveLiveSession({ controlDir, + operation: 'exec', sessionName: options.session, }); setAgentBrowserDefaults({ diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index ef935c5..5f29915 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -784,6 +784,64 @@ describe('isolated CLI lifecycle', () => { } }, 30000); + it('automatically selects the one live session when a stale record remains', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + for (const target of ['stale', 'live']) { + const start = runCli(audit, env, [ + 'start', + '--url', + `https://example.invalid/${target}`, + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + } + const sessions = readRegisteredSessions(env); + const staleSession = sessions.find((session) => + session.targetUrl.endsWith('/stale'), + ); + const liveSession = sessions.find((session) => + session.targetUrl.endsWith('/live'), + ); + expect(staleSession).toBeDefined(); + expect(liveSession).toBeDefined(); + cleanupProcesses.push(staleSession.browserProcess, liveSession.browserProcess); + await terminateOwnedProcessTree(staleSession.browserProcess, { graceMs: 300 }); + await waitForProcessExit(staleSession.browserProcess.pid); + cleanupProcesses.splice( + cleanupProcesses.indexOf(staleSession.browserProcess), + 1, + ); + + const implicitExec = runCli(audit, env, ['exec', 'get', 'url']); + expect(implicitExec.status, `${implicitExec.stdout}\n${implicitExec.stderr}`).toBe(0); + expect(implicitExec.stdout.trim()).toBe(liveSession.targetUrl); + + const staleCleanup = runCli(audit, env, [ + 'session', + 'clean', + '--session', + staleSession.sessionName, + ]); + expect( + staleCleanup.status, + `${staleCleanup.stdout}\n${staleCleanup.stderr}`, + ).toBe(0); + const finalStop = runCli(audit, env, ['stop']); + expect(finalStop.status, `${finalStop.stdout}\n${finalStop.stderr}`).toBe(0); + await waitForProcessExit(liveSession.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(liveSession.browserProcess), 1); + expect(readRegisteredSessions(env)).toEqual([]); + }, 30000); + it('keeps both owned browsers isolated when one exec process is interrupted', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -1093,7 +1151,7 @@ describe('isolated CLI lifecycle', () => { const mismatchedExec = runCli(nestedCwd, env, ['exec', 'get', 'url']); expect(mismatchedExec.status).toBe(1); expect(mismatchedExec.stderr).toContain( - 'Browser ownership no longer matches this ProofShot session', + 'No active ProofShot session matches this worktree', ); expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe( browserLogBeforeMismatchedExec, diff --git a/src/commands/stop.ts b/src/commands/stop.ts index b0434e8..7217035 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -94,6 +94,7 @@ export async function stopCommand(options: StopOptions): Promise { // Load session state const session = resolveLiveSession({ controlDir, + operation: 'stop', sessionName: options.session, }); if (!session) { diff --git a/src/session/selection.test.ts b/src/session/selection.test.ts new file mode 100644 index 0000000..b91e8ea --- /dev/null +++ b/src/session/selection.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + canAddressOwnedBrowserSession: vi.fn(), + getRegisteredSession: vi.fn(), + listRegisteredSessions: vi.fn(), + ownedProcessTreeIsAlive: vi.fn(), +})); + +vi.mock('./lifecycle.js', () => ({ + canAddressOwnedBrowserSession: mocks.canAddressOwnedBrowserSession, +})); +vi.mock('./registry.js', () => ({ + getRegisteredSession: mocks.getRegisteredSession, + listRegisteredSessions: mocks.listRegisteredSessions, +})); +vi.mock('../utils/process.js', () => ({ + ownedProcessTreeIsAlive: mocks.ownedProcessTreeIsAlive, +})); + +import { resolveLiveSession } from './selection.js'; +import type { SessionState } from './state.js'; +import type { ProcessIdentity } from '../utils/process.js'; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + mocks.ownedProcessTreeIsAlive.mockReturnValue(false); +}); + +describe('resolveLiveSession', () => { + it('selects the one addressable recording for exec and ignores stale records', () => { + const stale = buildSession('ps-stale'); + const live = buildSession('ps-live'); + mocks.listRegisteredSessions.mockReturnValue([stale, live]); + mocks.canAddressOwnedBrowserSession.mockImplementation( + (session) => session.sessionName === live.sessionName, + ); + + expect( + resolveLiveSession({ controlDir: '/project/output', operation: 'exec' }), + ).toBe(live); + }); + + it('does not present one stale active record as a live exec session', () => { + mocks.listRegisteredSessions.mockReturnValue([buildSession('ps-stale')]); + + expect( + resolveLiveSession({ controlDir: '/project/output', operation: 'exec' }), + ).toBeNull(); + }); + + it('prefers one verified live stop target over stale active records', () => { + const stale = buildSession('ps-stale'); + const live = buildSession('ps-live', { + browserProcess: buildProcessIdentity(42), + }); + mocks.listRegisteredSessions.mockReturnValue([stale, live]); + mocks.ownedProcessTreeIsAlive.mockImplementation( + (identity) => identity.pid === 42, + ); + + expect( + resolveLiveSession({ controlDir: '/project/output', operation: 'stop' }), + ).toBe(live); + }); + + it('resumes one interrupted stopping session when no owned process is live', () => { + const stopping = buildSession('ps-stopping', { + lifecycleStatus: 'stopping', + recordingActive: false, + }); + mocks.listRegisteredSessions.mockReturnValue([stopping]); + + expect( + resolveLiveSession({ controlDir: '/project/output', operation: 'stop' }), + ).toBe(stopping); + }); + + it('reports statuses when multiple live sessions require an explicit target', () => { + const first = buildSession('ps-first', { + browserProcess: buildProcessIdentity(41), + }); + const second = buildSession('ps-second', { + browserProcess: buildProcessIdentity(42), + }); + mocks.listRegisteredSessions.mockReturnValue([first, second]); + mocks.ownedProcessTreeIsAlive.mockReturnValue(true); + + expect(() => + resolveLiveSession({ controlDir: '/project/output', operation: 'stop' }), + ).toThrow(/ps-first \(active\)[\s\S]*ps-second \(active\)/); + }); + + it('honors an explicit stale session for recovery-oriented commands', () => { + const stale = buildSession('ps-explicit'); + mocks.getRegisteredSession.mockReturnValue(stale); + + expect( + resolveLiveSession({ + controlDir: '/project/output', + operation: 'stop', + sessionName: stale.sessionName, + }), + ).toBe(stale); + }); +}); + +function buildSession( + sessionName: string, + overrides: Partial = {}, +): SessionState { + return { + startedAt: '2026-08-15T20:00:00.000Z', + startDirectory: '/project', + controlDir: '/project/output', + lifecycleStatus: 'active', + description: null, + outputDir: '/project/output', + sessionDir: `/project/output/${sessionName}`, + sessionName, + videoPath: `/project/output/${sessionName}/session.webm`, + serverErrorLog: `/project/output/${sessionName}/server.log`, + port: 3000, + serverCommand: null, + serverAlreadyRunning: true, + recordingActive: true, + browserProcess: buildProcessIdentity(99), + serverProcess: null, + environment: null, + ...overrides, + }; +} + +function buildProcessIdentity(pid: number): ProcessIdentity { + return { + pid, + processGroupId: pid, + sessionId: pid, + startTime: String(pid), + }; +} diff --git a/src/session/selection.ts b/src/session/selection.ts index 488671a..d73494c 100644 --- a/src/session/selection.ts +++ b/src/session/selection.ts @@ -5,10 +5,12 @@ import { getRegisteredSession, listRegisteredSessions, } from './registry.js'; +import { canAddressOwnedBrowserSession } from './lifecycle.js'; import type { SessionState } from './state.js'; type ResolveSessionOptions = { controlDir: string; + operation: 'exec' | 'stop'; sessionName?: string; }; @@ -32,9 +34,13 @@ export function resolveLiveSession( return session; } - const sessions = listSessionsForControlDir(options.controlDir).filter( + const registeredSessions = listSessionsForControlDir(options.controlDir).filter( (session) => session.lifecycleStatus !== 'recovery', ); + const sessions = selectSessionsForOperation( + registeredSessions, + options.operation, + ); if (sessions.length === 0) { return null; } @@ -42,7 +48,12 @@ export function resolveLiveSession( return sessions[0]; } - const choices = sessions.map((session) => ` ${session.sessionName}`).join('\n'); + const choices = sessions + .map( + (session) => + ` ${session.sessionName} (${session.lifecycleStatus || 'stale'})`, + ) + .join('\n'); throw new Error( 'Multiple active ProofShot sessions match this worktree. ' + 'Re-run with --session :\n' + @@ -50,6 +61,31 @@ export function resolveLiveSession( ); } +function selectSessionsForOperation( + sessions: SessionState[], + operation: ResolveSessionOptions['operation'], +): SessionState[] { + if (operation === 'exec') { + return sessions.filter( + (session) => + session.recordingActive && canAddressOwnedBrowserSession(session), + ); + } + + const liveSessions = sessions.filter( + (session) => + sessionHasVerifiedLiveOwnership(session) || + canAddressOwnedBrowserSession(session), + ); + if (liveSessions.length > 0) { + return liveSessions; + } + return sessions.filter( + (session) => + session.lifecycleStatus === 'stopping' && session.bundleComplete !== true, + ); +} + export function sessionHasVerifiedLiveOwnership( session: SessionState, ): boolean { From 4ae5c24f41fd5c505c0ca7d6c3e78e27f1b568a3 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 24/74] fix(evidence): sanitize nested browser actions Co-authored-by: Codex --- src/browser/provenance.ts | 172 +++++++++++++++++++++++++++++--------- src/commands/exec.ts | 16 +++- 2 files changed, 144 insertions(+), 44 deletions(-) diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts index ddaeedf..a47984c 100644 --- a/src/browser/provenance.ts +++ b/src/browser/provenance.ts @@ -1,6 +1,44 @@ const REDACTED = '[REDACTED]'; +const REDACTED_ARGUMENTS = '[REDACTED_ARGUMENTS]'; +const REDACTED_BATCH = '[REDACTED_BATCH]'; const SECRET_FLAG = /^(?:--)?(?:authorization|cookie|credential|password|secret|token|api[-_]?key|body)$/i; const SECRET_QUERY_PARAMETER = /(?:auth|code|cookie|credential|key|password|secret|session|token)/i; +const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i; + +const HYBRID_ACTIONS = new Set(['check', 'fill', 'select', 'type', 'uncheck']); +const OBSERVATION_ACTIONS = new Set([ + 'assert-visible', + 'console', + 'errors', + 'get', + 'is', + 'read', + 'screenshot', + 'snapshot', + 'text', +]); +const POINTER_KEYBOARD_ACTIONS = new Set([ + 'click', + 'dblclick', + 'drag', + 'hover', + 'key', + 'keydown', + 'keyup', + 'mouse', + 'press', +]); +const SETUP_ACTIONS = new Set([ + 'auth', + 'close', + 'navigate', + 'open', + 'record', + 'set', + 'tab', + 'upload', + 'wait', +]); export type InteractionCategory = | 'observation' @@ -31,49 +69,43 @@ export function classifyInteraction(args: string[]): InteractionCategory { if (!command) { return 'unknown'; } - if ( - [ - 'assert-visible', - 'console', - 'errors', - 'get', - 'is', - 'read', - 'screenshot', - 'snapshot', - ].includes(command) - ) { + if (command === 'find') { + const nestedAction = args[3]?.toLowerCase(); + return nestedAction + ? classifyInteraction([nestedAction]) + : 'observation'; + } + if (command === 'keyboard') { + return ['inserttext', 'press', 'type'].includes(args[1]?.toLowerCase()) + ? 'pointer-keyboard' + : 'unknown'; + } + if (command === 'cookies' || command === 'storage') { + return !args[1] || ['get', 'list'].includes(args[1].toLowerCase()) + ? 'observation' + : 'setup'; + } + if (command === 'network') { + return !args[1] || ['get', 'requests'].includes(args[1].toLowerCase()) + ? 'observation' + : 'setup'; + } + if (command === 'console') { + return args[1]?.toLowerCase() === 'clear' ? 'setup' : 'observation'; + } + if (OBSERVATION_ACTIONS.has(command)) { return 'observation'; } - if ( - ['click', 'dblclick', 'drag', 'hover', 'key', 'mouse', 'press'].includes( - command, - ) - ) { + if (POINTER_KEYBOARD_ACTIONS.has(command)) { return 'pointer-keyboard'; } - if (['check', 'fill', 'select', 'type', 'uncheck'].includes(command)) { + if (HYBRID_ACTIONS.has(command)) { return 'hybrid'; } if (command === 'eval') { return 'synthetic-dom'; } - if ( - [ - 'close', - 'cookies', - 'find', - 'navigate', - 'network', - 'open', - 'record', - 'set', - 'storage', - 'tab', - 'upload', - 'wait', - ].includes(command) - ) { + if (SETUP_ACTIONS.has(command)) { return 'setup'; } return 'unknown'; @@ -83,8 +115,18 @@ export function sanitizePageUrl(value: string | undefined): string | undefined { if (!value) { return value; } + return sanitizeUrlValue(value); +} + +function sanitizeUrlValue(value: string): string { + const scheme = value.match(URL_SCHEME)?.[1]?.toLowerCase(); try { const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) { + return url.protocol === 'about:' && url.pathname === 'blank' + ? 'about:blank' + : `[REDACTED_URL:${url.protocol.slice(0, -1)}]`; + } url.username = ''; url.password = ''; for (const key of url.searchParams.keys()) { @@ -92,9 +134,10 @@ export function sanitizePageUrl(value: string | undefined): string | undefined { url.searchParams.set(key, REDACTED); } } + url.hash = ''; return url.toString(); } catch { - return sanitizeDiagnosticMessage(value); + return scheme ? `[REDACTED_URL:${scheme}]` : '[REDACTED_URL]'; } } @@ -109,14 +152,29 @@ export function sanitizeDiagnosticMessage(value: string | undefined): string | u /\b(authorization|cookie|credential|password|secret|token|api[-_]?key)\b\s*[:=]\s*([^\s,;]+)/gi, `$1=${REDACTED}`, ) - .replace(/https?:\/\/[^\s"']+/g, (url) => sanitizePageUrl(url) || REDACTED); + .replace(/https?:\/\/[^\s"'<>]+/g, sanitizeUrlValue); } function sanitizeArguments(command: string, args: string[]): string[] { if (command === 'eval') { return [command, '[REDACTED_SCRIPT]']; } - if (['fill', 'type'].includes(command)) { + if (command === 'batch') { + return [command, REDACTED_BATCH]; + } + if (command === 'auth') { + return [command, REDACTED_ARGUMENTS]; + } + if (command === 'find') { + return sanitizeFindArguments(args); + } + if ( + command === 'keyboard' && + ['inserttext', 'type'].includes(args[1]?.toLowerCase()) + ) { + return [command, args[1].toLowerCase(), REDACTED]; + } + if (['fill', 'select', 'type'].includes(command)) { return [command, sanitizeArgument(args[1] || ''), REDACTED]; } if (command === 'set' && ['credentials', 'headers'].includes(args[1]?.toLowerCase())) { @@ -125,9 +183,23 @@ function sanitizeArguments(command: string, args: string[]): string[] { if (command === 'cookies' && args[1]?.toLowerCase() === 'set') { return [command, 'set', sanitizeArgument(args[2] || ''), REDACTED]; } + if (command === 'storage') { + const setIndex = args.findIndex( + (argument, index) => index > 0 && argument.toLowerCase() === 'set', + ); + if (setIndex >= 0) { + return [ + ...args.slice(0, setIndex + 2).map(sanitizeArgument), + REDACTED, + ]; + } + } if (command === 'upload') { return [command, sanitizeArgument(args[1] || ''), '[LOCAL_FILE]']; } + if (!isKnownCommand(command)) { + return args.length > 1 ? [command, REDACTED_ARGUMENTS] : [command]; + } const sanitized: string[] = []; let redactNext = false; @@ -147,10 +219,30 @@ function sanitizeArguments(command: string, args: string[]): string[] { return sanitized; } +function sanitizeFindArguments(args: string[]): string[] { + const nestedAction = args[3]?.toLowerCase(); + if (!nestedAction) { + return args.map(sanitizeArgument); + } + if (['fill', 'select', 'type'].includes(nestedAction)) { + return [...args.slice(0, 4).map(sanitizeArgument), REDACTED]; + } + return args.map(sanitizeArgument); +} + +function isKnownCommand(command: string): boolean { + return ( + HYBRID_ACTIONS.has(command) || + OBSERVATION_ACTIONS.has(command) || + POINTER_KEYBOARD_ACTIONS.has(command) || + SETUP_ACTIONS.has(command) || + ['cookies', 'find', 'keyboard', 'network', 'storage'].includes(command) + ); +} + function sanitizeArgument(value: string): string { - const url = sanitizePageUrl(value); - if (url !== value) { - return url || REDACTED; + if (URL_SCHEME.test(value)) { + return sanitizeUrlValue(value); } return sanitizeDiagnosticMessage(value) || ''; } diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 0fea961..81f8c5a 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -259,6 +259,7 @@ export async function execCommand( const execLease = claimSessionOperation(session, 'exec'); try { + const pageUrlBefore = capturePageUrl(session.sessionName); // Resolve args (screenshot path rewriting) let resolvedArgs = translated.agentBrowserArgs; if (session) { @@ -287,6 +288,7 @@ export async function execCommand( action, category: classifyInteraction(args), intent, + pageUrl: pageUrlBefore, relativeTimeSec, timestamp: now.toISOString(), expectedSelector: sanitizeDiagnosticMessage(translated.expectedSelector), @@ -334,9 +336,7 @@ export async function execCommand( process.stdout.write('\n'); } } - const pageUrl = session - ? sanitizePageUrl(getPageUrl(session.sessionName) || undefined) - : undefined; + const pageUrl = capturePageUrl(session.sessionName); const agentBrowserResult = session ? writePrivateAgentBrowserResult({ command: intent.command, @@ -381,7 +381,7 @@ export async function execCommand( sessionLogPath, 'failed', persistedError, - undefined, + pageUrlBefore, Date.now() - executionStartedAt, agentBrowserResult, ); @@ -411,6 +411,14 @@ export async function execCommand( } } +function capturePageUrl(sessionName: string): string | undefined { + try { + return sanitizePageUrl(getPageUrl(sessionName) || undefined); + } catch { + return undefined; + } +} + function persistActionOutcome( entry: SessionLogEntry | null, logPath: string | null, From e11c162749de76cb81608deaadd88fe1ebdf8bbb Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 25/74] test(evidence): cover nested action redaction Co-authored-by: Codex --- src/browser/provenance.test.ts | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/browser/provenance.test.ts b/src/browser/provenance.test.ts index e483c10..899b25c 100644 --- a/src/browser/provenance.test.ts +++ b/src/browser/provenance.test.ts @@ -17,6 +17,26 @@ describe('browser interaction provenance', () => { expect(classifyInteraction(['open', 'https://example.com'])).toBe('setup'); }); + it('classifies nested and keyboard commands by their effective action', () => { + expect( + classifyInteraction(['find', 'role', 'button', 'click', '--name', 'Submit']), + ).toBe('pointer-keyboard'); + expect( + classifyInteraction(['find', 'label', 'Password', 'fill', 'private']), + ).toBe('hybrid'); + expect(classifyInteraction(['find', 'text', 'Welcome', 'text'])).toBe( + 'observation', + ); + expect(classifyInteraction(['keyboard', 'type', 'private'])).toBe( + 'pointer-keyboard', + ); + expect(classifyInteraction(['keydown', 'Shift'])).toBe('pointer-keyboard'); + expect(classifyInteraction(['storage', 'get', 'theme'])).toBe('observation'); + expect(classifyInteraction(['storage', 'set', 'theme', 'dark'])).toBe('setup'); + expect(classifyInteraction(['network', 'requests'])).toBe('observation'); + expect(classifyInteraction(['network', 'har', 'start'])).toBe('setup'); + }); + it('redacts typed values, credentials, scripts, and sensitive URL parameters', () => { expect( buildSanitizedCommandIntent(['fill', '@e2', 'private-value']).summary, @@ -36,6 +56,41 @@ describe('browser interaction provenance', () => { ]).summary, ).toBe('eval [REDACTED_SCRIPT]'); + const nestedSecrets = [ + buildSanitizedCommandIntent([ + 'find', + 'label', + 'Password', + 'fill', + 'private-find-value', + ]).summary, + buildSanitizedCommandIntent([ + 'keyboard', + 'type', + 'private-keyboard-value', + ]).summary, + buildSanitizedCommandIntent([ + 'storage', + 'local', + 'set', + 'password', + 'private-storage-value', + ]).summary, + buildSanitizedCommandIntent([ + 'batch', + '[{"command":"fill","value":"private-batch-value"}]', + ]).summary, + buildSanitizedCommandIntent([ + 'future-command', + 'private-unknown-value', + ]).summary, + ].join('\n'); + expect(nestedSecrets).not.toContain('private-find-value'); + expect(nestedSecrets).not.toContain('private-keyboard-value'); + expect(nestedSecrets).not.toContain('private-storage-value'); + expect(nestedSecrets).not.toContain('private-batch-value'); + expect(nestedSecrets).not.toContain('private-unknown-value'); + const url = sanitizePageUrl( 'https://user:pass@example.com/callback?token=private&tab=profile', ); @@ -44,6 +99,21 @@ describe('browser interaction provenance', () => { ); }); + it('sanitizes fragments, opaque URLs, and malformed URLs without recursion', () => { + expect( + sanitizePageUrl( + 'https://example.com/callback#access_token=private-fragment-token', + ), + ).toBe('https://example.com/callback'); + expect(sanitizePageUrl('data:text/html,private-content')).toBe( + '[REDACTED_URL:data]', + ); + expect(sanitizePageUrl('http://[')).toBe('[REDACTED_URL:http]'); + expect(sanitizeDiagnosticMessage('request failed at http://[')).toBe( + 'request failed at [REDACTED_URL:http]', + ); + }); + it('redacts secret-shaped diagnostics before persistence', () => { const message = sanitizeDiagnosticMessage( 'authorization: Bearer private-token token=another-private-value', From 4862d332aaf4cd8c2f9fd23212801ff5006efccc Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 26/74] fix(evidence): finalize HAR capture atomically Co-authored-by: Codex --- src/browser/evidence.ts | 80 +++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index 3e6cfb8..9a7fcb9 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -53,6 +53,7 @@ export function finalizePrivateNetworkCapture( paths: PrivateNetworkEvidencePaths, ): SanitizedNetworkSummary { preparePrivateDirectory(paths.privateDirectory); + const har = finalizePrivateHarCapture(sessionName, paths.harPath); let requests: string; try { requests = ab('network requests --json', { session: sessionName }); @@ -69,21 +70,78 @@ export function finalizePrivateNetworkCapture( paths.requestsPath, requests.trim() ? `${requests.trim()}\n` : '{"success":true,"data":[]}\n', ); - ab( - `network har stop ${quoteShellArgument(paths.harPath)} --json`, - { session: sessionName }, - ); - if (!fs.existsSync(paths.harPath)) { - throw new Error('agent-browser did not write the requested HAR evidence.'); - } - fs.chmodSync(paths.harPath, 0o600); - const summary = buildSanitizedNetworkSummary( - JSON.parse(fs.readFileSync(paths.harPath, 'utf-8')), - ); + const summary = buildSanitizedNetworkSummary(har); writeJsonFile(paths.summaryPath, summary); return summary; } +function finalizePrivateHarCapture( + sessionName: string, + harPath: string, +): unknown { + const finalizedHar = readValidHarFile(harPath); + if (finalizedHar !== null) { + fs.chmodSync(harPath, 0o600); + return finalizedHar; + } + + const pendingPath = `${harPath}.pending`; + const pendingHar = readValidHarFile(pendingPath); + if (pendingHar !== null) { + adoptPendingHar(pendingPath, harPath); + return pendingHar; + } + removeInvalidHarFile(harPath); + removeInvalidHarFile(pendingPath); + + let stopError: unknown; + try { + ab(`network har stop ${quoteShellArgument(pendingPath)} --json`, { + session: sessionName, + }); + } catch (error) { + stopError = error; + } + + const capturedHar = readValidHarFile(pendingPath); + if (capturedHar === null) { + if (stopError) { + throw stopError; + } + throw new Error('agent-browser did not write valid HAR evidence.'); + } + adoptPendingHar(pendingPath, harPath); + return capturedHar; +} + +function readValidHarFile(filePath: string): unknown | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const value = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as unknown; + buildSanitizedNetworkSummary(value); + return value; + } catch { + return null; + } +} + +function adoptPendingHar(pendingPath: string, harPath: string): void { + fs.chmodSync(pendingPath, 0o600); + if (fs.existsSync(harPath)) { + fs.unlinkSync(harPath); + } + fs.renameSync(pendingPath, harPath); + fs.chmodSync(harPath, 0o600); +} + +function removeInvalidHarFile(filePath: string): void { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + export function loadSanitizedNetworkSummary( summaryPath: string | undefined, ): SanitizedNetworkSummary | null { From 26eee69f4b8126e100a1439cfc9aff0629b4ddbf Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 27/74] test(evidence): cover interrupted HAR recovery Co-authored-by: Codex --- src/browser/evidence.test.ts | 99 ++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index fa70080..db956a0 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -113,13 +113,16 @@ describe('private browser evidence', () => { summaryPath: path.join(sessionDir, 'network-summary.json'), }; mocks.ab.mockImplementation((command: string) => { - if (command === 'network requests --json') { - throw new Error('request inventory failed'); - } if (command.startsWith('network har stop ')) { - fs.writeFileSync(paths.harPath, JSON.stringify({ log: { entries: [] } })); + fs.writeFileSync( + `${paths.harPath}.pending`, + JSON.stringify({ log: { entries: [] } }), + ); return JSON.stringify({ success: true }); } + if (command === 'network requests --json') { + throw new Error('request inventory failed'); + } throw new Error(`unexpected command: ${command}`); }); @@ -128,7 +131,10 @@ describe('private browser evidence', () => { expect(summary.requestCount).toBe(0); expect(mocks.ab).toHaveBeenCalledTimes(2); - expect(mocks.ab.mock.calls[1][0]).toContain('network har stop'); + expect(mocks.ab.mock.calls[0][0]).toContain('network.har.pending'); + expect(mocks.ab.mock.calls[1][0]).toBe('network requests --json'); + expect(fs.existsSync(`${paths.harPath}.pending`)).toBe(false); + expect(fs.existsSync(paths.harPath)).toBe(true); expect(fs.readFileSync(paths.requestsPath, 'utf-8')).toContain( 'request inventory failed', ); @@ -136,4 +142,87 @@ describe('private browser evidence', () => { fs.rmSync(sessionDir, { recursive: true, force: true }); } }); + + it('adopts a complete pending HAR after interrupted finalization', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-network-recovery-'), + ); + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + fs.mkdirSync(privateDirectory, { recursive: true }); + const paths = { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; + fs.writeFileSync( + `${paths.harPath}.pending`, + JSON.stringify({ + log: { + entries: [ + { + time: 4, + request: { method: 'GET', url: 'https://example.com/recovered' }, + response: { status: 200 }, + }, + ], + }, + }), + ); + mocks.ab.mockImplementation((command: string) => { + if (command === 'network requests --json') { + return JSON.stringify({ success: true, data: [] }); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + const summary = finalizePrivateNetworkCapture('ps-test', paths); + + expect(summary.requests).toEqual([ + { + endpoint: 'https://example.com/recovered', + method: 'GET', + status: 200, + durationMs: 4, + error: null, + }, + ]); + expect(mocks.ab).toHaveBeenCalledTimes(1); + expect(fs.existsSync(`${paths.harPath}.pending`)).toBe(false); + expect(fs.statSync(paths.harPath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); + + it('refuses an invalid HAR without adopting a partial final file', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-network-invalid-'), + ); + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + fs.mkdirSync(privateDirectory, { recursive: true }); + const paths = { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; + mocks.ab.mockImplementation((command: string) => { + if (command.startsWith('network har stop ')) { + fs.writeFileSync(`${paths.harPath}.pending`, '{'); + return JSON.stringify({ success: true }); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + expect(() => finalizePrivateNetworkCapture('ps-test', paths)).toThrow( + 'agent-browser did not write valid HAR evidence', + ); + expect(fs.existsSync(paths.harPath)).toBe(false); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); }); From 4a0aabb0233875199f33f5571b615b6c3a802584 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 28/74] fix(browser): enforce isolated 0.34 runtime Co-authored-by: Codex --- src/browser/isolation.ts | 153 +++++++++++++++++++++++++++++++++++++++ src/commands/start.ts | 36 ++++++--- src/session/registry.ts | 1 + src/session/state.ts | 1 + src/utils/exec.ts | 3 +- 5 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/browser/isolation.ts diff --git a/src/browser/isolation.ts b/src/browser/isolation.ts new file mode 100644 index 0000000..ba345aa --- /dev/null +++ b/src/browser/isolation.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { execFileSync } from 'child_process'; + +const MINIMUM_AGENT_BROWSER_VERSION = [0, 34, 0] as const; +const INCOMPATIBLE_ENVIRONMENT_KEYS = [ + 'AGENT_BROWSER_ARGS', + 'AGENT_BROWSER_AUTO_CONNECT', + 'AGENT_BROWSER_CDP', + 'AGENT_BROWSER_CONFIG', + 'AGENT_BROWSER_ENGINE', + 'AGENT_BROWSER_EXTENSIONS', + 'AGENT_BROWSER_INIT_SCRIPTS', + 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_PROVIDER', + 'AGENT_BROWSER_PROXY', + 'AGENT_BROWSER_PROXY_BYPASS', + 'AGENT_BROWSER_SESSION', + 'AGENT_BROWSER_SESSION_NAME', + 'AGENT_BROWSER_STATE', +] as const; +const INCOMPATIBLE_CONFIG_KEYS = new Set([ + 'allowfileaccess', + 'args', + 'autoconnect', + 'cdp', + 'enable', + 'engine', + 'extensions', + 'headers', + 'headed', + 'initscript', + 'initscripts', + 'profile', + 'provider', + 'proxy', + 'proxybypass', + 'session', + 'sessionname', + 'state', +]); + +export function loadIsolatedAgentBrowserConfig( + sourcePath: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Record { + assertIsolatedAgentBrowserEnvironment(env); + if (!sourcePath) { + return {}; + } + const value = JSON.parse(fs.readFileSync(sourcePath, 'utf-8')) as unknown; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`agent-browser config must contain a JSON object: ${sourcePath}`); + } + const config = value as Record; + const incompatibleKey = Object.keys(config).find((key) => + INCOMPATIBLE_CONFIG_KEYS.has(normalizeConfigKey(key)), + ); + if (incompatibleKey) { + throw new Error( + `agent-browser config key "${incompatibleKey}" is incompatible with an isolated local ProofShot browser.`, + ); + } + return config; +} + +export function writeIsolatedAgentBrowserConfig( + privateDirectory: string, + config: Record, +): string { + fs.mkdirSync(privateDirectory, { recursive: true, mode: 0o700 }); + fs.chmodSync(privateDirectory, 0o700); + const configPath = path.join(privateDirectory, 'config.json'); + const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(config, null, 2) + '\n', { + mode: 0o600, + }); + fs.renameSync(temporaryPath, configPath); + fs.chmodSync(configPath, 0o600); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } + return configPath; +} + +export function assertSupportedAgentBrowserVersion( + env: NodeJS.ProcessEnv = process.env, +): string { + const output = execFileSync('agent-browser', ['--version'], { + encoding: 'utf-8', + env: getIsolatedAgentBrowserEnvironment(env), + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }).trim(); + const version = parseAgentBrowserVersion(output); + if (!version || compareVersions(version, MINIMUM_AGENT_BROWSER_VERSION) < 0) { + throw new Error( + `ProofShot requires agent-browser >=0.34.0; received ${output || 'no version output'}.`, + ); + } + return version.join('.'); +} + +export function getIsolatedAgentBrowserEnvironment( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const isolatedEnvironment = { ...env }; + for (const key of INCOMPATIBLE_ENVIRONMENT_KEYS) { + delete isolatedEnvironment[key]; + } + return isolatedEnvironment; +} + +export function parseAgentBrowserVersion( + output: string, +): readonly [number, number, number] | null { + const match = output.match(/(?:agent-browser\s+)?(\d+)\.(\d+)\.(\d+)/i); + return match + ? [Number(match[1]), Number(match[2]), Number(match[3])] + : null; +} + +function assertIsolatedAgentBrowserEnvironment(env: NodeJS.ProcessEnv): void { + const incompatibleKey = INCOMPATIBLE_ENVIRONMENT_KEYS.find( + (key) => env[key] !== undefined && env[key] !== '', + ); + if (incompatibleKey) { + throw new Error( + `${incompatibleKey} is incompatible with an isolated local ProofShot browser.`, + ); + } +} + +function normalizeConfigKey(key: string): string { + return key.toLowerCase().replace(/[-_]/g, ''); +} + +function compareVersions( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const difference = (left[index] || 0) - (right[index] || 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} diff --git a/src/commands/start.ts b/src/commands/start.ts index 786ab39..e3b3b36 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -9,6 +9,11 @@ import { startPrivateNetworkCapture, } from '../browser/evidence.js'; import { sanitizePageUrl } from '../browser/provenance.js'; +import { + assertSupportedAgentBrowserVersion, + loadIsolatedAgentBrowserConfig, + writeIsolatedAgentBrowserConfig, +} from '../browser/isolation.js'; import { startRecording } from '../browser/capture.js'; import { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js'; import { @@ -152,8 +157,14 @@ export async function startCommand(options: StartOptions): Promise { let socketRoot: string; let socketDir: string; let browserExecutable: string | null; + let agentBrowserVersion: string; + let isolatedAgentBrowserConfig: Record; try { + isolatedAgentBrowserConfig = loadIsolatedAgentBrowserConfig( + config.browser.configPath, + ); + agentBrowserVersion = assertSupportedAgentBrowserVersion(); socketRoot = prepareAgentBrowserSocketDir( sessionName, process.env, @@ -167,11 +178,7 @@ export async function startCommand(options: StartOptions): Promise { browserExecutable = discoverBrowserExecutable({ configuredPath: options.browserExecutable || config.browser.executablePath, }); - if ( - !browserExecutable && - !process.env.AGENT_BROWSER_PROVIDER && - !process.env.AGENT_BROWSER_CDP - ) { + if (!browserExecutable) { throw browserSetupError(); } } catch (error: any) { @@ -181,12 +188,6 @@ export async function startCommand(options: StartOptions): Promise { } if (browserExecutable) config.browser.executablePath = browserExecutable; - setAgentBrowserDefaults({ - allowedDomains: agentBrowserAllowedDomains, - configPath: config.browser.configPath, - namespace: agentBrowserNamespace, - socketDir: socketRoot, - }); ensureOutputDir(outputDir); ensureOutputDir(sessionDir); @@ -194,6 +195,16 @@ export async function startCommand(options: StartOptions): Promise { const videoPath = path.join(sessionDir, 'session.webm'); const serverErrorLog = path.join(sessionDir, 'server.log'); const networkEvidence = preparePrivateNetworkEvidence(sessionDir); + const agentBrowserConfigPath = writeIsolatedAgentBrowserConfig( + networkEvidence.privateDirectory, + isolatedAgentBrowserConfig, + ); + setAgentBrowserDefaults({ + allowedDomains: agentBrowserAllowedDomains, + configPath: agentBrowserConfigPath, + namespace: agentBrowserNamespace, + socketDir: socketRoot, + }); const provenance = captureGitProvenance(process.cwd(), [outputDir]); @@ -234,7 +245,8 @@ export async function startCommand(options: StartOptions): Promise { agentBrowserSocketRoot: socketRoot, agentBrowserNamespace, agentBrowserAllowedDomains, - agentBrowserConfigPath: config.browser.configPath, + agentBrowserConfigPath, + agentBrowserVersion, privateEvidenceDir: networkEvidence.privateDirectory, networkHarPath: networkEvidence.harPath, networkRequestsPath: networkEvidence.requestsPath, diff --git a/src/session/registry.ts b/src/session/registry.ts index 3c3376a..ac3d73c 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -339,6 +339,7 @@ function isSessionState(value: unknown): value is SessionState { isOptionalString(session.agentBrowserNamespace) && isOptionalStringArray(session.agentBrowserAllowedDomains) && isOptionalString(session.agentBrowserConfigPath) && + isOptionalString(session.agentBrowserVersion) && isOptionalString(session.privateEvidenceDir) && isOptionalString(session.networkHarPath) && isOptionalString(session.networkRequestsPath) && diff --git a/src/session/state.ts b/src/session/state.ts index 6812414..ca88e6c 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -46,6 +46,7 @@ export interface SessionState { agentBrowserNamespace?: string; agentBrowserAllowedDomains?: string[]; agentBrowserConfigPath?: string; + agentBrowserVersion?: string; privateEvidenceDir?: string; networkHarPath?: string; networkRequestsPath?: string; diff --git a/src/utils/exec.ts b/src/utils/exec.ts index f2f2cf0..96555b0 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -1,4 +1,5 @@ import { execSync, type ChildProcess } from 'child_process'; +import { getIsolatedAgentBrowserEnvironment } from '../browser/isolation.js'; import { spawnShellCommand } from './process.js'; export class ProofShotError extends Error { @@ -46,7 +47,7 @@ export function getAgentBrowserEnvironment( const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir; const namespace = options.namespace ?? defaultAgentBrowserOptions.namespace; return { - ...process.env, + ...getIsolatedAgentBrowserEnvironment(process.env), AGENT_BROWSER_IDLE_TIMEOUT_MS: process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '1800000', ...(socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {}), From 31552f94a5ea7443a46b661a4603e5fbba451a9a Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:46 +0000 Subject: [PATCH 29/74] test(browser): cover runtime isolation Co-authored-by: Codex --- src/browser/isolation.test.ts | 109 +++++++++++++++++++++ src/commands/lifecycle.integration.test.ts | 22 +++++ src/commands/start.test.ts | 29 ++++++ 3 files changed, 160 insertions(+) create mode 100644 src/browser/isolation.test.ts diff --git a/src/browser/isolation.test.ts b/src/browser/isolation.test.ts new file mode 100644 index 0000000..f0b0153 --- /dev/null +++ b/src/browser/isolation.test.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ execFileSync: vi.fn() })); + +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync: mocks.execFileSync, +})); + +import { + assertSupportedAgentBrowserVersion, + getIsolatedAgentBrowserEnvironment, + loadIsolatedAgentBrowserConfig, + parseAgentBrowserVersion, + writeIsolatedAgentBrowserConfig, +} from './isolation.js'; + +const createdDirectories: string[] = []; + +beforeEach(() => { + mocks.execFileSync.mockReset(); +}); + +afterEach(() => { + for (const directory of createdDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('agent-browser isolation', () => { + it('rejects inherited provider, connection, and persistent-state modes', () => { + for (const key of [ + 'AGENT_BROWSER_PROVIDER', + 'AGENT_BROWSER_CDP', + 'AGENT_BROWSER_AUTO_CONNECT', + 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_STATE', + 'AGENT_BROWSER_SESSION_NAME', + ]) { + expect(() => + loadIsolatedAgentBrowserConfig(undefined, { [key]: 'configured' }), + ).toThrow(key); + } + }); + + it('rejects incompatible modes in an explicit config', () => { + const directory = createDirectory(); + const configPath = path.join(directory, 'agent-browser.json'); + fs.writeFileSync(configPath, JSON.stringify({ profile: './shared-profile' })); + + expect(() => + loadIsolatedAgentBrowserConfig(configPath, {}), + ).toThrow('config key "profile"'); + }); + + it('copies a safe config into private per-session state', () => { + const directory = createDirectory(); + const sourcePath = path.join(directory, 'source.json'); + const privateDirectory = path.join(directory, 'private', 'agent-browser'); + fs.writeFileSync( + sourcePath, + JSON.stringify({ ignoreHttpsErrors: true, userAgent: 'proofshot-test' }), + ); + + const config = loadIsolatedAgentBrowserConfig(sourcePath, {}); + const configPath = writeIsolatedAgentBrowserConfig(privateDirectory, config); + + expect(JSON.parse(fs.readFileSync(configPath, 'utf-8'))).toEqual(config); + expect(fs.statSync(privateDirectory).mode & 0o777).toBe(0o700); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it('strips incompatible modes from every agent-browser subprocess', () => { + const environment = getIsolatedAgentBrowserEnvironment({ + AGENT_BROWSER_PROVIDER: 'browserbase', + AGENT_BROWSER_PROFILE: './shared', + PATH: '/usr/bin', + }); + + expect(environment).toEqual({ PATH: '/usr/bin' }); + }); + + it('parses the supported semantic version envelope', () => { + expect(parseAgentBrowserVersion('agent-browser 0.34.0')).toEqual([0, 34, 0]); + expect(parseAgentBrowserVersion('0.35.2')).toEqual([0, 35, 2]); + expect(parseAgentBrowserVersion('unknown')).toBeNull(); + }); + + it('requires agent-browser 0.34.0 or newer before launch', () => { + mocks.execFileSync.mockReturnValueOnce('agent-browser 0.33.1\n'); + expect(() => assertSupportedAgentBrowserVersion({ PATH: '/usr/bin' })).toThrow( + 'requires agent-browser >=0.34.0', + ); + + mocks.execFileSync.mockReturnValueOnce('agent-browser 0.34.0\n'); + expect(assertSupportedAgentBrowserVersion({ PATH: '/usr/bin' })).toBe( + '0.34.0', + ); + }); +}); + +function createDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'proofshot-isolation-')); + createdDirectories.push(directory); + return directory; +} diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 5f29915..f72cf3b 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -106,6 +106,10 @@ const fs = require('fs'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); let args = process.argv.slice(2); +if (args[0] === '--version') { + process.stdout.write('agent-browser 0.34.0\\n'); + process.exit(0); +} let session = 'default'; const sessionIndex = args.indexOf('--session'); if (sessionIndex >= 0) { @@ -243,6 +247,10 @@ if (command === 'hang') { }); process.exit(0); } +if (command === 'fail') { + process.stderr.write('simulated action failure\\n'); + process.exit(7); +} if (command === 'close') { try { const pid = Number(fs.readFileSync(pidPath, 'utf8')); @@ -707,6 +715,20 @@ describe('isolated CLI lifecycle', () => { ); expect(fs.statSync(privateResultPath).mode & 0o777).toBe(0o600); + const failedExec = runCli(audit, env, [ + 'exec', + '--session', + sessions[0].sessionName, + 'fail', + ]); + expect(failedExec.status).toBe(7); + const failedEntry = JSON.parse(fs.readFileSync(actionLogPath, 'utf-8')).at(-1); + expect(failedEntry).toMatchObject({ + action: 'fail', + outcome: 'failed', + pageUrl: sessions[0].targetUrl, + }); + const ambiguousStop = runCli(audit, env, ['stop']); expect(ambiguousStop.status).toBe(1); expect(ambiguousStop.stderr).toContain('Multiple active ProofShot sessions'); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 484d766..f709aa7 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -28,6 +28,9 @@ const mocks = vi.hoisted(() => ({ captureAgentBrowserProcessIdentity: vi.fn(), cleanupFailedStart: vi.fn(), startOwnedEnvironment: vi.fn(), + assertSupportedAgentBrowserVersion: vi.fn(), + loadIsolatedAgentBrowserConfig: vi.fn(), + writeIsolatedAgentBrowserConfig: vi.fn(), claimSessionOperation: vi.fn(), registerSession: vi.fn(), releaseSessionOperation: vi.fn(), @@ -69,6 +72,12 @@ vi.mock('../browser/runtime.js', () => ({ captureAgentBrowserProcessIdentity: mocks.captureAgentBrowserProcessIdentity, })); +vi.mock('../browser/isolation.js', () => ({ + assertSupportedAgentBrowserVersion: mocks.assertSupportedAgentBrowserVersion, + loadIsolatedAgentBrowserConfig: mocks.loadIsolatedAgentBrowserConfig, + writeIsolatedAgentBrowserConfig: mocks.writeIsolatedAgentBrowserConfig, +})); + vi.mock('../artifacts/bundle.js', () => ({ ensureOutputDir: mocks.ensureOutputDir, generateTimestamp: mocks.generateTimestamp, @@ -147,6 +156,11 @@ describe('startCommand', () => { startTime: '12345', }); mocks.cleanupFailedStart.mockResolvedValue(undefined); + mocks.assertSupportedAgentBrowserVersion.mockReturnValue('0.34.0'); + mocks.loadIsolatedAgentBrowserConfig.mockReturnValue({}); + mocks.writeIsolatedAgentBrowserConfig.mockReturnValue( + '/audit/private/agent-browser/config.json', + ); mocks.claimSessionOperation.mockImplementation((session, kind) => { const lease = { id: `${kind}-lease`, @@ -202,6 +216,19 @@ describe('startCommand', () => { expect(mocks.unregisterSession).toHaveBeenCalledWith('ps-2026-04-deadbeef1234'); }); + it('refuses incompatible inherited browser state before creating evidence', async () => { + mocks.loadIsolatedAgentBrowserConfig.mockImplementationOnce(() => { + throw new Error('AGENT_BROWSER_PROFILE is incompatible'); + }); + + await expect(startCommand({})).rejects.toThrow('process.exit:1'); + + expect(mocks.assertSupportedAgentBrowserVersion).not.toHaveBeenCalled(); + expect(mocks.ensureOutputDir).not.toHaveBeenCalled(); + expect(mocks.openBrowser).not.toHaveBeenCalled(); + expect(mocks.registerSession).not.toHaveBeenCalled(); + }); + it('clears discoverable registry state when recording never starts', async () => { mocks.startRecording.mockImplementation(() => { throw new Error('Recording already active'); @@ -253,6 +280,8 @@ describe('startCommand', () => { agentBrowserSocketRoot: '/run/user/1000/proofshot', agentBrowserNamespace: 'psn-deadbeef1234', agentBrowserAllowedDomains: ['127.0.0.1'], + agentBrowserConfigPath: '/audit/private/agent-browser/config.json', + agentBrowserVersion: '0.34.0', }); expect(finalState.controlDir).toBe('/project/proofshot-artifacts'); }); From 928cf8fc539f50619f1dcbe4bfcfc6a7dc4a0d19 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:38:19 +0000 Subject: [PATCH 30/74] chore(browser): pin agent-browser 0.34.0 Co-authored-by: Codex --- package-lock.json | 12 ++++++++---- package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b2ad5e..6b30961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "agent-browser": "^0.27.0", + "agent-browser": "0.34.0", "chalk": "^5.4.0", "commander": "^12.0.0", "detect-port": "^2.1.0", @@ -1038,13 +1038,17 @@ } }, "node_modules/agent-browser": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.27.0.tgz", - "integrity": "sha512-mmHzVsYFVA6nshNNGJzg83aVMgKpf4h98ytY3pvtJB1Cot0ZyA2bfnkbSngGD56Azkj+GlhVH6qx9DfKOVE0yg==", + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.34.0.tgz", + "integrity": "sha512-eR6Ey4I/DMs9zZ60b3ziV6pgLIgpxXWzggr3dfFbtskLmeXPJAgXCIIwVL4PihVYJqEUpvWgUKlZ2CIjY1u44g==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { "agent-browser": "bin/agent-browser.js" + }, + "engines": { + "node": ">=24.0.0", + "pnpm": ">=11.0.0" } }, "node_modules/any-promise": { diff --git a/package.json b/package.json index 382c1d0..4ddaa2c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "node -e \"try { require('child_process').execSync('agent-browser install', { stdio: 'inherit' }) } catch(e) { console.log('Note: Run agent-browser install to set up Chromium') }\"" }, "dependencies": { - "agent-browser": "^0.27.0", + "agent-browser": "0.34.0", "chalk": "^5.4.0", "commander": "^12.0.0", "detect-port": "^2.1.0", From b08594d312f812684572ed31046f0a9b9d11bb96 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:41:58 +0000 Subject: [PATCH 31/74] docs(workflow): define exploration-to-proof flow Co-authored-by: Codex --- CLAUDE.md | 16 +++-- PROOFSHOT.md | 34 ++++------ README.md | 17 +++-- content/docs/concepts/how-it-works.mdx | 37 +++++------ content/docs/faq.mdx | 13 +++- content/docs/guides/verify-feature.mdx | 2 +- content/docs/quick-start.mdx | 4 +- content/docs/reference/cli.mdx | 29 +++++---- docs/architecture.md | 63 ++++++++++--------- skills/claude/SKILL.md | 79 +++++++---------------- skills/codex/AGENTS.md | 23 +------ skills/codex/SKILL.md | 87 +++++++++++--------------- skills/cursor/proofshot.mdc | 27 +++----- skills/generic/PROOFSHOT.md | 29 +++------ skills/opencode/SKILL.md | 81 +++--------------------- 15 files changed, 205 insertions(+), 336 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6409a2f..c44c64d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ src/ ├── commands/ # One file per CLI command (install, start, stop, exec, diff, pr, clean) ├── browser/ # agent-browser CLI wrappers (session, capture, interact, navigate) ├── server/ # Dev server detection, startup, port waiting -├── session/state.ts # .session.json lifecycle (save/load/clear) +├── session/ # Global registry, per-session leases, selection, manifests ├── session/metadata.ts # Persistent per-session metadata (branch, commit) for PR matching ├── artifacts/ # Output generation (viewer.html, SUMMARY.md, PR format) └── utils/ # Config, exec helpers, port utils, error patterns, GitHub API @@ -30,16 +30,16 @@ src/ - **ESM only** — all imports MUST use `.js` extensions: `import { foo } from '../utils/config.js'` - **Build before test** — CLI runs from `dist/`, always `npm run build` after code changes -- **agent-browser** — external peer dependency (Rust CLI + Node daemon). All browser commands go through `ab()` in `utils/exec.ts` which calls `agent-browser ` via `execSync` -- **Session state** — `start` writes `.session.json`, `exec` and `stop` read it. `stop` clears it. Don't assume session exists without checking +- **agent-browser** — exact 0.34.0 runtime dependency. ProofShot preflights version/capabilities and uses a dedicated Node 24 launcher without changing the project's runtime +- **Session state** — registry files live under the user state directory; every session has an immutable ID, private browser namespace, evidence directory, and operation lease. `exec`/`stop` auto-select only one live match or require `--session` - **Session metadata** — `start` writes `metadata.json` inside each session folder with git branch/commit. This persists after `stop` and is used by `pr` to match sessions to branches - **Per-session subfolders** — artifacts go in `proofshot-artifacts/YYYY-MM-DD_HH-mm-ss_slug/` ## Command lifecycle -1. `proofshot start` — spawns dev server, opens browser, starts recording, saves session state + writes `metadata.json` with git branch/commit -2. `proofshot exec ` — logs action to `session-log.json`, forwards to `agent-browser` -3. `proofshot stop` — collects errors, stops recording, trims video, generates SUMMARY.md + viewer.html, clears session +1. `proofshot start` — validates a local isolated browser, claims a start lease, opens a unique agent-browser namespace, starts recording/network capture, and writes metadata +2. `proofshot exec [--session ID] ` — claims an exec lease, logs sanitized provenance/private structured output, and forwards to the exact browser +3. `proofshot stop [--session ID]` — claims a stop lease, atomically finalizes private evidence, performs exact cleanup, and generates canonical artifacts 4. `proofshot pr [number]` — finds sessions for current branch, uploads artifacts to GitHub, posts PR comment ## Adding a new command @@ -71,6 +71,8 @@ Edit `src/utils/error-patterns.ts` — add a new entry to the `PATTERNS` array: | `session-log.json` | `exec` (appended each call) | Action timeline with relative timestamps | | `server.log` | `start` (piped stdout+stderr) | All dev server output | | `console-output.log` | `stop` | Browser console output | +| `network-summary.json` | `stop` | Sanitized endpoint/method/status/timing/error metadata | +| `private/agent-browser/` | `exec` / `stop` | User-only raw JSON/HAR; excluded from manifests and publication | | `step-*.png` | `exec screenshot` | Screenshots at key moments | | `SUMMARY.md` | `stop` | Markdown report with errors and screenshots | | `viewer.html` | `stop` | Standalone HTML viewer with video + timeline | @@ -90,6 +92,8 @@ Edit `src/utils/error-patterns.ts` — add a new entry to the `PATTERNS` array: ## Gotchas - `proofshot exec` has special shell quoting logic (`buildShellCommand` in exec.ts) — `eval` commands get single-quoted, args with special chars get auto-quoted +- Direct DOM mutation is diagnostic only; synthetic actions force `INCOMPLETE`. Preserve category/redaction behavior when adding commands +- Every browser subprocess strips provider/CDP/profile/state/session/proxy inheritance and uses the persisted private config, namespace, allowlist, and socket root - Video trimming adjusts session-log.json timestamps to match the trimmed video (see `trimOffsetSec` in stop.ts) - Server log capture only works when proofshot starts the server itself — if the port is already occupied, we skip spawning and get no server logs - The `consoleErrors`/`consoleOutput` from agent-browser are point-in-time snapshots collected at stop time diff --git a/PROOFSHOT.md b/PROOFSHOT.md index a4d6746..daa2e26 100644 --- a/PROOFSHOT.md +++ b/PROOFSHOT.md @@ -1,26 +1,18 @@ -# ProofShot Visual Verification +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets any AI coding agent verify its own work in a real browser — no vendor lock-in required. +Explore UI behavior with the approved local agent-browser launcher, maintain User Stories/Use Cases/User Flows and iteration feedback under the task's persistent `~/data` directory, then record one fresh ProofShot session per finalized Use Case. -After building or modifying UI features, verify with this workflow: +```bash +proofshot start --run "your-dev-command" --port PORT --description "UC-ID: behavior" +proofshot exec snapshot -i +proofshot exec click @e3 +proofshot exec assert-visible "#expected-result" +proofshot exec screenshot step-result.png +proofshot stop +``` -1. Start: `proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"` - If the server is already running, omit --run. -2. Test: Use `proofshot exec` to navigate, click, fill forms, take screenshots -3. Stop: `proofshot stop` — bundles video, screenshots, and error report +With multiple sessions, pass the printed ID to `proofshot exec --session ID ...` and `proofshot stop --session ID`. Prefer pointer/keyboard actions, disclose hybrid input, and treat synthetic DOM mutation as diagnostic only. `FAIL`, `INCOMPLETE`, and `BLOCKED` are unsuccessful. -ProofShot keeps all `proofshot exec` commands inside the same isolated `agent-browser` session that was created by `proofshot start`, so recording, screenshots, and browser actions stay aligned. +Keep raw JSON, HAR, response bodies, credentials, and customer data private under the task's `~/data` evidence root. Report the local path without copying raw artifacts into prompts, scratch, PRs, or uploads. Copy only exercised final flows into PR User Testing instructions, then dry-run ordered explicit publication with repeated `--session` flags. -Use `--url` on `start` when verification must begin on a specific target. In an isolated HOME, ProofShot discovers executable-only Chrome/Chromium installs from system/account locations; use `--browser-executable /absolute/path/to/chrome` to select one explicitly. - -Key proofshot exec commands: -- `proofshot exec snapshot -i` — see interactive elements -- `proofshot exec click @e3` — click an element -- `proofshot exec fill @e2 "text"` — fill a form field -- `proofshot exec screenshot step.png` — capture a moment - -Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. -Custom `--output` paths do not move active control state, so a separate `proofshot stop` still finds the session. `stop` is idempotent; after `stop --no-close`, run a later plain `stop` to close that exact retained browser without rebundling. -You can customize browser launch behavior in `proofshot.config.json`, including HTTPS error ignoring, a custom browser executable path, and a project-specific `agent-browser` config path. - -Use `proofshot doctor` when the local setup looks wrong. It prints the current config path, browser mode, viewport, installed binaries, and any active ProofShot session. +Browser tooling requires agent-browser 0.34+ through its Node 24 launcher; project commands continue to honor the repository runtime. Recover with `proofshot session list` and `proofshot session clean --session ID`, never broad process cleanup. diff --git a/README.md b/README.md index 29d5b7a..3b7f36e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ npm install -g proofshot proofshot install ``` -The first command installs the CLI and [agent-browser](https://github.com/vercel-labs/agent-browser) (with headless Chromium). The second detects your AI coding tools and installs the ProofShot skill at user level — works across all your projects automatically. +The first command installs the CLI and the exact agent-browser 0.34.0 package. agent-browser requires Node 24; managed environments should expose it through a dedicated Node 24 launcher so project servers can continue using their repository runtime (for example, Node 22 from `.nvmrc`). The second command installs the ProofShot skill at user level. ## How It Works @@ -107,6 +107,9 @@ Each session produces a timestamped folder in `./proofshot-artifacts/`: | `evidence.json` | Canonical browser/environment events, incidents, source integrity, and media timing | | `verdict.json` | Structured `PASS`, `FAIL`, `INCOMPLETE`, or `BLOCKED` verdict | | `artifact-manifest.json` | Finalized repository/commit provenance and ordered artifact hashes | +| `network-summary.json` | Sanitized request method/status/timing/error metadata | + +Raw agent-browser JSON and HAR evidence stays under the session's private directory with user-only permissions. It is excluded from manifests and publication; only curated media and sanitized summaries are reviewable artifacts.

ProofShot artifacts folder @@ -161,11 +164,11 @@ You can also configure browser launch behavior in `proofshot.config.json`: } ``` -Set `browser.configPath` when you need ProofShot to run `agent-browser` against a project-specific config instead of inheriting `~/.agent-browser/config.json`. Relative paths are resolved from the directory that contains `proofshot.config.json`. +Set `browser.configPath` for a project-specific agent-browser config. ProofShot validates it, rejects provider/CDP/profile/state/proxy modes, and copies it into private per-session state. Without this option, ProofShot uses an empty per-session config instead of inheriting user or project agent-browser defaults. -ProofShot discovers system and account-level Chrome/Chromium installs even when the command runs with an isolated `HOME`. If no runnable browser is found, `start` prints the exact `agent-browser install` action. An explicit `--browser-executable` takes precedence for one run. +ProofShot requires agent-browser 0.34.0 or newer, a local Chrome/Chromium executable, an explicit target-domain allowlist, and a fresh isolated session. Cloud providers, shared CDP browsers, persistent profiles/state, and auto-connect are refused. If no runnable browser is found, `start` prints the exact `agent-browser install` action. An explicit `--browser-executable` takes precedence for one run. -`--output` changes only where evidence is written. Active control state stays in the configured/default output directory, so later `proofshot exec` and `proofshot stop` processes can find the same session. +`--output` changes only where evidence is written. Registry-backed control state remains discoverable across processes. Concurrent sessions in one worktree are supported; commands resolve a single live session automatically and require `--session ` when several live sessions match. `--force` recovers proven stale state only and refuses live processes or lifecycle operations. ### `proofshot stop` @@ -173,6 +176,7 @@ Stop recording, collect errors, generate proof artifacts. ```bash proofshot stop # Stop session and close browser +proofshot stop --session ID # Stop one exact session proofshot stop --no-close # Stop but keep browser open ``` @@ -186,11 +190,13 @@ When a ProofShot session is active, `proofshot exec` reuses the same isolated `a ```bash proofshot exec click @e3 +proofshot exec --session ID click @e3 proofshot exec assert-visible "#checkout-complete" proofshot exec screenshot step-checkout.png ``` Failed `assert-visible` checks are recorded in `session-log.json` and contribute to the structured verdict. +Action receipts include sanitized intent, interaction category, timing, outcome, and page context. Synthetic DOM actions make final proof incomplete; they remain useful only for diagnosis. ### `proofshot diff` @@ -227,6 +233,7 @@ Inspect and clean durable recovery records after an interrupted or incomplete cl ```bash proofshot session list proofshot session clean --session +proofshot session clean --all ``` Cleanup validates persisted process identities and never widens to a name-, port-, or default-socket kill. @@ -239,7 +246,7 @@ Remove the `./proofshot-artifacts/` directory. proofshot clean ``` -`clean` refuses while active or retained session control state exists. Run `proofshot stop` first so ProofShot does not discard exact process ownership metadata. +`clean` refuses while any matching registry state exists, including stale, starting, stopping, or recovery records. Run `proofshot stop` or `proofshot session clean` first so ProofShot does not discard evidence or exact process ownership metadata. ### `proofshot doctor` diff --git a/content/docs/concepts/how-it-works.mdx b/content/docs/concepts/how-it-works.mdx index ab24f84..9e81406 100644 --- a/content/docs/concepts/how-it-works.mdx +++ b/content/docs/concepts/how-it-works.mdx @@ -47,9 +47,10 @@ ProofShot uses a three-phase model. 1. Check if the port is available (fail fast on conflicts) 2. Spawn an isolated dev-server process session if `--run` is provided, pipe timestamped output to `server.log`, and persist its immutable PID/process-group identity 3. Wait for the port to respond (polls every 500ms, 30s timeout) -4. Open the requested URL in a short, collision-safe agent-browser session and persist its daemon identity -5. Start video recording -6. Write `.session.json` to the configured/default control directory and `metadata.json` beside the evidence (git branch/commit, persists after stop) +4. Verify agent-browser 0.34+, reject inherited provider/CDP/profile/state modes, and copy a safe config into private per-session state +5. Open the requested URL in a unique namespace with a target-domain allowlist and persist the daemon identity +6. Start private HAR capture and video recording +7. Register the immutable session ID and operation lease under the user state directory; write `metadata.json` beside the evidence Recording is mandatory. If it fails after 3 retries, the session aborts. @@ -57,31 +58,31 @@ Recording is mandatory. If it fails after 3 retries, the session aborts. Each `proofshot exec` call: -1. Loads `.session.json` and validates the session is active -2. For `click`/`fill`/`type` targeting `@eN` refs: captures the element's bounding box and label *before* execution -3. Appends an entry to `session-log.json` with a relative timestamp -4. Forwards the command to agent-browser -5. Returns the output +1. Selects the single addressable recording or requires `--session ` when several live sessions match +2. Claims an immutable exec lease +3. Captures pre-action page context and element data where available +4. Appends sanitized intent, interaction category, timing, and outcome to `session-log.json`; raw structured output stays private +5. Forwards the command to the exact agent-browser namespace and releases the lease ### Phase 3: Stop `proofshot stop` collects and bundles everything: -1. Collects browser console errors and output (point-in-time snapshot) -2. Stops video recording -3. Closes the exact owned browser session -4. Stops only the dev-server process session created by this run -5. Trims video dead time using ffmpeg (5s buffer before first action, 3s after last), converts the finalized recording to H.264 `session.mp4`, and adjusts all `session-log.json` timestamps by the trim offset. -6. Scans `server.log` with multi-language regex patterns for errors -7. Generates `SUMMARY.md` and `viewer.html` -8. Clears `.session.json` (or retains exact browser ownership after `--no-close`) +1. Claims a stop lease and collects console evidence +2. Atomically finalizes private HAR evidence and writes a metadata-only network summary +3. Stops video recording and closes only the exact owned browser/environment/server processes +4. Trims video dead time using ffmpeg, converts to H.264 `session.mp4`, and adjusts action timestamps +5. Generates canonical evidence, verdict, summary, viewer, and provenance manifest +6. Unregisters completed ownership, or retains the exact browser record after `--no-close` ## Design principles **Graceful degradation.** Missing ffmpeg? Video trimming and MP4 conversion are skipped, preserving the original `session.webm`. Element data capture fails? Overlays are skipped. Browser already closed? Silent catch. Non-critical failures never abort a session. -**Minimal dependencies.** Three production dependencies: `commander`, `chalk`, `detect-port`. agent-browser is an optional peer dependency. Small install, small supply chain. +**Pinned browser contract.** ProofShot pins agent-browser 0.34.0 and fails before launch on older versions. Managed launchers keep its Node 24 runtime separate from project runtimes. -**Session isolation.** Each project keeps control state in its configured/default output directory. A CLI-only custom evidence path cannot hide the session from a later process, while different projects still run independently. +**Session isolation.** Registry-backed immutable IDs, namespaces, sockets, operation leases, and evidence directories allow concurrent sessions in one worktree without cross-session cleanup. + +**Private evidence boundary.** Raw JSON, HAR, response bodies, credentials, and customer data remain in a user-only private directory excluded from manifests and publication. **ESM-only.** All imports use explicit `.js` extensions for correct resolution after TypeScript compilation. diff --git a/content/docs/faq.mdx b/content/docs/faq.mdx index 79d46e6..085d582 100644 --- a/content/docs/faq.mdx +++ b/content/docs/faq.mdx @@ -14,7 +14,7 @@ If your agent can run shell commands, it works with ProofShot. The CLI is agent- No. Skills install at user level (your home directory), so one `proofshot install` works across all projects on your machine. The optional `proofshot.config.json` is only needed if you want to customize defaults like port or viewport size. **What does the agent-browser dependency do?** -[agent-browser](https://github.com/vercel-labs/agent-browser) is a Rust CLI + Node.js daemon that manages a headless Chromium browser. ProofShot delegates all browser work to it — opening pages, clicking elements, taking screenshots, recording video. It's installed automatically with ProofShot. +[agent-browser](https://github.com/vercel-labs/agent-browser) is a Rust CLI + Node.js daemon that manages Chromium. ProofShot pins 0.34.0, requires its Node 24 runtime, and delegates browser interaction/recording to it. A dedicated launcher lets the project continue using its own runtime, such as Node 22 from `.nvmrc`. **Do I need ffmpeg?** No. ffmpeg is optional. Without it, ProofShot skips video trimming (you get the full uncut recording) and `.webm` to `.mp4` conversion (GitHub PR comments may not display `.webm` inline). Everything else works the same. @@ -28,7 +28,7 @@ The skill file installed by `proofshot install` teaches your agent the three-ste Stable handles to interactive elements on a page. When your agent runs `proofshot exec snapshot -i`, it gets a list like `@e1: button "Submit"`, `@e2: input "Email"`. These references persist across commands within a session, so the agent can target elements reliably without CSS selectors. **Can I run multiple sessions at the same time?** -Yes. Session state (`.session.json`) lives in each project's configured/default output directory, not globally. Different projects can run concurrently. A one-run `--output` override moves evidence without changing where that project finds active control state. +Yes. Registry-backed session IDs, browser namespaces, sockets, operation leases, and evidence directories are independent even in one worktree. Commands auto-select one live match; with several, pass the printed ID using `--session`. **What languages does error detection support?** JavaScript/Node.js, Python, Ruby/Rails, Go, Java/Kotlin, Rust, PHP, C#/.NET, Elixir/Phoenix, plus generic patterns for `FATAL`, `CRITICAL`, and segfaults. See [How to add error patterns](/docs/guides/add-error-pattern) to extend support. @@ -41,9 +41,18 @@ When ffmpeg is available, `proofshot stop` cuts dead time from the video — kee **"No active session" when running exec or stop** You need to run `proofshot start` before `exec`. `stop` is idempotent, so it succeeds without changing artifacts when the session is already stopped. +**"Multiple active sessions" when running exec or stop** +Run `proofshot session list`, then pass the exact ID with `proofshot exec --session ID ...` or `proofshot stop --session ID`. + +**agent-browser version or Node errors** +Check the repository runtime (`.nvmrc` and `node --version`) separately from the configured agent-browser launcher. ProofShot requires the launcher to report agent-browser 0.34.0 or newer under Node 24; do not change the project's runtime to fix the browser tool. + **Chrome is installed, but an isolated HOME cannot find it** ProofShot checks system paths and executable-only browser caches under the real account home without reusing a browser profile or storage. You can also pass one exact path with `proofshot start --browser-executable /absolute/path/to/chrome`. If nothing is runnable, run the `agent-browser install` command printed by `proofshot start`. +**Can ProofShot use my Chrome profile, saved state, cloud provider, or CDP browser?** +No. Final proof uses a fresh local owned Chromium session. ProofShot rejects provider/CDP/auto-connect/profile/state/proxy modes and copies a validated config into private per-session state. + **Server errors aren't being detected** Server log capture only works when ProofShot starts the server itself via `--run`. If your server was already running on the port, ProofShot skips spawning and gets no logs. diff --git a/content/docs/guides/verify-feature.mdx b/content/docs/guides/verify-feature.mdx index 2bb6d0b..f113c0a 100644 --- a/content/docs/guides/verify-feature.mdx +++ b/content/docs/guides/verify-feature.mdx @@ -125,7 +125,7 @@ Every `proofshot exec` or `agent-browser` command operates on the same browser s ## Troubleshooting **"No active session" error when running exec commands** -You need to run `proofshot start` first. The session state is stored in `.session.json` inside the artifacts directory. +Run `proofshot start` first. If several live sessions match the worktree, inspect `proofshot session list` and pass the exact ID with `--session`. **Video has long pauses with no activity** Install [ffmpeg](https://ffmpeg.org/) and ProofShot will automatically trim dead time from the video (5s buffer before the first action, 3s buffer after the last) and convert the finalized recording to H.264 MP4. diff --git a/content/docs/quick-start.mdx b/content/docs/quick-start.mdx index 04260ad..f506024 100644 --- a/content/docs/quick-start.mdx +++ b/content/docs/quick-start.mdx @@ -34,7 +34,7 @@ yarn global add proofshot -This installs the CLI and downloads [agent-browser](https://github.com/vercel-labs/agent-browser) with headless Chromium. +This installs the CLI and exact agent-browser 0.34.0 package. agent-browser requires Node 24; use the configured browser-tool launcher so project commands continue to honor their repository runtime. ### Install the skill for your AI agent @@ -64,7 +64,7 @@ In your project directory, tell your AI agent to verify a feature. For example: > Verify the login page with proofshot. Start the dev server, check that the form renders, fill in credentials, and submit. Screenshot each step. -The agent will run: +Explore and refine the User Flow first, then record one fresh session for the finalized Use Case. The agent will run: ```sh proofshot start --run "npm run dev" --port 3000 --description "Login page verification" diff --git a/content/docs/reference/cli.mdx b/content/docs/reference/cli.mdx index 4b5401d..d946440 100644 --- a/content/docs/reference/cli.mdx +++ b/content/docs/reference/cli.mdx @@ -49,7 +49,7 @@ proofshot start [options] | `--headed` | Show the browser window (visible Chromium) | `false` | | `--output

` | Custom output directory for artifacts | `./proofshot-artifacts` | | `--browser-executable ` | Use an exact Chrome/Chromium executable | auto-discovered | -| `--force` | Clean up and replace an active session | `false` | +| `--force` | Recover proven stale state; refuses live sessions/operations | `false` | **Examples:** @@ -64,10 +64,10 @@ proofshot start --headed # Show the browser w **What happens:** 1. Starts either `--run` or the configured owned `environment` (the two are mutually exclusive), attaches every configured log source, and completes readiness checks -2. Opens headless Chromium via agent-browser -3. Navigates to `--url` (or `http://localhost:`) -4. Starts video recording (retries up to 3 times) -5. Writes control `.session.json` to the configured/default output and durable `metadata.json` beside the evidence. A CLI-only `--output` changes evidence placement, not control discovery. +2. Requires agent-browser 0.34+, a local Chromium executable, a safe per-session config, and a target-domain allowlist +3. Creates a unique browser namespace/private evidence directory and registers an immutable session ID +4. Navigates to `--url`, starts private HAR capture, and starts video recording +5. Persists exact process identities and lifecycle operation ownership for cross-process recovery --- @@ -81,17 +81,18 @@ proofshot stop [options] | Flag | Description | Default | |------|-------------|---------| +| `--session ` | Stop one exact registered session | auto-select one live match | | `--no-close` | Keep the browser open after stopping | `false` | **What happens:** -1. Collects console errors and output from the browser -2. Stops video recording +1. Claims the exact session operation and collects console errors/output +2. Atomically finalizes private HAR evidence and a sanitized network summary, then stops video recording 3. Closes the exact owned browser session (unless `--no-close`) 4. Stops only the tmux session/server, direct processes, capture helpers, and dev-server process identities created by this ProofShot start 5. Trims video dead time and converts the finalized recording to H.264 `session.mp4` (requires ffmpeg): 5s buffer before first action, 3s after last 6. Scans `server.log` for errors across 10+ languages 7. Generates `SUMMARY.md`, canonical `evidence.json`, structured `verdict.json`, `viewer.html`, and a hashed provenance manifest -8. Clears `.session.json`, or retains it after `--no-close` until a later plain `stop` closes that exact browser +8. Unregisters completed ownership, or retains the exact session after `--no-close` until a later plain `stop` Repeated `stop` calls are successful no-ops. If bundling fails, control state remains retryable; a later `stop` reuses already-collected artifacts instead of widening process cleanup. @@ -102,7 +103,7 @@ Repeated `stop` calls are successful no-ops. If bundling fails, control state re Pass-through to agent-browser with automatic session logging. Captures timestamps, element bounding boxes, and resolves screenshot paths into the session directory. ```sh -proofshot exec [args...] +proofshot exec [--session ] [args...] ``` **Examples:** @@ -116,14 +117,15 @@ proofshot exec scroll down proofshot exec press Enter ``` -Every `exec` call appends an entry to `session-log.json` with: +With one live session, `exec` selects it automatically. Multiple live sessions require `--session `. Every call appends an entry to `session-log.json` with: - Relative timestamp from session start -- The command and arguments +- Sanitized command intent and interaction category - Passed/failed outcome and error text +- Sanitized pre/post page context and duration - Expected selector for `assert-visible` - Element bounding box and label (for `click`, `fill`, `type` targeting `@eN` refs) -This data powers the interactive viewer's timeline and overlays. +This data powers the interactive viewer's timeline and overlays. Synthetic DOM actions are diagnostic only and force an incomplete final verdict. Raw structured agent-browser output stays in private local evidence. --- @@ -201,6 +203,7 @@ Inspect durable recovery records and retry exact cleanup after an interrupted ru ```sh proofshot session list proofshot session clean --session +proofshot session clean --all ``` `clean` validates persisted process/session identities. Identity mismatches remain in recovery state and never widen cleanup to a port, process name, or default tmux socket. @@ -215,4 +218,4 @@ Remove the entire artifacts directory. proofshot clean ``` -Deletes `./proofshot-artifacts/` (or the configured output directory). No flags. If active or retained control state exists, `clean` refuses and asks you to run `proofshot stop` first so exact process ownership metadata is not discarded. +Deletes `./proofshot-artifacts/` (or the configured output directory). No flags. If any matching registry state exists—including starting, stopping, stale, or recovery state—`clean` refuses until `proofshot stop` or `proofshot session clean` preserves the evidence/ownership invariant. diff --git a/docs/architecture.md b/docs/architecture.md index 5f530d8..688ec1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,15 +34,14 @@ The choice of agent-browser as the browser automation layer is the most importan **Built-in recording.** Playwright's screencast API is exposed directly, so video capture works without ffmpeg. Finalization uses ffmpeg to trim the WebM capture and convert it to H.264 MP4. -All browser commands in ProofShot go through a single function: +All browser commands pass through one wrapper that injects the exact session, private config, namespace, socket root, allowlist, and a cleaned environment: ```typescript // src/utils/exec.ts -export function ab(command: string, timeoutMs = 30000): string { - return execSync(`agent-browser ${command}`, { - encoding: 'utf-8', - timeout: timeoutMs, - stdio: ['pipe', 'pipe', 'pipe'], +export function ab(command: string, options: AgentBrowserCommandOptions): string { + return execSync(buildAgentBrowserCommand(command, options), { + env: getAgentBrowserEnvironment(options), + timeout: options.timeoutMs ?? 30000, }).trim(); } ``` @@ -57,12 +56,12 @@ ProofShot uses a three-phase model: **start**, **exec** (repeated), **stop**. proofshot start --run "npm run dev" --port 3000 --description "Login flow" ``` -1. Check if port is already occupied (fail fast if `--run` conflicts) -2. Spawn dev server as a detached process, pipe stdout/stderr to `server.log` -3. Wait for port to become available (polls every 500ms, 30s timeout) -4. Open headless Chromium via agent-browser -5. Start video recording (Playwright screencast → temporary `.webm`) -6. Write `.session.json` to the artifacts directory +1. Verify agent-browser 0.34+, reject inherited provider/CDP/profile/state modes, and copy a safe config into private per-session state +2. Create a collision-safe session ID, namespace, socket root, domain allowlist, evidence directory, and immutable start-operation lease +3. Spawn an exact-owned dev server/environment when configured and persist process identities before waiting for readiness +4. Open fresh local Chromium and persist the daemon identity +5. Start private HAR capture and video recording +6. Register the session under the user state directory and write durable Git metadata beside its evidence Recording is mandatory. If it fails after 3 retries, the session aborts. Video proof is the whole point. @@ -75,11 +74,11 @@ proofshot exec screenshot step-login.png Each `exec` call: -1. Loads `.session.json`, validates recording is active -2. For click/fill/type actions targeting `@eN` refs: captures element bounding box and label *before* execution (used for viewer overlays) -3. Appends an entry to `session-log.json` with relative timestamp and element data -4. Forwards the command to agent-browser -5. Returns agent-browser's output +1. Selects the single addressable recording or requires `--session ` when several live sessions match +2. Claims an immutable exec-operation lease +3. Captures pre-action page context plus element bounds/label when available +4. Appends sanitized intent, interaction category, timing, outcome, and page context to `session-log.json`; raw structured output stays private +5. Forwards the command to the exact agent-browser namespace and releases the lease The element data capture uses a multi-strategy approach because agent-browser's `get box` command doesn't accept refs directly: - Try to get the element's `id` attribute, then query by `#id` @@ -92,14 +91,12 @@ The element data capture uses a multi-strategy approach because agent-browser's proofshot stop ``` -1. Collect console errors and console output from the browser (point-in-time snapshots) -2. Stop video recording -3. Close browser (unless `--no-close`) -4. **Finalize video** — remove dead time before the first action (5s buffer) and after the last action (3s buffer), convert to H.264 `session.mp4`, and adjust all `session-log.json` timestamps by the trim offset. -5. **Extract server errors** — scan `server.log` with multi-language regex patterns -6. Generate `SUMMARY.md` — markdown report with description, video link, screenshots, and errors -7. Generate `viewer.html` — standalone interactive viewer -8. Clear `.session.json` +1. Claim a stop-operation lease and collect console evidence +2. Flush HAR capture to a private pending file, validate it, atomically adopt it, and write a metadata-only network summary +3. Stop video and close only exact owned browser/environment/server processes +4. **Finalize video** — trim dead time, convert to H.264 `session.mp4`, and adjust action timestamps +5. Generate canonical evidence, verdict, summary, viewer, and provenance manifest +6. Unregister completed ownership, or retain the exact browser record after `--no-close` ## Interactive viewer @@ -176,6 +173,9 @@ src/ │ ├── pr.ts # GitHub PR description formatting │ └── clean.ts # Artifact directory removal ├── browser/ +│ ├── isolation.ts # Version gate, clean config/env, local-runtime policy +│ ├── provenance.ts # Interaction classification and secret-safe intent +│ ├── evidence.ts # Private structured/HAR evidence + sanitized summary │ ├── session.ts # Browser open/close, console collection │ ├── capture.ts # Recording start/stop, screenshots, diffs │ ├── navigate.ts # URL navigation, snapshot @@ -183,7 +183,10 @@ src/ ├── server/ │ └── start.ts # Dev server spawn + port waiting ├── session/ -│ └── state.ts # .session.json read/write/clear +│ ├── registry.ts # Global registry and per-session operation leases +│ ├── selection.ts # Operation-specific implicit/explicit resolution +│ ├── lifecycle.ts # Exact owned-process cleanup and recovery +│ └── state.ts # Session identity and evidence/process state ├── artifacts/ │ ├── viewer.ts # Interactive HTML viewer generation │ ├── summary.ts # Markdown summary generation @@ -203,10 +206,12 @@ src/ **tsup with two entry points.** The CLI binary (`bin/proofshot.ts`) builds to a single file with a shebang. The library entry (`src/index.ts`) builds with code splitting and `.d.ts` generation. This supports both CLI usage and programmatic import by other tools. -**Minimal dependencies.** Only three production dependencies: `commander` (CLI framework), `chalk` (terminal colors), `detect-port` (port checking). agent-browser is an optional peer dependency. This keeps the install fast and the supply chain small. +**Pinned browser contract.** agent-browser 0.34.0 is an exact runtime dependency and requires Node 24. Managed installations use a dedicated launcher so project commands keep their repository runtime. -**Session state in the output directory.** `.session.json` lives alongside artifacts, not in a global location. This allows parallel sessions in different projects and ensures `proofshot clean` removes everything. +**Registry-backed session control.** User-state registry files point to per-run evidence while immutable IDs, namespaces, sockets, and operation leases allow concurrent sessions in the same worktree. `clean` refuses while any matching registry record remains. + +**Private evidence boundary.** Raw agent-browser JSON, HAR, response bodies, credentials, and customer data remain in a `0700` private tree with `0600` files. Manifests and PR publication exclude that tree. **Config file walk-up.** `proofshot.config.json` is searched from cwd upward to filesystem root, supporting monorepo layouts where config lives at the repo root. -**Graceful degradation everywhere.** Missing ffmpeg skips video trimming and MP4 conversion, preserving the original WebM capture. Failed element data capture skips overlays. Browser already closed gets a silent catch. Console errors unavailable shows "0 errors". Non-critical failures never abort a session. +**Fail closed at trust boundaries.** Wrong browser versions, shared/cloud browser modes, ownership mismatches, corrupt operation locks, synthetic final proof, and invalid private evidence cannot be presented as successful proof. Missing ffmpeg still preserves the original WebM. diff --git a/skills/claude/SKILL.md b/skills/claude/SKILL.md index e99f2ca..cd7f836 100644 --- a/skills/claude/SKILL.md +++ b/skills/claude/SKILL.md @@ -1,79 +1,48 @@ --- name: proofshot -description: Visual verification of UI features. Use after building or modifying any - UI component, page, or visual feature. Starts a verification session with video - recording and error capture, then you drive the browser to test, then stop to - bundle proof artifacts for the human. +description: Visual verification of UI features. Explore and refine real user flows with agent-browser, then record one fresh isolated ProofShot session per reviewable use case and retain local evidence. allowed-tools: Bash(proofshot:*), Bash(agent-browser:*) --- -# ProofShot — Visual Verification Workflow +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets you verify your own work in a real browser — video proof, screenshots, and error reports, no vendor lock-in. +Use this workflow after a UI or visual behavior changes. -## When to use +## 1. Explore and write the brief -Use ProofShot after: -- Building a new UI feature or page -- Modifying existing UI components -- Fixing a visual bug -- Any change that affects what the user sees +Use the approved local `agent-browser` launcher before recording. Keep project commands on the repository runtime; browser tooling uses its separate Node 24 launcher and agent-browser 0.34.0 or newer. -## The workflow (always follow these 3 steps) +Maintain one Markdown brief in the task's persistent `~/data` directory. Define User Stories (actor, goal, benefit), stable Use Cases (preconditions, starting state, test data, success criteria), user-level User Flows, Iteration Feedback, and final User Testing instructions. -### Step 1: Start the session +## 2. Refine every flow -```bash -proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" -``` - -This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. - -**Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). -Only omit `--run` if the server was explicitly started by the user or another process — without it, no server logs are captured. +Exercise each flow repeatedly until it is correct, stable, and concise. Prefer pointer/keyboard-backed actions and disclose hybrid fill/select/checkbox behavior. `eval`, direct DOM clicks, setters, and dispatched events are diagnostics only and cannot be final proof. -If a previous session was not stopped cleanly, add `--force` to override it. -For configured multi-service projects, let `environment` start the declared tmux panes or direct processes; do not launch duplicate services separately. Use `proofshot doctor` when browser, FFmpeg, or recovery state is unclear. - -### Step 2: Drive the browser and test - -Use proofshot exec to navigate, interact, and verify: +## 3. Record one fresh session per Use Case ```bash -proofshot exec snapshot -i # See interactive elements -proofshot exec open http://localhost:PORT/page # Navigate to a page -proofshot exec click @e3 # Click a button -proofshot exec fill @e2 "test@example.com" # Fill a form field -proofshot exec assert-visible "#expected-result" # Record an explicit assertion -proofshot exec screenshot step-NAME.png # Capture key moments +proofshot start --run "your-dev-command" --port PORT --description "UC-ID: behavior" +proofshot exec snapshot -i +proofshot exec click @e3 +proofshot exec assert-visible "#expected-result" +proofshot exec screenshot step-result.png +proofshot stop ``` -Take screenshots at important moments — these become the visual proof. -Verify expected outcomes with assertion commands before taking screenshots. A screenshot alone is not a passing assertion. +Omit `--run` only for an explicitly external server. With multiple sessions, use the printed ID in `proofshot exec --session ID ...` and `proofshot stop --session ID`. Record the final session ID and local evidence pointer in the brief. -### Step 3: Stop and bundle the proof +## 4. Inspect and retain -```bash -proofshot stop -``` +`FAIL`, `INCOMPLETE`, and `BLOCKED` are unsuccessful. Keep raw JSON, HAR, response bodies, credentials, and customer data private under the task's `~/data` evidence root. Report paths without copying raw artifacts into prompts, scratch, PRs, or uploads. -This stops recording, collects console + server errors, and generates -a SUMMARY.md with video, screenshots, and error report. -Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` verdicts as unsuccessful verification. If cleanup was interrupted, inspect `proofshot session list` and retry only the exact session with `proofshot session clean --session ID`. +Recover interrupted cleanup with `proofshot session list` and `proofshot session clean --session ID`; never kill by process name or port. `--force` only recovers proven stale state. -### Step 4 (optional): Post proof to the PR +## 5. Publish curated proof + +Copy the exercised final flows into PR User Testing instructions. Dry-run explicit ordered publication, repeating `--session` in review order: ```bash -proofshot pr # Auto-detect PR from current branch -proofshot pr 42 # Target a specific PR number -proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID +proofshot pr 42 --session SESSION_ID --screenshot step-result.png --dry-run ``` -This uploads screenshots and video to GitHub and posts a formatted comment on the PR. By default it uses the official GitHub contents API on a `proofshot-artifacts` branch. Use `--upload-provider github-web-attachments` if you specifically want GitHub attachment URLs. - -## Tips - -- Always include a meaningful --description so the human knows what was tested -- Take screenshots before AND after key actions (e.g., before form submit, after redirect) -- If you find errors during verification, fix them and re-run the workflow -- Use `proofshot pr` after stopping to attach proof directly to the pull request +Publish only curated media, sanitized summaries, and interaction disclosure. diff --git a/skills/codex/AGENTS.md b/skills/codex/AGENTS.md index 5b7afc4..ad2500d 100644 --- a/skills/codex/AGENTS.md +++ b/skills/codex/AGENTS.md @@ -1,22 +1,3 @@ -# ProofShot Visual Verification +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets any AI coding agent verify its own work in a real browser — no vendor lock-in required. - -After building or modifying UI features, verify with this workflow: - -1. Start: `proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"` - If the server is already running, omit --run. - For configured multi-service projects, let `environment` launch its tmux panes or processes. Run `proofshot doctor` for dependency or recovery diagnostics. -2. Test: Use `proofshot exec` to navigate, click, fill forms, take screenshots - Record an explicit assertion such as `proofshot exec assert-visible "#expected-result"` before screenshots. -3. Stop: `proofshot stop` — bundles canonical evidence, video, screenshots, and a structured verdict. `FAIL`, `INCOMPLETE`, and `BLOCKED` are unsuccessful. - After an interrupted cleanup, use `proofshot session list` and `proofshot session clean --session ID`; never kill by port or process name. -4. Publish only finalized matching evidence: `proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID`. Repeat either flag for multiple explicit selections. - -Key proofshot exec commands: -- `proofshot exec snapshot -i` — see interactive elements -- `proofshot exec click @e3` — click an element -- `proofshot exec fill @e2 "text"` — fill a form field -- `proofshot exec screenshot step.png` — capture a moment - -Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. +Follow [SKILL.md](./SKILL.md) for the complete exploration-to-proof workflow. The completion boundary is one fresh isolated session per finalized Use Case, retained local evidence, and PR User Testing instructions copied from flows that were actually exercised. diff --git a/skills/codex/SKILL.md b/skills/codex/SKILL.md index 2dd6659..7942e05 100644 --- a/skills/codex/SKILL.md +++ b/skills/codex/SKILL.md @@ -1,78 +1,61 @@ --- name: proofshot -description: Visual verification of UI features. Use after building or modifying any - UI component, page, or visual feature. Starts a verification session with video - recording and error capture, then you drive the browser to test, then stop to - bundle proof artifacts for the human. +description: Visual verification of UI features. Explore and refine real user flows with agent-browser, then record one fresh isolated ProofShot session per reviewable use case and retain local evidence. --- -# ProofShot — Visual Verification Workflow +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets you verify your own work in a real browser — video proof, screenshots, and error reports, no vendor lock-in. +Use this workflow after a UI or visual behavior changes. -## When to use +## 1. Explore -Use ProofShot after: -- Building a new UI feature or page -- Modifying existing UI components -- Fixing a visual bug -- Any change that affects what the user sees +Use the approved local `agent-browser` launcher to understand the feature before recording proof. Keep the project on its repository runtime; browser tooling uses its separate Node 24 launcher and agent-browser 0.34.0 or newer. -## The workflow (always follow these 3 steps) +Maintain one Markdown brief in the task's persistent `~/data` directory: -### Step 1: Start the session +- **User Story**: actor, goal, and benefit. +- **Use Case**: one reviewable behavior with a stable ID, preconditions, starting state, test-data references, and success criteria. +- **User Flow**: ordered user-level actions and observable outcomes. Exclude shell commands and ephemeral element refs. +- **Iteration Feedback**: what was wrong, brittle, redundant, or slow and what the next revision changes. +- **User Testing instructions**: the concise final flow copied to the PR after it was exercised. -```bash -proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" -``` - -This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. +## 2. Refine every flow -**Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). -Only omit `--run` if the server was explicitly started by the user or another process — without it, no server logs are captured. +Exercise each User Flow with `agent-browser`. Append feedback and revise until the flow is correct, stable, and concise. Exploration can use repeated passes. -If a previous session was not stopped cleanly, add `--force` to override it. -For configured multi-service projects, let `environment` start the declared tmux panes or direct processes; do not launch duplicate services separately. Use `proofshot doctor` when browser, FFmpeg, or recovery state is unclear. +- Prefer pointer/keyboard-backed interactions. +- Disclose hybrid operations such as fill, select, and checkbox actions. +- Treat `eval`, direct DOM `.click()`, setters, and dispatched events as diagnostics only. A synthetic DOM action cannot be final behavioral proof. -### Step 2: Drive the browser and test +## 3. Record fresh proof -Use proofshot exec to navigate, interact, and verify: +For each finalized Use Case, start a fresh ProofShot session and rerun only its optimized flow: ```bash -proofshot exec snapshot -i # See interactive elements -proofshot exec open http://localhost:PORT/page # Navigate to a page -proofshot exec click @e3 # Click a button -proofshot exec fill @e2 "test@example.com" # Fill a form field -proofshot exec assert-visible "#expected-result" # Record an explicit assertion -proofshot exec screenshot step-NAME.png # Capture key moments +proofshot start --run "your-dev-command" --port PORT --description "UC-ID: behavior" +proofshot exec snapshot -i +proofshot exec click @e3 +proofshot exec assert-visible "#expected-result" +proofshot exec screenshot step-result.png +proofshot stop ``` -Take screenshots at important moments — these become the visual proof. -Verify expected outcomes with assertion commands before taking screenshots. A screenshot alone is not a passing assertion. +Omit `--run` only when the server is explicitly owned elsewhere. Record one short session per Use Case. When several sessions match the worktree, pass the printed ID with `proofshot exec --session ID ...` and `proofshot stop --session ID`. -### Step 3: Stop and bundle the proof +Completion requires an explicit assertion, a fresh-session replay, and a retained session ID/evidence pointer in the brief. -```bash -proofshot stop -``` +## 4. Inspect and retain -This stops recording, collects console + server errors, and generates -a SUMMARY.md with video, screenshots, and error report. -Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` verdicts as unsuccessful verification. If cleanup was interrupted, inspect `proofshot session list` and retry only the exact session with `proofshot session clean --session ID`. +Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` as unsuccessful verification. Raw agent-browser JSON, HAR, response bodies, credentials, and customer data remain private under the task's `~/data` evidence root. Report the local path; do not copy raw evidence into prompts, scratch, PRs, or uploads. -### Step 4 (optional): Post proof to the PR +If cleanup is interrupted, use `proofshot session list` and `proofshot session clean --session ID`. Never clean by process name or port. `--force` recovers proven stale state only and refuses a verified live session or operation. -```bash -proofshot pr # Auto-detect PR from current branch -proofshot pr 42 # Target a specific PR number -proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID -``` +## 5. Publish curated proof -This uploads screenshots and video to GitHub and posts a formatted comment on the PR. By default it uses the official GitHub contents API on a `proofshot-artifacts` branch. Use `--upload-provider github-web-attachments` if you specifically want GitHub attachment URLs. +Copy only exercised final flows into PR User Testing instructions. Publish explicit ordered sessions and curated screenshots: -## Tips +```bash +proofshot pr 42 --session SESSION_ID --screenshot step-result.png --dry-run +``` -- Always include a meaningful --description so the human knows what was tested -- Take screenshots before AND after key actions (e.g., before form submit, after redirect) -- If you find errors during verification, fix them and re-run the workflow -- Use `proofshot pr` after stopping to attach proof directly to the pull request +Repeat `--session` in review order. Publish only curated media, sanitized summaries, and interaction disclosure; keep raw private evidence local. diff --git a/skills/cursor/proofshot.mdc b/skills/cursor/proofshot.mdc index 0e9dcb7..139ad19 100644 --- a/skills/cursor/proofshot.mdc +++ b/skills/cursor/proofshot.mdc @@ -1,24 +1,15 @@ --- -description: Visual verification of UI changes using ProofShot +description: Explore UI behavior, refine user flows, then record fresh isolated ProofShot evidence globs: ["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.html"] --- -ProofShot is an open-source, agent-agnostic CLI for visual verification — works with any agent, no vendor lock-in. +After a UI change: -After modifying UI files, visually verify changes with this workflow: +1. Explore with the approved local agent-browser launcher. Keep project commands on the repository runtime and browser tooling on its separate Node 24/agent-browser 0.34+ launcher. +2. Maintain a brief under the task's persistent `~/data` directory: User Stories; stable Use Cases with preconditions, starting state, test data, and success criteria; user-level User Flows; Iteration Feedback; and final User Testing instructions. +3. Exercise and simplify every flow. Prefer pointer/keyboard actions, disclose hybrid fill/select/checkbox behavior, and use synthetic DOM mutation only for diagnosis. +4. Rerun each finalized Use Case in one fresh ProofShot session. Include an assertion and screenshot. With multiple sessions, pass the printed ID to `proofshot exec --session ID ...` and `proofshot stop --session ID`. +5. Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` as unsuccessful. Record the session ID and local evidence pointer. Keep raw JSON/HAR/bodies/credentials/customer data local under `~/data` and out of prompts, scratch, PRs, and uploads. +6. Copy only exercised flows into PR User Testing instructions. Dry-run explicit ordered publication with repeated `--session` flags and curated screenshots. -1. Start session: `proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"` - Always use --run so proofshot captures server logs. Only omit --run if the server was started by the user. - If the port is in use, ProofShot leaves that unowned listener alone; choose another port or stop it explicitly. Add --force only if a stale ProofShot session blocks startup. - For configured multi-service projects, let `environment` launch its tmux panes or processes instead of starting duplicate services. Run `proofshot doctor` for dependency or recovery diagnostics. -2. Drive browser: Use `proofshot exec` commands to navigate, click, fill forms, and take screenshots - Record an explicit assertion such as `proofshot exec assert-visible "#expected-result"` before screenshots. -3. Stop session: `proofshot stop` to bundle canonical evidence, video, screenshots, and a structured verdict. `FAIL`, `INCOMPLETE`, and `BLOCKED` are unsuccessful. - After an interrupted cleanup, use `proofshot session list` and `proofshot session clean --session ID`; never kill by port or process name. -4. Publish only finalized matching evidence: `proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID`. Repeat either flag for multiple explicit selections. - -Key proofshot exec commands: -- `proofshot exec snapshot -i` — see interactive elements -- `proofshot exec click @e3` — click an element -- `proofshot exec fill @e2 "text"` — fill a form field -- `proofshot exec screenshot step.png` — capture a moment +Recover only with `proofshot session list` and `proofshot session clean --session ID`; never kill by process name or port. `--force` refuses live sessions and operations. diff --git a/skills/generic/PROOFSHOT.md b/skills/generic/PROOFSHOT.md index 4655d77..972a428 100644 --- a/skills/generic/PROOFSHOT.md +++ b/skills/generic/PROOFSHOT.md @@ -1,23 +1,10 @@ -# ProofShot Visual Verification +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets any AI coding agent verify its own work in a real browser — no vendor lock-in required. +1. Explore with the approved local agent-browser launcher before recording. Keep project commands on the repository runtime and browser tooling on its separate Node 24/agent-browser 0.34+ launcher. +2. Maintain a brief under the task's persistent `~/data` directory: User Stories; stable Use Cases with preconditions, starting state, test data, and success criteria; user-level User Flows; Iteration Feedback; and final User Testing instructions. +3. Exercise and simplify every flow. Prefer pointer/keyboard actions, disclose hybrid fill/select/checkbox behavior, and use synthetic DOM mutation only for diagnosis. +4. Record one fresh ProofShot session per finalized Use Case. Include an assertion and screenshot. With multiple sessions, use `proofshot exec --session ID ...` and `proofshot stop --session ID`. +5. Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` as unsuccessful. Record the session ID and local evidence pointer. Keep raw JSON/HAR/bodies/credentials/customer data local under `~/data` and out of prompts, scratch, PRs, and uploads. +6. Copy only exercised flows into PR User Testing instructions and dry-run explicit ordered publication with curated screenshots. -After building or modifying UI features, verify with this workflow: - -1. Start: `proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"` - Always use --run so proofshot captures server logs. Only omit --run if the server was started by the user. - If the port is in use, ProofShot leaves that unowned listener alone; choose another port or stop it explicitly. Add --force only if a stale ProofShot session blocks startup. - For configured multi-service projects, let `environment` launch its tmux panes or processes instead of starting duplicate services. Run `proofshot doctor` for dependency or recovery diagnostics. -2. Test: Use `proofshot exec` to navigate, click, fill forms, take screenshots - Record an explicit assertion such as `proofshot exec assert-visible "#expected-result"` before screenshots. -3. Stop: `proofshot stop` — bundles canonical evidence, video, screenshots, and a structured verdict. `FAIL`, `INCOMPLETE`, and `BLOCKED` are unsuccessful. - After an interrupted cleanup, use `proofshot session list` and `proofshot session clean --session ID`; never kill by port or process name. -4. Publish only finalized matching evidence: `proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID`. Repeat either flag for multiple explicit selections. - -Key proofshot exec commands: -- `proofshot exec snapshot -i` — see interactive elements -- `proofshot exec click @e3` — click an element -- `proofshot exec fill @e2 "text"` — fill a form field -- `proofshot exec screenshot step.png` — capture a moment - -Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. +Recover with `proofshot session list` and `proofshot session clean --session ID`; never kill by process name or port. `--force` refuses live sessions and operations. diff --git a/skills/opencode/SKILL.md b/skills/opencode/SKILL.md index 7268484..fa6f517 100644 --- a/skills/opencode/SKILL.md +++ b/skills/opencode/SKILL.md @@ -1,79 +1,16 @@ --- name: proofshot -description: Visual verification of UI features. Use after building or modifying any - UI component, page, or visual feature. Starts a verification session with video - recording and error capture, then you drive the browser to test, then stop to - bundle proof artifacts for the human. +description: Explore UI behavior, refine user flows, then record one fresh isolated ProofShot session per reviewable use case. compatibility: opencode --- -# ProofShot - Visual Verification Workflow +# ProofShot visual verification -ProofShot is an open-source, agent-agnostic CLI that lets you verify your own work in a real browser - video proof, screenshots, and error reports, no vendor lock-in. +1. Explore with the approved local agent-browser launcher before recording. Keep project commands on the repository runtime and browser tooling on its separate Node 24/agent-browser 0.34+ launcher. +2. Maintain a brief under the task's persistent `~/data` directory: User Stories; stable Use Cases with preconditions, starting state, test data, and success criteria; user-level User Flows; Iteration Feedback; and final User Testing instructions. +3. Exercise and simplify every flow. Prefer pointer/keyboard actions, disclose hybrid fill/select/checkbox behavior, and use synthetic DOM mutation only for diagnosis. +4. Record one fresh session per finalized Use Case with an explicit assertion and screenshot. With multiple sessions, use `proofshot exec --session ID ...` and `proofshot stop --session ID`. +5. Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` as unsuccessful. Retain the session ID and local evidence pointer. Keep raw JSON/HAR/bodies/credentials/customer data local under `~/data` and out of prompts, scratch, PRs, and uploads. +6. Copy only exercised flows into PR User Testing instructions and dry-run explicit ordered publication with curated screenshots. -## When to use - -Use ProofShot after: -- Building a new UI feature or page -- Modifying existing UI components -- Fixing a visual bug -- Any change that affects what the user sees - -## The workflow (always follow these 3 steps) - -### Step 1: Start the session - -```bash -proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" -``` - -This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. - -**Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). -Only omit `--run` if the server was explicitly started by the user or another process - without it, no server logs are captured. - -If a previous session was not stopped cleanly, add `--force` to override it. -For configured multi-service projects, let `environment` start the declared tmux panes or direct processes; do not launch duplicate services separately. Use `proofshot doctor` when browser, FFmpeg, or recovery state is unclear. - -### Step 2: Drive the browser and test - -Use proofshot exec to navigate, interact, and verify: - -```bash -proofshot exec snapshot -i # See interactive elements -proofshot exec open http://localhost:PORT/page # Navigate to a page -proofshot exec click @e3 # Click a button -proofshot exec fill @e2 "test@example.com" # Fill a form field -proofshot exec assert-visible "#expected-result" # Record an explicit assertion -proofshot exec screenshot step-NAME.png # Capture key moments -``` - -Take screenshots at important moments - these become the visual proof. -Verify expected outcomes with assertion commands before taking screenshots. A screenshot alone is not a passing assertion. - -### Step 3: Stop and bundle the proof - -```bash -proofshot stop -``` - -This stops recording, collects console + server errors, and generates -a SUMMARY.md with video, screenshots, and error report. -Treat `FAIL`, `INCOMPLETE`, and `BLOCKED` verdicts as unsuccessful verification. If cleanup was interrupted, inspect `proofshot session list` and retry only the exact session with `proofshot session clean --session ID`. - -### Step 4 (optional): Post proof to the PR - -```bash -proofshot pr # Auto-detect PR from current branch -proofshot pr 42 # Target a specific PR number -proofshot pr 42 --session SESSION_ID --screenshot ARTIFACT_ID -``` - -This uploads screenshots and video to GitHub and posts a formatted comment on the PR. By default it uses the official GitHub contents API on a `proofshot-artifacts` branch. Use `--upload-provider github-web-attachments` if you specifically want GitHub attachment URLs. - -## Tips - -- Always include a meaningful --description so the human knows what was tested -- Take screenshots before AND after key actions (e.g., before form submit, after redirect) -- If you find errors during verification, fix them and re-run the workflow -- Use `proofshot pr` after stopping to attach proof directly to the pull request +Recover with `proofshot session list` and `proofshot session clean --session ID`; never kill by process name or port. `--force` refuses live sessions and operations. From d84665c3ee362c0e54819ddbf539158cb444a091 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:41:58 +0000 Subject: [PATCH 32/74] fix(skills): align inline proof workflow Co-authored-by: Codex --- src/utils/skills.ts | 131 ++++++++------------------------------------ 1 file changed, 24 insertions(+), 107 deletions(-) diff --git a/src/utils/skills.ts b/src/utils/skills.ts index e500f81..e0ecb8a 100644 --- a/src/utils/skills.ts +++ b/src/utils/skills.ts @@ -1,6 +1,18 @@ import * as fs from 'fs'; import * as path from 'path'; +const INLINE_WORKFLOW = `After a UI change: + +1. Explore with the approved local agent-browser launcher before recording. Keep project commands on the repository runtime and browser tooling on its separate Node 24/agent-browser 0.34+ launcher. +2. Maintain a brief under the task's persistent ~/data directory: User Stories; stable Use Cases with preconditions, starting state, test data, and success criteria; user-level User Flows; Iteration Feedback; and final User Testing instructions. +3. Exercise and simplify every flow. Prefer pointer/keyboard actions, disclose hybrid fill/select/checkbox behavior, and use synthetic DOM mutation only for diagnosis. +4. Record one fresh ProofShot session per finalized Use Case with an assertion and screenshot. With multiple sessions, use proofshot exec --session ID ... and proofshot stop --session ID. +5. Treat FAIL, INCOMPLETE, and BLOCKED as unsuccessful. Retain the session ID and local evidence pointer. Keep raw JSON/HAR/bodies/credentials/customer data local under ~/data and out of prompts, scratch, PRs, and uploads. +6. Copy only exercised flows into PR User Testing instructions and dry-run explicit ordered publication with curated screenshots. + +Recover with proofshot session list and proofshot session clean --session ID; never kill by process name or port. --force refuses live sessions and operations. +`; + /** * Resolve the directory where bundled skill files are shipped. */ @@ -27,125 +39,30 @@ export function readBundledSkill(relativePath: string): string | null { */ export function getInlineSkillContent(agent: string): string { if (agent === 'claude' || agent === 'codex') { + const allowedTools = + agent === 'claude' + ? 'allowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n' + : ''; return `--- name: proofshot -description: Visual verification of UI features. Use after building or modifying any - UI component, page, or visual feature. Starts a verification session with video - recording and error capture, then you drive the browser to test, then stop to - bundle proof artifacts for the human. -allowed-tools: Bash(proofshot:*), Bash(agent-browser:*) ---- - -# ProofShot — Visual Verification Workflow - -## When to use - -Use ProofShot after: -- Building a new UI feature or page -- Modifying existing UI components -- Fixing a visual bug -- Any change that affects what the user sees - -## The workflow (always follow these 3 steps) - -### Step 1: Start the session - -\`\`\`bash -proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" -\`\`\` - -This opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output. -If the server is already running, omit --run (no server logs captured). -The description appears in the proof report for the human. - -### Step 2: Drive the browser and test - -Use proofshot exec to navigate, interact, and verify: - -\`\`\`bash -proofshot exec snapshot -i # See interactive elements -proofshot exec open http://localhost:PORT/page # Navigate to a page -proofshot exec click @e3 # Click a button -proofshot exec fill @e2 "test@example.com" # Fill a form field -proofshot exec assert-visible "#expected-result" # Record an expected selector -proofshot exec screenshot step-NAME.png # Capture key moments -\`\`\` +description: Explore UI behavior, refine user flows, then record fresh isolated ProofShot evidence. +${allowedTools}--- -Take screenshots at important moments — these become the visual proof. -Verify what you expect to see by reading the snapshot output. +# ProofShot visual verification -### Step 3: Stop and bundle the proof - -\`\`\`bash -proofshot stop -\`\`\` - -This stops recording, collects canonical browser + environment evidence, and generates -a SUMMARY.md, viewer, structured verdict, and provenance manifest. - -### Step 4 (optional): Post proof to the PR - -\`\`\`bash -proofshot pr # Auto-detect PR from current branch -proofshot pr 42 # Target a specific PR number -proofshot pr 42 --session SESSION_ID --screenshot step-NAME.png -\`\`\` - -This selects one finalized session compatible with the PR head, validates artifact hashes, uploads the selected screenshots/video, and posts only after every upload succeeds. Requires \`gh\` CLI to be authenticated. -Default upload mode uses the official GitHub contents API on a \`proofshot-artifacts\` branch. For GitHub-hosted attachment URLs, use \`proofshot pr --upload-provider github-web-attachments\`. - -## Tips - -- Always include a meaningful --description so the human knows what was tested -- Take screenshots before AND after key actions (e.g., before form submit, after redirect) -- If you find errors during verification, fix them and re-run the workflow -- Use \`proofshot pr\` after stopping to attach proof directly to the pull request -`; +${INLINE_WORKFLOW}`; } if (agent === 'cursor') { return `--- -description: Visual verification of UI changes using ProofShot +description: Explore UI behavior, refine user flows, then record fresh isolated ProofShot evidence globs: ["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.html"] --- -After modifying UI files, visually verify changes with this workflow: - -1. Start session: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` - If the server is already running, omit --run. -2. Drive browser: Use \`proofshot exec\` commands to navigate, click, fill forms, and take screenshots -3. Stop session: \`proofshot stop\` to bundle video + screenshots + error report -4. (Optional) Post to PR: \`proofshot pr\` to upload proof to the GitHub PR - Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. - -Key proofshot exec commands: -- \`proofshot exec snapshot -i\` — see interactive elements -- \`proofshot exec click @e3\` — click an element -- \`proofshot exec fill @e2 "text"\` — fill a form field -- \`proofshot exec assert-visible "#selector"\` — record an expected selector -- \`proofshot exec screenshot step.png\` — capture a moment -`; +${INLINE_WORKFLOW}`; } - // Generic / gemini / windsurf - return `# ProofShot Visual Verification - -After building or modifying UI features, verify with this workflow: + return `# ProofShot visual verification -1. Start: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` - If the server is already running, omit --run. -2. Test: Use \`proofshot exec\` to navigate, click, fill forms, take screenshots -3. Stop: \`proofshot stop\` — bundles video, screenshots, and error report -4. (Optional) Post to PR: \`proofshot pr\` — uploads proof to the GitHub PR - Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. - -Key proofshot exec commands: -- \`proofshot exec snapshot -i\` — see interactive elements -- \`proofshot exec click @e3\` — click an element -- \`proofshot exec fill @e2 "text"\` — fill a form field -- \`proofshot exec assert-visible "#selector"\` — record an expected selector -- \`proofshot exec screenshot step.png\` — capture a moment - -Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. -`; +${INLINE_WORKFLOW}`; } From bf4f55d0fec021c530656d353b420921173fbc50 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:20:30 +0000 Subject: [PATCH 33/74] fix(browser): Harden action provenance parsing Co-authored-by: Codex --- src/browser/provenance.test.ts | 64 +++++++++++++++++ src/browser/provenance.ts | 127 +++++++++++++++++++++++++++++++-- 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/src/browser/provenance.test.ts b/src/browser/provenance.test.ts index 899b25c..12ef89b 100644 --- a/src/browser/provenance.test.ts +++ b/src/browser/provenance.test.ts @@ -30,6 +30,12 @@ describe('browser interaction provenance', () => { expect(classifyInteraction(['keyboard', 'type', 'private'])).toBe( 'pointer-keyboard', ); + expect( + classifyInteraction(['find', 'nth', '2', 'a', 'click']), + ).toBe('pointer-keyboard'); + expect(classifyInteraction(['keyboard', 'inserttext', 'private'])).toBe( + 'hybrid', + ); expect(classifyInteraction(['keydown', 'Shift'])).toBe('pointer-keyboard'); expect(classifyInteraction(['storage', 'get', 'theme'])).toBe('observation'); expect(classifyInteraction(['storage', 'set', 'theme', 'dark'])).toBe('setup'); @@ -37,6 +43,39 @@ describe('browser interaction provenance', () => { expect(classifyInteraction(['network', 'har', 'start'])).toBe('setup'); }); + it('rejects synthetic DOM mutation hidden in nested batch commands', () => { + const syntheticCommand = JSON.stringify([ + 'eval', + 'document.querySelector("button").click()', + ]); + const nestedBatch = JSON.stringify([ + 'batch', + syntheticCommand, + ]); + + expect( + classifyInteraction([ + 'batch', + '--bail', + '["open","https://example.com"]', + syntheticCommand, + ]), + ).toBe('synthetic-dom'); + expect( + classifyInteraction(['batch', nestedBatch]), + ).toBe('synthetic-dom'); + expect( + classifyInteraction([ + 'batch', + 'open https://example.com', + 'eval "document.body.click()"', + ]), + ).toBe('synthetic-dom'); + expect(classifyInteraction(['batch', '["snapshot","-i"]'])).toBe( + 'observation', + ); + }); + it('redacts typed values, credentials, scripts, and sensitive URL parameters', () => { expect( buildSanitizedCommandIntent(['fill', '@e2', 'private-value']).summary, @@ -55,6 +94,31 @@ describe('browser interaction provenance', () => { "document.querySelector('#secret').value", ]).summary, ).toBe('eval [REDACTED_SCRIPT]'); + expect( + buildSanitizedCommandIntent([ + 'find', + 'nth', + '2', + '.card', + 'fill', + 'private-nth-value', + ]).summary, + ).toBe('find nth 2 .card fill [REDACTED]'); + expect( + buildSanitizedCommandIntent([ + 'open', + 'https://api.example.com', + '--headers', + '{"Authorization":"Bearer private-header-token"}', + ]).summary, + ).toBe('open https://api.example.com/ --headers [REDACTED]'); + expect( + buildSanitizedCommandIntent([ + 'keyboard', + 'inserttext', + 'private-keyboard-value', + ]).summary, + ).toBe('keyboard inserttext [REDACTED]'); const nestedSecrets = [ buildSanitizedCommandIntent([ diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts index a47984c..7fc0491 100644 --- a/src/browser/provenance.ts +++ b/src/browser/provenance.ts @@ -1,7 +1,8 @@ const REDACTED = '[REDACTED]'; const REDACTED_ARGUMENTS = '[REDACTED_ARGUMENTS]'; const REDACTED_BATCH = '[REDACTED_BATCH]'; -const SECRET_FLAG = /^(?:--)?(?:authorization|cookie|credential|password|secret|token|api[-_]?key|body)$/i; +const MAX_BATCH_DEPTH = 8; +const SECRET_FLAG = /^(?:--)?(?:authorization|cookie|credential|headers|password|secret|token|api[-_]?key|body)$/i; const SECRET_QUERY_PARAMETER = /(?:auth|code|cookie|credential|key|password|secret|session|token)/i; const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i; @@ -65,18 +66,32 @@ export function buildSanitizedCommandIntent( } export function classifyInteraction(args: string[]): InteractionCategory { + return classifyInteractionAtDepth(args, 0); +} + +function classifyInteractionAtDepth( + args: string[], + batchDepth: number, +): InteractionCategory { const command = args[0]?.toLowerCase(); if (!command) { return 'unknown'; } + if (command === 'batch') { + return classifyBatchInteraction(args, batchDepth); + } if (command === 'find') { - const nestedAction = args[3]?.toLowerCase(); + const nestedAction = args[findActionIndex(args)]?.toLowerCase(); return nestedAction - ? classifyInteraction([nestedAction]) + ? classifyInteractionAtDepth([nestedAction], batchDepth) : 'observation'; } if (command === 'keyboard') { - return ['inserttext', 'press', 'type'].includes(args[1]?.toLowerCase()) + const keyboardAction = args[1]?.toLowerCase(); + if (keyboardAction === 'inserttext') { + return 'hybrid'; + } + return ['press', 'type'].includes(keyboardAction) ? 'pointer-keyboard' : 'unknown'; } @@ -111,6 +126,101 @@ export function classifyInteraction(args: string[]): InteractionCategory { return 'unknown'; } +function classifyBatchInteraction( + args: string[], + batchDepth: number, +): InteractionCategory { + if (batchDepth >= MAX_BATCH_DEPTH) { + return 'synthetic-dom'; + } + + const nestedCommands = args + .slice(1) + .filter((argument) => !['--bail', '--json'].includes(argument)) + .flatMap(parseBatchArgument); + if (nestedCommands.length === 0) { + return 'unknown'; + } + + const categories = nestedCommands.map((nestedArgs) => + classifyInteractionAtDepth(nestedArgs, batchDepth + 1), + ); + if (categories.includes('synthetic-dom')) { + return 'synthetic-dom'; + } + + const uniqueCategories = new Set(categories); + return uniqueCategories.size === 1 ? categories[0] : 'unknown'; +} + +function parseBatchArgument(value: string): string[][] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + const command = tokenizeCommand(value); + return command.length > 0 ? [command] : []; + } + + if (typeof parsed === 'string') { + const command = tokenizeCommand(parsed); + return command.length > 0 ? [command] : []; + } + if (!Array.isArray(parsed)) { + return []; + } + if (parsed.every((argument): argument is string => typeof argument === 'string')) { + return [parsed]; + } + return parsed.filter( + (command): command is string[] => + Array.isArray(command) && command.every((argument) => typeof argument === 'string'), + ); +} + +function tokenizeCommand(value: string): string[] { + const args: string[] = []; + let current = ''; + let quote: 'single' | 'double' | null = null; + let escaped = false; + + for (const character of value) { + if (escaped) { + current += character; + escaped = false; + continue; + } + if (character === '\\' && quote !== 'single') { + escaped = true; + continue; + } + if (character === "'" && quote !== 'double') { + quote = quote === 'single' ? null : 'single'; + continue; + } + if (character === '"' && quote !== 'single') { + quote = quote === 'double' ? null : 'double'; + continue; + } + if (/\s/.test(character) && quote === null) { + if (current.length > 0) { + args.push(current); + current = ''; + } + continue; + } + current += character; + } + + if (escaped) { + current += '\\'; + } + if (current.length > 0) { + args.push(current); + } + return args; +} + export function sanitizePageUrl(value: string | undefined): string | undefined { if (!value) { return value; @@ -220,16 +330,21 @@ function sanitizeArguments(command: string, args: string[]): string[] { } function sanitizeFindArguments(args: string[]): string[] { - const nestedAction = args[3]?.toLowerCase(); + const actionIndex = findActionIndex(args); + const nestedAction = args[actionIndex]?.toLowerCase(); if (!nestedAction) { return args.map(sanitizeArgument); } if (['fill', 'select', 'type'].includes(nestedAction)) { - return [...args.slice(0, 4).map(sanitizeArgument), REDACTED]; + return [...args.slice(0, actionIndex + 1).map(sanitizeArgument), REDACTED]; } return args.map(sanitizeArgument); } +function findActionIndex(args: string[]): number { + return args[1]?.toLowerCase() === 'nth' ? 4 : 3; +} + function isKnownCommand(command: string): boolean { return ( HYBRID_ACTIONS.has(command) || From 039ba1d1f6eff27319cf8a0d38641a3fe9214be9 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:22:08 +0000 Subject: [PATCH 34/74] fix(browser): Reject plugin configuration Co-authored-by: Codex --- src/browser/isolation.test.ts | 11 +++++++++++ src/browser/isolation.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/browser/isolation.test.ts b/src/browser/isolation.test.ts index f0b0153..5ab9efb 100644 --- a/src/browser/isolation.test.ts +++ b/src/browser/isolation.test.ts @@ -37,6 +37,7 @@ describe('agent-browser isolation', () => { 'AGENT_BROWSER_CDP', 'AGENT_BROWSER_AUTO_CONNECT', 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_PLUGINS', 'AGENT_BROWSER_STATE', 'AGENT_BROWSER_SESSION_NAME', ]) { @@ -56,6 +57,16 @@ describe('agent-browser isolation', () => { ).toThrow('config key "profile"'); }); + it('rejects plugin loading from an explicit config', () => { + const directory = createDirectory(); + const configPath = path.join(directory, 'agent-browser.json'); + fs.writeFileSync(configPath, JSON.stringify({ plugins: ['./plugin.js'] })); + + expect(() => loadIsolatedAgentBrowserConfig(configPath, {})).toThrow( + 'config key "plugins"', + ); + }); + it('copies a safe config into private per-session state', () => { const directory = createDirectory(); const sourcePath = path.join(directory, 'source.json'); diff --git a/src/browser/isolation.ts b/src/browser/isolation.ts index ba345aa..9f476bf 100644 --- a/src/browser/isolation.ts +++ b/src/browser/isolation.ts @@ -12,6 +12,7 @@ const INCOMPATIBLE_ENVIRONMENT_KEYS = [ 'AGENT_BROWSER_ENGINE', 'AGENT_BROWSER_EXTENSIONS', 'AGENT_BROWSER_INIT_SCRIPTS', + 'AGENT_BROWSER_PLUGINS', 'AGENT_BROWSER_PROFILE', 'AGENT_BROWSER_PROVIDER', 'AGENT_BROWSER_PROXY', @@ -33,6 +34,7 @@ const INCOMPATIBLE_CONFIG_KEYS = new Set([ 'initscript', 'initscripts', 'profile', + 'plugins', 'provider', 'proxy', 'proxybypass', From c6f31b79d769e788332ec457f851aad98b89aff6 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:28:53 +0000 Subject: [PATCH 35/74] fix(browser): Pin verified executable per session Co-authored-by: Codex --- src/browser/isolation.test.ts | 42 ++++++++++++++++++---- src/browser/isolation.ts | 41 ++++++++++----------- src/commands/exec.test.ts | 19 +++++++--- src/commands/exec.ts | 1 + src/commands/lifecycle.integration.test.ts | 30 +++++++++++++--- src/commands/session.test.ts | 2 ++ src/commands/session.ts | 1 + src/commands/start.test.ts | 12 ++++--- src/commands/start.ts | 10 ++++-- src/commands/stop.ts | 1 + src/session/registry.ts | 1 + src/session/state.ts | 1 + src/utils/exec.test.ts | 39 +++++++++++++++----- src/utils/exec.ts | 15 +++++--- 14 files changed, 160 insertions(+), 55 deletions(-) diff --git a/src/browser/isolation.test.ts b/src/browser/isolation.test.ts index 5ab9efb..89c1835 100644 --- a/src/browser/isolation.test.ts +++ b/src/browser/isolation.test.ts @@ -3,18 +3,24 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ execFileSync: vi.fn() })); +const mocks = vi.hoisted(() => ({ + execFileSync: vi.fn(), + findExecutablePath: vi.fn(), +})); vi.mock('child_process', async (importOriginal) => ({ ...(await importOriginal()), execFileSync: mocks.execFileSync, })); +vi.mock('../utils/process.js', () => ({ + findExecutablePath: mocks.findExecutablePath, +})); import { - assertSupportedAgentBrowserVersion, getIsolatedAgentBrowserEnvironment, loadIsolatedAgentBrowserConfig, parseAgentBrowserVersion, + resolveAgentBrowserRuntime, writeIsolatedAgentBrowserConfig, } from './isolation.js'; @@ -22,6 +28,8 @@ const createdDirectories: string[] = []; beforeEach(() => { mocks.execFileSync.mockReset(); + mocks.findExecutablePath.mockReset(); + mocks.findExecutablePath.mockReturnValue('/opt/node24/bin/agent-browser'); }); afterEach(() => { @@ -100,16 +108,36 @@ describe('agent-browser isolation', () => { expect(parseAgentBrowserVersion('unknown')).toBeNull(); }); - it('requires agent-browser 0.34.0 or newer before launch', () => { + it('resolves one executable and requires exactly agent-browser 0.34.0', () => { mocks.execFileSync.mockReturnValueOnce('agent-browser 0.33.1\n'); - expect(() => assertSupportedAgentBrowserVersion({ PATH: '/usr/bin' })).toThrow( - 'requires agent-browser >=0.34.0', + expect(() => resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toThrow( + 'requires exactly agent-browser 0.34.0', + ); + + mocks.execFileSync.mockReturnValueOnce('agent-browser 0.35.0\n'); + expect(() => resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toThrow( + 'requires exactly agent-browser 0.34.0', ); mocks.execFileSync.mockReturnValueOnce('agent-browser 0.34.0\n'); - expect(assertSupportedAgentBrowserVersion({ PATH: '/usr/bin' })).toBe( - '0.34.0', + expect(resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toEqual({ + executablePath: '/opt/node24/bin/agent-browser', + version: '0.34.0', + }); + expect(mocks.execFileSync).toHaveBeenLastCalledWith( + '/opt/node24/bin/agent-browser', + ['--version'], + expect.objectContaining({ env: { PATH: '/usr/bin' } }), + ); + }); + + it('fails before version probing when agent-browser is unavailable', () => { + mocks.findExecutablePath.mockReturnValue(null); + + expect(() => resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toThrow( + 'agent-browser executable was not found', ); + expect(mocks.execFileSync).not.toHaveBeenCalled(); }); }); diff --git a/src/browser/isolation.ts b/src/browser/isolation.ts index 9f476bf..3c768ba 100644 --- a/src/browser/isolation.ts +++ b/src/browser/isolation.ts @@ -2,8 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; +import { findExecutablePath } from '../utils/process.js'; -const MINIMUM_AGENT_BROWSER_VERSION = [0, 34, 0] as const; +const REQUIRED_AGENT_BROWSER_VERSION = '0.34.0'; const INCOMPATIBLE_ENVIRONMENT_KEYS = [ 'AGENT_BROWSER_ARGS', 'AGENT_BROWSER_AUTO_CONNECT', @@ -89,22 +90,35 @@ export function writeIsolatedAgentBrowserConfig( return configPath; } -export function assertSupportedAgentBrowserVersion( +export type AgentBrowserRuntime = { + executablePath: string; + version: string; +}; + +export function resolveAgentBrowserRuntime( env: NodeJS.ProcessEnv = process.env, -): string { - const output = execFileSync('agent-browser', ['--version'], { +): AgentBrowserRuntime { + const executablePath = findExecutablePath('agent-browser'); + if (!executablePath) { + throw new Error('agent-browser executable was not found on PATH.'); + } + + const output = execFileSync(executablePath, ['--version'], { encoding: 'utf-8', env: getIsolatedAgentBrowserEnvironment(env), stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, }).trim(); const version = parseAgentBrowserVersion(output); - if (!version || compareVersions(version, MINIMUM_AGENT_BROWSER_VERSION) < 0) { + if (!version || version.join('.') !== REQUIRED_AGENT_BROWSER_VERSION) { throw new Error( - `ProofShot requires agent-browser >=0.34.0; received ${output || 'no version output'}.`, + `ProofShot requires exactly agent-browser ${REQUIRED_AGENT_BROWSER_VERSION}; received ${output || 'no version output'}.`, ); } - return version.join('.'); + return { + executablePath, + version: version.join('.'), + }; } export function getIsolatedAgentBrowserEnvironment( @@ -140,16 +154,3 @@ function assertIsolatedAgentBrowserEnvironment(env: NodeJS.ProcessEnv): void { function normalizeConfigKey(key: string): string { return key.toLowerCase().replace(/[-_]/g, ''); } - -function compareVersions( - left: readonly number[], - right: readonly number[], -): number { - for (let index = 0; index < Math.max(left.length, right.length); index += 1) { - const difference = (left[index] || 0) - (right[index] || 0); - if (difference !== 0) { - return difference; - } - } - return 0; -} diff --git a/src/commands/exec.test.ts b/src/commands/exec.test.ts index 313eeb9..6f2ec46 100644 --- a/src/commands/exec.test.ts +++ b/src/commands/exec.test.ts @@ -1,25 +1,36 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setAgentBrowserDefaults } from '../utils/exec.js'; import { buildShellCommand, translateProofShotExecArgs, } from './exec.js'; describe('buildShellCommand', () => { + beforeEach(() => { + setAgentBrowserDefaults({ + executablePath: '/opt/node24/bin/agent-browser', + }); + }); + + afterEach(() => { + setAgentBrowserDefaults({}); + }); + it('routes regular commands through the active ProofShot session', () => { expect(buildShellCommand(['click', '@e2'], 'proofshot-2026-04-07_22-30-00')).toBe( - "agent-browser --session 'proofshot-2026-04-07_22-30-00' click @e2", + "'/opt/node24/bin/agent-browser' --session 'proofshot-2026-04-07_22-30-00' click @e2", ); }); it('preserves eval shell quoting while adding the session flag', () => { expect(buildShellCommand(['eval', "console.log('hello')"], 'proofshot-dev')).toBe( - "agent-browser --session 'proofshot-dev' eval 'console.log('\\''hello'\\'')'", + "'/opt/node24/bin/agent-browser' --session 'proofshot-dev' eval 'console.log('\\''hello'\\'')'", ); }); it('quotes regular arguments that contain shell metacharacters', () => { expect(buildShellCommand(['screenshot', 'step (1).png'], 'proofshot-dev')).toBe( - "agent-browser --session 'proofshot-dev' screenshot 'step (1).png'", + "'/opt/node24/bin/agent-browser' --session 'proofshot-dev' screenshot 'step (1).png'", ); }); diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 81f8c5a..65e3104 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -227,6 +227,7 @@ export async function execCommand( setAgentBrowserDefaults({ allowedDomains: session?.agentBrowserAllowedDomains, configPath: session?.agentBrowserConfigPath || config.browser.configPath, + executablePath: session?.agentBrowserExecutablePath, namespace: session?.agentBrowserNamespace, socketDir: session?.agentBrowserSocketRoot || session?.agentBrowserSocketDir, }); diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index f72cf3b..727134b 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -648,6 +648,25 @@ describe('isolated CLI lifecycle', () => { ); expect(sessions[0].agentBrowserAllowedDomains).toEqual(['example.invalid']); expect(sessions[1].agentBrowserAllowedDomains).toEqual(['example.invalid']); + expect(sessions[0].agentBrowserExecutablePath).toBe( + tools.binDir + path.sep + 'agent-browser', + ); + expect(sessions[1].agentBrowserExecutablePath).toBe( + tools.binDir + path.sep + 'agent-browser', + ); + const decoyBinDir = path.join(base, 'decoy-bin'); + const decoyLog = path.join(base, 'decoy-agent-browser.log'); + fs.mkdirSync(decoyBinDir, { recursive: true }); + const decoyAgentBrowser = path.join(decoyBinDir, 'agent-browser'); + fs.writeFileSync( + decoyAgentBrowser, + `#!/bin/sh\nprintf 'invoked\\n' >> ${shellQuote(decoyLog)}\nexit 88\n`, + ); + fs.chmodSync(decoyAgentBrowser, 0o755); + const postStartEnv = { + ...env, + PATH: `${decoyBinDir}${path.delimiter}${env.PATH}`, + }; const openCalls = fs .readFileSync(tools.browserLog, 'utf-8') .trim() @@ -669,7 +688,7 @@ describe('isolated CLI lifecycle', () => { expect(ambiguousExec.stderr).toContain('Multiple active ProofShot sessions'); for (const session of sessions) { - const explicitExec = runCli(audit, env, [ + const explicitExec = runCli(audit, postStartEnv, [ 'exec', '--session', session.sessionName, @@ -681,7 +700,7 @@ describe('isolated CLI lifecycle', () => { } const secret = 'proofshot-secret-value'; - const secretExec = runCli(audit, env, [ + const secretExec = runCli(audit, postStartEnv, [ 'exec', '--session', sessions[0].sessionName, @@ -715,7 +734,7 @@ describe('isolated CLI lifecycle', () => { ); expect(fs.statSync(privateResultPath).mode & 0o777).toBe(0o600); - const failedExec = runCli(audit, env, [ + const failedExec = runCli(audit, postStartEnv, [ 'exec', '--session', sessions[0].sessionName, @@ -733,7 +752,7 @@ describe('isolated CLI lifecycle', () => { expect(ambiguousStop.status).toBe(1); expect(ambiguousStop.stderr).toContain('Multiple active ProofShot sessions'); - const explicitStop = runCli(audit, env, [ + const explicitStop = runCli(audit, postStartEnv, [ 'stop', '--session', sessions[0].sessionName, @@ -742,11 +761,12 @@ describe('isolated CLI lifecycle', () => { await waitForProcessExit(sessions[0].browserProcess.pid); cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[0].browserProcess), 1); - const implicitStop = runCli(audit, env, ['stop']); + const implicitStop = runCli(audit, postStartEnv, ['stop']); expect(implicitStop.status, `${implicitStop.stdout}\n${implicitStop.stderr}`).toBe(0); await waitForProcessExit(sessions[1].browserProcess.pid); cleanupProcesses.splice(cleanupProcesses.indexOf(sessions[1].browserProcess), 1); expect(fs.readdirSync(registryDir)).toEqual([]); + expect(fs.existsSync(decoyLog)).toBe(false); const browserLogAfterStops = fs.readFileSync(tools.browserLog, 'utf-8'); const execWithoutSession = runCli(audit, env, ['exec', 'get', 'url']); expect(execWithoutSession.status).toBe(1); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index 2c398eb..f88ab08 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -100,6 +100,7 @@ describe('session commands', () => { expect(mocks.setAgentBrowserDefaults).toHaveBeenCalledWith({ allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath, + executablePath: session.agentBrowserExecutablePath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketDir, }); @@ -145,6 +146,7 @@ function buildSession(overrides: Record = {}): any { serverCommand: null, serverAlreadyRunning: true, recordingActive: false, + agentBrowserExecutablePath: '/opt/node24/bin/agent-browser', browserLaunchAttempted: false, browserProcess: null, serverProcess: null, diff --git a/src/commands/session.ts b/src/commands/session.ts index 47b1757..6acb77c 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -80,6 +80,7 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise setAgentBrowserDefaults({ allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath, + executablePath: session.agentBrowserExecutablePath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index f709aa7..c717fd3 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({ captureAgentBrowserProcessIdentity: vi.fn(), cleanupFailedStart: vi.fn(), startOwnedEnvironment: vi.fn(), - assertSupportedAgentBrowserVersion: vi.fn(), + resolveAgentBrowserRuntime: vi.fn(), loadIsolatedAgentBrowserConfig: vi.fn(), writeIsolatedAgentBrowserConfig: vi.fn(), claimSessionOperation: vi.fn(), @@ -73,7 +73,7 @@ vi.mock('../browser/runtime.js', () => ({ })); vi.mock('../browser/isolation.js', () => ({ - assertSupportedAgentBrowserVersion: mocks.assertSupportedAgentBrowserVersion, + resolveAgentBrowserRuntime: mocks.resolveAgentBrowserRuntime, loadIsolatedAgentBrowserConfig: mocks.loadIsolatedAgentBrowserConfig, writeIsolatedAgentBrowserConfig: mocks.writeIsolatedAgentBrowserConfig, })); @@ -156,7 +156,10 @@ describe('startCommand', () => { startTime: '12345', }); mocks.cleanupFailedStart.mockResolvedValue(undefined); - mocks.assertSupportedAgentBrowserVersion.mockReturnValue('0.34.0'); + mocks.resolveAgentBrowserRuntime.mockReturnValue({ + executablePath: '/opt/node24/bin/agent-browser', + version: '0.34.0', + }); mocks.loadIsolatedAgentBrowserConfig.mockReturnValue({}); mocks.writeIsolatedAgentBrowserConfig.mockReturnValue( '/audit/private/agent-browser/config.json', @@ -223,7 +226,7 @@ describe('startCommand', () => { await expect(startCommand({})).rejects.toThrow('process.exit:1'); - expect(mocks.assertSupportedAgentBrowserVersion).not.toHaveBeenCalled(); + expect(mocks.resolveAgentBrowserRuntime).not.toHaveBeenCalled(); expect(mocks.ensureOutputDir).not.toHaveBeenCalled(); expect(mocks.openBrowser).not.toHaveBeenCalled(); expect(mocks.registerSession).not.toHaveBeenCalled(); @@ -281,6 +284,7 @@ describe('startCommand', () => { agentBrowserNamespace: 'psn-deadbeef1234', agentBrowserAllowedDomains: ['127.0.0.1'], agentBrowserConfigPath: '/audit/private/agent-browser/config.json', + agentBrowserExecutablePath: '/opt/node24/bin/agent-browser', agentBrowserVersion: '0.34.0', }); expect(finalState.controlDir).toBe('/project/proofshot-artifacts'); diff --git a/src/commands/start.ts b/src/commands/start.ts index e3b3b36..2ccff42 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -10,8 +10,8 @@ import { } from '../browser/evidence.js'; import { sanitizePageUrl } from '../browser/provenance.js'; import { - assertSupportedAgentBrowserVersion, loadIsolatedAgentBrowserConfig, + resolveAgentBrowserRuntime, writeIsolatedAgentBrowserConfig, } from '../browser/isolation.js'; import { startRecording } from '../browser/capture.js'; @@ -105,6 +105,7 @@ export async function startCommand(options: StartOptions): Promise { allowedDomains: existingSession.agentBrowserAllowedDomains, configPath: existingSession.agentBrowserConfigPath || config.browser.configPath, + executablePath: existingSession.agentBrowserExecutablePath, namespace: existingSession.agentBrowserNamespace, socketDir: existingSession.agentBrowserSocketRoot || @@ -157,6 +158,7 @@ export async function startCommand(options: StartOptions): Promise { let socketRoot: string; let socketDir: string; let browserExecutable: string | null; + let agentBrowserExecutablePath: string; let agentBrowserVersion: string; let isolatedAgentBrowserConfig: Record; @@ -164,7 +166,9 @@ export async function startCommand(options: StartOptions): Promise { isolatedAgentBrowserConfig = loadIsolatedAgentBrowserConfig( config.browser.configPath, ); - agentBrowserVersion = assertSupportedAgentBrowserVersion(); + const agentBrowserRuntime = resolveAgentBrowserRuntime(); + agentBrowserExecutablePath = agentBrowserRuntime.executablePath; + agentBrowserVersion = agentBrowserRuntime.version; socketRoot = prepareAgentBrowserSocketDir( sessionName, process.env, @@ -202,6 +206,7 @@ export async function startCommand(options: StartOptions): Promise { setAgentBrowserDefaults({ allowedDomains: agentBrowserAllowedDomains, configPath: agentBrowserConfigPath, + executablePath: agentBrowserExecutablePath, namespace: agentBrowserNamespace, socketDir: socketRoot, }); @@ -246,6 +251,7 @@ export async function startCommand(options: StartOptions): Promise { agentBrowserNamespace, agentBrowserAllowedDomains, agentBrowserConfigPath, + agentBrowserExecutablePath, agentBrowserVersion, privateEvidenceDir: networkEvidence.privateDirectory, networkHarPath: networkEvidence.harPath, diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 7217035..a0c7be0 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -106,6 +106,7 @@ export async function stopCommand(options: StopOptions): Promise { setAgentBrowserDefaults({ allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath || config.browser.configPath, + executablePath: session.agentBrowserExecutablePath, namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); diff --git a/src/session/registry.ts b/src/session/registry.ts index ac3d73c..957dff9 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -339,6 +339,7 @@ function isSessionState(value: unknown): value is SessionState { isOptionalString(session.agentBrowserNamespace) && isOptionalStringArray(session.agentBrowserAllowedDomains) && isOptionalString(session.agentBrowserConfigPath) && + isOptionalString(session.agentBrowserExecutablePath) && isOptionalString(session.agentBrowserVersion) && isOptionalString(session.privateEvidenceDir) && isOptionalString(session.networkHarPath) && diff --git a/src/session/state.ts b/src/session/state.ts index ca88e6c..6829636 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -46,6 +46,7 @@ export interface SessionState { agentBrowserNamespace?: string; agentBrowserAllowedDomains?: string[]; agentBrowserConfigPath?: string; + agentBrowserExecutablePath?: string; agentBrowserVersion?: string; privateEvidenceDir?: string; networkHarPath?: string; diff --git a/src/utils/exec.test.ts b/src/utils/exec.test.ts index efb902f..f32944c 100644 --- a/src/utils/exec.test.ts +++ b/src/utils/exec.test.ts @@ -1,40 +1,61 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildAgentBrowserCommand, setAgentBrowserDefaults } from './exec.js'; describe('buildAgentBrowserCommand', () => { + beforeEach(() => { + setAgentBrowserDefaults({ + executablePath: '/opt/node24/bin/agent-browser', + }); + }); + afterEach(() => { setAgentBrowserDefaults({}); }); - it('builds a plain agent-browser command when no options are provided', () => { - expect(buildAgentBrowserCommand('open http://localhost:3000')).toBe( - 'agent-browser open http://localhost:3000', + it('refuses commands before an executable is verified', () => { + setAgentBrowserDefaults({}); + + expect(() => buildAgentBrowserCommand('open http://localhost:3000')).toThrow( + 'agent-browser executable path has not been verified', ); }); it('prepends the configured session flag before the command', () => { expect(buildAgentBrowserCommand('snapshot -i', { session: 'proofshot-2026-04-07_22-30-00' })).toBe( - "agent-browser --session 'proofshot-2026-04-07_22-30-00' snapshot -i", + "'/opt/node24/bin/agent-browser' --session 'proofshot-2026-04-07_22-30-00' snapshot -i", ); }); it('shell-quotes session names safely', () => { expect(buildAgentBrowserCommand('console', { session: "proofshot-o'connor" })).toBe( - "agent-browser --session 'proofshot-o'\\''connor' console", + "'/opt/node24/bin/agent-browser' --session 'proofshot-o'\\''connor' console", ); }); it('prepends the configured agent-browser config path before the command', () => { expect(buildAgentBrowserCommand('open http://localhost:3000', { configPath: '/tmp/agent-browser.json' })).toBe( - "agent-browser --config '/tmp/agent-browser.json' open http://localhost:3000", + "'/opt/node24/bin/agent-browser' --config '/tmp/agent-browser.json' open http://localhost:3000", ); }); it('applies default config path options to later commands', () => { - setAgentBrowserDefaults({ configPath: '/tmp/project-agent-browser.json' }); + setAgentBrowserDefaults({ + configPath: '/tmp/project-agent-browser.json', + executablePath: '/opt/node24/bin/agent-browser', + }); + + expect(buildAgentBrowserCommand('snapshot -i')).toBe( + "'/opt/node24/bin/agent-browser' --config '/tmp/project-agent-browser.json' snapshot -i", + ); + }); + + it('executes the exact verified agent-browser path for later commands', () => { + setAgentBrowserDefaults({ + executablePath: '/opt/node 24/bin/agent-browser', + }); expect(buildAgentBrowserCommand('snapshot -i')).toBe( - "agent-browser --config '/tmp/project-agent-browser.json' snapshot -i", + "'/opt/node 24/bin/agent-browser' snapshot -i", ); }); }); diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 96555b0..4c78e7d 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -15,6 +15,7 @@ export class ProofShotError extends Error { export interface AgentBrowserCommandOptions { allowedDomains?: string[]; configPath?: string; + executablePath?: string; json?: boolean; namespace?: string; session?: string; @@ -24,13 +25,13 @@ export interface AgentBrowserCommandOptions { let defaultAgentBrowserOptions: Pick< AgentBrowserCommandOptions, - 'allowedDomains' | 'configPath' | 'namespace' | 'socketDir' + 'allowedDomains' | 'configPath' | 'executablePath' | 'namespace' | 'socketDir' > = {}; export function setAgentBrowserDefaults( options: Pick< AgentBrowserCommandOptions, - 'allowedDomains' | 'configPath' | 'namespace' | 'socketDir' + 'allowedDomains' | 'configPath' | 'executablePath' | 'namespace' | 'socketDir' >, ): void { defaultAgentBrowserOptions = { ...options }; @@ -67,7 +68,7 @@ export function buildAgentBrowserCommand( command: string, options: Pick< AgentBrowserCommandOptions, - 'configPath' | 'json' | 'session' + 'configPath' | 'executablePath' | 'json' | 'session' > = {}, ): string { const mergedOptions = { @@ -81,7 +82,13 @@ export function buildAgentBrowserCommand( ? ` --session ${quoteShellArgument(mergedOptions.session)}` : ''; const jsonFlag = mergedOptions.json ? ' --json' : ''; - return `agent-browser${configFlag}${sessionFlag}${jsonFlag} ${command}`; + if (!mergedOptions.executablePath) { + throw new Error( + 'agent-browser executable path has not been verified for this ProofShot operation.', + ); + } + const executable = quoteShellArgument(mergedOptions.executablePath); + return `${executable}${configFlag}${sessionFlag}${jsonFlag} ${command}`; } /** From 2383d0e58cd5976c1f971d662acac8e94a20e474 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:35:10 +0000 Subject: [PATCH 36/74] fix(evidence): Preserve retryable network capture Co-authored-by: Codex --- src/browser/evidence.test.ts | 89 ++++++++++++++++++++++ src/browser/evidence.ts | 51 ++++++++----- src/commands/lifecycle.integration.test.ts | 69 +++++++++++++++++ src/commands/start.test.ts | 21 +++++ src/commands/start.ts | 3 +- src/commands/stop.test.ts | 75 ++++++++++++++++++ src/commands/stop.ts | 43 ++++++++--- src/session/lifecycle.test.ts | 35 +++++++++ src/session/lifecycle.ts | 53 ++++++++----- 9 files changed, 394 insertions(+), 45 deletions(-) diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index db956a0..82d600d 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -143,6 +143,42 @@ describe('private browser evidence', () => { } }); + it('adopts a valid pending HAR when the stop command reports failure', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-network-stop-error-'), + ); + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + const paths = { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; + mocks.ab.mockImplementation((command: string) => { + if (command.startsWith('network har stop ')) { + fs.writeFileSync( + `${paths.harPath}.pending`, + JSON.stringify({ log: { entries: [] } }), + ); + throw new Error('HAR stop response was lost'); + } + if (command === 'network requests --json') { + return JSON.stringify({ success: true, data: [] }); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + expect(finalizePrivateNetworkCapture('ps-test', paths)).toMatchObject({ + requestCount: 0, + }); + expect(fs.existsSync(paths.harPath)).toBe(true); + expect(fs.existsSync(`${paths.harPath}.pending`)).toBe(false); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); + it('adopts a complete pending HAR after interrupted finalization', () => { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'proofshot-network-recovery-'), @@ -196,6 +232,59 @@ describe('private browser evidence', () => { } }); + it('adopts existing HAR evidence without browser commands after browser loss', () => { + const sessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'proofshot-network-offline-recovery-'), + ); + const privateDirectory = path.join(sessionDir, 'private', 'agent-browser'); + fs.mkdirSync(privateDirectory, { recursive: true }); + const paths = { + privateDirectory, + harPath: path.join(privateDirectory, 'network.har'), + requestsPath: path.join(privateDirectory, 'requests.json'), + summaryPath: path.join(sessionDir, 'network-summary.json'), + }; + fs.writeFileSync( + paths.harPath, + JSON.stringify({ + log: { + entries: [ + { + time: 9, + request: { method: 'GET', url: 'https://example.com/offline' }, + response: { status: 204 }, + }, + ], + }, + }), + ); + fs.writeFileSync(paths.requestsPath, 'retained request evidence\n'); + + try { + const summary = finalizePrivateNetworkCapture('ps-test', paths, { + allowBrowserCommands: false, + }); + + expect(summary.requests).toEqual([ + { + endpoint: 'https://example.com/offline', + method: 'GET', + status: 204, + durationMs: 9, + error: null, + }, + ]); + expect(mocks.ab).not.toHaveBeenCalled(); + expect(fs.readFileSync(paths.requestsPath, 'utf-8')).toBe( + 'retained request evidence\n', + ); + expect(fs.statSync(paths.harPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(paths.summaryPath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + }); + it('refuses an invalid HAR without adopting a partial final file', () => { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'proofshot-network-invalid-'), diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index 9a7fcb9..0ae1462 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -17,6 +17,10 @@ export type PrivateNetworkEvidencePaths = { summaryPath: string; }; +export type FinalizePrivateNetworkCaptureOptions = { + allowBrowserCommands?: boolean; +}; + export type SanitizedNetworkRequest = { endpoint: string; method: string; @@ -51,25 +55,35 @@ export function startPrivateNetworkCapture(sessionName: string): void { export function finalizePrivateNetworkCapture( sessionName: string, paths: PrivateNetworkEvidencePaths, + options: FinalizePrivateNetworkCaptureOptions = {}, ): SanitizedNetworkSummary { preparePrivateDirectory(paths.privateDirectory); - const har = finalizePrivateHarCapture(sessionName, paths.harPath); - let requests: string; - try { - requests = ab('network requests --json', { session: sessionName }); - } catch (error) { - requests = JSON.stringify({ - success: false, - data: null, - error: sanitizeDiagnosticMessage( - error instanceof Error ? error.message : String(error), - ), - }); - } - writePrivateTextFile( - paths.requestsPath, - requests.trim() ? `${requests.trim()}\n` : '{"success":true,"data":[]}\n', + const allowBrowserCommands = options.allowBrowserCommands !== false; + const har = finalizePrivateHarCapture( + sessionName, + paths.harPath, + allowBrowserCommands, ); + if (fs.existsSync(paths.requestsPath)) { + fs.chmodSync(paths.requestsPath, 0o600); + } else if (allowBrowserCommands) { + let requests: string; + try { + requests = ab('network requests --json', { session: sessionName }); + } catch (error) { + requests = JSON.stringify({ + success: false, + data: null, + error: sanitizeDiagnosticMessage( + error instanceof Error ? error.message : String(error), + ), + }); + } + writePrivateTextFile( + paths.requestsPath, + requests.trim() ? `${requests.trim()}\n` : '{"success":true,"data":[]}\n', + ); + } const summary = buildSanitizedNetworkSummary(har); writeJsonFile(paths.summaryPath, summary); return summary; @@ -78,6 +92,7 @@ export function finalizePrivateNetworkCapture( function finalizePrivateHarCapture( sessionName: string, harPath: string, + allowBrowserCommands: boolean, ): unknown { const finalizedHar = readValidHarFile(harPath); if (finalizedHar !== null) { @@ -91,7 +106,9 @@ function finalizePrivateHarCapture( adoptPendingHar(pendingPath, harPath); return pendingHar; } - removeInvalidHarFile(harPath); + if (!allowBrowserCommands) { + throw new Error('No valid local HAR evidence was available for recovery.'); + } removeInvalidHarFile(pendingPath); let stopError: unknown; diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 727134b..5d68863 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -884,6 +884,75 @@ describe('isolated CLI lifecycle', () => { expect(readRegisteredSessions(env)).toEqual([]); }, 30000); + it('adopts pending HAR evidence after the owned browser is lost', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/browser-loss', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + await terminateOwnedProcessTree(session.browserProcess, { graceMs: 300 }); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + + fs.writeFileSync( + `${session.networkHarPath}.pending`, + JSON.stringify({ + log: { + entries: [ + { + time: 7, + request: { + method: 'GET', + url: 'https://example.invalid/recovered-after-loss?token=private', + }, + response: { status: 200 }, + }, + ], + }, + }), + ); + const browserLogBeforeStop = fs.readFileSync(tools.browserLog, 'utf-8'); + + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + expect(fs.existsSync(`${session.networkHarPath}.pending`)).toBe(false); + expect(fs.existsSync(session.networkHarPath)).toBe(true); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBeforeStop); + expect(JSON.parse(fs.readFileSync(session.networkSummaryPath, 'utf-8'))).toEqual({ + version: 1, + requestCount: 1, + requests: [ + { + endpoint: 'https://example.invalid/recovered-after-loss', + method: 'GET', + status: 200, + durationMs: 7, + error: null, + }, + ], + }); + expect(readRegisteredSessions(env)).toEqual([]); + }, 30000); + it('keeps both owned browsers isolated when one exec process is interrupted', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index c717fd3..30d162e 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -259,6 +259,27 @@ describe('startCommand', () => { expect(mocks.unregisterSession).toHaveBeenCalledWith('ps-2026-04-deadbeef1234'); }); + it('persists network cleanup state before starting HAR capture', async () => { + mocks.startPrivateNetworkCapture.mockImplementation(() => { + const persistedState = mocks.registerSession.mock.calls.at(-1)?.[0]; + expect(persistedState).toMatchObject({ + networkCaptureStarted: false, + networkCaptureActive: true, + }); + throw new Error('HAR start response was lost'); + }); + + const commandPromise = startCommand({}).catch((error) => error); + await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); + + expect(mocks.cleanupFailedStart).toHaveBeenCalledWith( + expect.objectContaining({ + networkCaptureStarted: false, + networkCaptureActive: true, + }), + ); + }); + it('persists the intended target and stable control path with custom evidence output', async () => { await startCommand({ output: '/audit/custom-evidence', diff --git a/src/commands/start.ts b/src/commands/start.ts index 2ccff42..0da2fe7 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -329,9 +329,10 @@ export async function startCommand(options: StartOptions): Promise { console.log(chalk.green('✓') + ' Browser ready'); failureContext = 'start private network capture'; + session.networkCaptureActive = true; + persistOwnedSession(session); startPrivateNetworkCapture(sessionName); session.networkCaptureStarted = true; - session.networkCaptureActive = true; persistOwnedSession(session); console.log(chalk.green('✓') + ' Private network capture started'); diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 7b5f899..683c794 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => ({ loadSessionLog: vi.fn(), estimateTokenUsage: vi.fn(), execFileSync: vi.fn(), + finalizePrivateNetworkCapture: vi.fn(), + loadSanitizedNetworkSummary: vi.fn(), })); vi.mock('../utils/config.js', async (importOriginal) => ({ @@ -39,6 +41,10 @@ vi.mock('../session/state.js', () => ({ saveSession: mocks.saveSession, })); vi.mock('../browser/capture.js', () => ({ stopRecording: mocks.stopRecording })); +vi.mock('../browser/evidence.js', () => ({ + finalizePrivateNetworkCapture: mocks.finalizePrivateNetworkCapture, + loadSanitizedNetworkSummary: mocks.loadSanitizedNetworkSummary, +})); vi.mock('../browser/session.js', () => ({ getConsoleErrors: mocks.getConsoleErrors, getConsoleOutput: mocks.getConsoleOutput, @@ -130,6 +136,12 @@ beforeEach(() => { mocks.extractServerErrors.mockReturnValue([]); mocks.loadSessionLog.mockReturnValue([]); mocks.estimateTokenUsage.mockReturnValue(null); + mocks.loadSanitizedNetworkSummary.mockReturnValue(null); + mocks.finalizePrivateNetworkCapture.mockReturnValue({ + version: 1, + requestCount: 0, + requests: [], + }); mocks.execFileSync.mockReturnValue(''); mocks.stopOwnedBrowser.mockResolvedValue(undefined); mocks.stopOwnedServer.mockResolvedValue(undefined); @@ -163,6 +175,69 @@ afterEach(() => { }); describe('stopCommand retryability', () => { + it('retains active network capture and browser ownership when live finalization fails', async () => { + session.privateEvidenceDir = path.join(session.sessionDir, 'private', 'agent-browser'); + session.networkHarPath = path.join(session.privateEvidenceDir, 'network.har'); + session.networkRequestsPath = path.join(session.privateEvidenceDir, 'requests.json'); + session.networkSummaryPath = path.join(session.sessionDir, 'network-summary.json'); + session.networkCaptureStarted = true; + session.networkCaptureActive = true; + mocks.finalizePrivateNetworkCapture.mockImplementation(() => { + throw new Error('HAR finalization failed'); + }); + + await expect(stopCommand({})).rejects.toThrow('HAR finalization failed'); + + expect(session.networkCaptureActive).toBe(true); + expect(session.stoppedAt).toBeUndefined(); + expect(session.networkEvidenceAvailable).toBe(false); + expect(session.networkCaptureError).toContain('HAR finalization failed'); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + expect(mocks.stopOwnedBrowser).not.toHaveBeenCalled(); + expect(mocks.stopOwnedServer).not.toHaveBeenCalled(); + expect(mocks.registerSession).toHaveBeenCalledWith(session); + + mocks.finalizePrivateNetworkCapture.mockReturnValue({ + version: 1, + requestCount: 0, + requests: [], + }); + await expect(stopCommand({})).resolves.toBeUndefined(); + + expect(mocks.finalizePrivateNetworkCapture).toHaveBeenCalledTimes(2); + expect(session.networkCaptureActive).toBe(false); + expect(session.networkEvidenceAvailable).toBe(true); + expect(session.networkCaptureError).toBeNull(); + expect(mocks.stopRecording).toHaveBeenCalledTimes(1); + expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); + }); + + it('adopts local network evidence after browser ownership is lost', async () => { + session.privateEvidenceDir = path.join(session.sessionDir, 'private', 'agent-browser'); + session.networkHarPath = path.join(session.privateEvidenceDir, 'network.har'); + session.networkRequestsPath = path.join(session.privateEvidenceDir, 'requests.json'); + session.networkSummaryPath = path.join(session.sessionDir, 'network-summary.json'); + session.networkCaptureStarted = true; + session.networkCaptureActive = true; + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + + await stopCommand({}); + + expect(mocks.finalizePrivateNetworkCapture).toHaveBeenCalledWith( + session.sessionName, + { + privateDirectory: session.privateEvidenceDir, + harPath: session.networkHarPath, + requestsPath: session.networkRequestsPath, + summaryPath: session.networkSummaryPath, + }, + { allowBrowserCommands: false }, + ); + expect(session.networkCaptureActive).toBe(false); + expect(session.networkEvidenceAvailable).toBe(true); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + }); + it('keeps state after a bundle failure and retries without replacing a valid summary', async () => { fs.writeFileSync(session.videoPath, 'nonempty-original-video'); const sessionLogPath = path.join(session.sessionDir, 'session-log.json'); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index a0c7be0..5317650 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -238,23 +238,34 @@ export async function stopCommand(options: StopOptions): Promise { let networkSummary: SanitizedNetworkSummary | null = loadSanitizedNetworkSummary(session.networkSummaryPath); - if ( - browserSessionAvailable && - session.networkCaptureActive && + const networkEvidencePaths = session.privateEvidenceDir && session.networkHarPath && session.networkRequestsPath && session.networkSummaryPath + ? { + privateDirectory: session.privateEvidenceDir, + harPath: session.networkHarPath, + requestsPath: session.networkRequestsPath, + summaryPath: session.networkSummaryPath, + } + : null; + if ( + !networkSummary && + networkEvidencePaths && + (session.networkCaptureStarted || session.networkCaptureActive) ) { console.log(chalk.dim('Collecting private network evidence...')); + const allowBrowserCommands = + browserSessionAvailable && session.networkCaptureActive === true; try { - networkSummary = finalizePrivateNetworkCapture(session.sessionName, { - privateDirectory: session.privateEvidenceDir, - harPath: session.networkHarPath, - requestsPath: session.networkRequestsPath, - summaryPath: session.networkSummaryPath, - }); + networkSummary = finalizePrivateNetworkCapture( + session.sessionName, + networkEvidencePaths, + { allowBrowserCommands }, + ); session.networkEvidenceAvailable = true; + session.networkCaptureActive = false; session.networkCaptureError = null; } catch (error) { session.networkEvidenceAvailable = false; @@ -266,10 +277,22 @@ export async function stopCommand(options: StopOptions): Promise { chalk.yellow('⚠') + ` Private network evidence was incomplete: ${session.networkCaptureError}`, ); + if (allowBrowserCommands) { + session.lifecycleStatus = 'active'; + session.stoppedAt = undefined; + session.networkCaptureActive = true; + persistOwnedSession(session); + throw error; + } + session.networkCaptureActive = false; } + persistOwnedSession(session); + } else if (networkSummary) { + session.networkEvidenceAvailable = true; session.networkCaptureActive = false; + session.networkCaptureError = null; persistOwnedSession(session); - } else if (session.networkCaptureStarted && !networkSummary) { + } else if (session.networkCaptureStarted || session.networkCaptureActive) { session.networkEvidenceAvailable = false; session.networkCaptureActive = false; persistOwnedSession(session); diff --git a/src/session/lifecycle.test.ts b/src/session/lifecycle.test.ts index 3571e2c..4b2588a 100644 --- a/src/session/lifecycle.test.ts +++ b/src/session/lifecycle.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ processIdentitiesMatch: vi.fn(), processIdentityMatches: vi.fn(), terminateOwnedProcessTree: vi.fn(), + finalizePrivateNetworkCapture: vi.fn(), })); vi.mock('../browser/runtime.js', () => ({ @@ -20,6 +21,9 @@ vi.mock('../browser/runtime.js', () => ({ })); vi.mock('../browser/session.js', () => ({ closeBrowser: mocks.closeBrowser })); vi.mock('../browser/capture.js', () => ({ stopRecording: mocks.stopRecording })); +vi.mock('../browser/evidence.js', () => ({ + finalizePrivateNetworkCapture: mocks.finalizePrivateNetworkCapture, +})); vi.mock('../utils/process.js', () => ({ captureProcessIdentity: mocks.captureProcessIdentity, ownedProcessTreeIsAlive: mocks.ownedProcessTreeIsAlive, @@ -59,6 +63,11 @@ beforeEach(() => { mocks.ownedProcessTreeIsAlive.mockReturnValue(false); mocks.terminateOwnedProcessTree.mockResolvedValue(true); mocks.waitForAgentBrowserProcessIdentity.mockResolvedValue(null); + mocks.finalizePrivateNetworkCapture.mockReturnValue({ + version: 1, + requestCount: 0, + requests: [], + }); }); describe('owned browser lifecycle', () => { @@ -116,6 +125,32 @@ describe('owned browser lifecycle', () => { expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith(persistedIdentity); }); + it('retains failed network finalization for an exact cleanup retry', async () => { + const state = { + ...session(), + networkCaptureStarted: false, + networkCaptureActive: true, + privateEvidenceDir: '/evidence/private/agent-browser', + networkHarPath: '/evidence/private/agent-browser/network.har', + networkRequestsPath: '/evidence/private/agent-browser/requests.json', + networkSummaryPath: '/evidence/network-summary.json', + }; + mocks.finalizePrivateNetworkCapture.mockImplementationOnce(() => { + throw new Error('HAR finalization failed'); + }); + + await expect(cleanupFailedStart(state)).rejects.toThrow( + 'HAR finalization failed', + ); + + expect(state.networkCaptureActive).toBe(true); + expect(state.networkEvidenceAvailable).toBe(false); + expect(state.networkCaptureError).toContain('HAR finalization failed'); + expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith( + persistedIdentity, + ); + }); + it('falls back to exact termination when graceful browser close fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); mocks.closeBrowser.mockImplementationOnce(() => { diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts index ecb52fb..55cdf59 100644 --- a/src/session/lifecycle.ts +++ b/src/session/lifecycle.ts @@ -1,5 +1,6 @@ import { stopRecording } from '../browser/capture.js'; import { finalizePrivateNetworkCapture } from '../browser/evidence.js'; +import { sanitizeDiagnosticMessage } from '../browser/provenance.js'; import { stopOwnedEnvironment } from '../environment/runtime.js'; import { clearAgentBrowserSessionFiles, @@ -123,29 +124,47 @@ export async function cleanupFailedStart(session: SessionState): Promise { ); } - // Recording may have started even when its CLI call returned an error. Both - // operations are session-scoped and best effort. Never address a session - // name unless its daemon still has the identity captured by this start. - if (canAddressOwnedBrowserSession(session)) { - if ( - session.networkCaptureActive && - session.privateEvidenceDir && - session.networkHarPath && - session.networkRequestsPath && - session.networkSummaryPath - ) { - try { - finalizePrivateNetworkCapture(session.sessionName, { + const browserSessionAddressable = canAddressOwnedBrowserSession(session); + if ( + (session.networkCaptureStarted || session.networkCaptureActive) && + session.privateEvidenceDir && + session.networkHarPath && + session.networkRequestsPath && + session.networkSummaryPath + ) { + const allowBrowserCommands = + browserSessionAddressable && session.networkCaptureActive === true; + try { + finalizePrivateNetworkCapture( + session.sessionName, + { privateDirectory: session.privateEvidenceDir, harPath: session.networkHarPath, requestsPath: session.networkRequestsPath, summaryPath: session.networkSummaryPath, - }); - } catch { - // Failed-start cleanup must still close exact owned processes. - } + }, + { allowBrowserCommands }, + ); + session.networkEvidenceAvailable = true; session.networkCaptureActive = false; + session.networkCaptureError = null; + } catch (error) { + session.networkEvidenceAvailable = false; + session.networkCaptureError = + sanitizeDiagnosticMessage( + error instanceof Error ? error.message : String(error), + ) || 'network capture failed'; + session.networkCaptureActive = allowBrowserCommands; + if (allowBrowserCommands) { + cleanupError ||= error; + } } + } + + // Recording may have started even when its CLI call returned an error. + // Never address a session name unless its daemon still has the identity + // captured by this start. + if (browserSessionAddressable) { stopRecording(session.sessionName); } if (session.browserProcess || !session.browserLaunchAttempted) { From b5bdb480eb047badca15600e690152d020d4bf80 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:52:45 +0000 Subject: [PATCH 37/74] fix(browser): Anchor exact runtime verification Co-authored-by: Codex --- src/browser/isolation.test.ts | 21 +++++++++++++++++++++ src/browser/isolation.ts | 12 +++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/browser/isolation.test.ts b/src/browser/isolation.test.ts index 89c1835..4d6fff7 100644 --- a/src/browser/isolation.test.ts +++ b/src/browser/isolation.test.ts @@ -119,6 +119,11 @@ describe('agent-browser isolation', () => { 'requires exactly agent-browser 0.34.0', ); + mocks.execFileSync.mockReturnValueOnce('agent-browser 0.34.0-beta.1\n'); + expect(() => resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toThrow( + 'requires exactly agent-browser 0.34.0', + ); + mocks.execFileSync.mockReturnValueOnce('agent-browser 0.34.0\n'); expect(resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toEqual({ executablePath: '/opt/node24/bin/agent-browser', @@ -131,6 +136,22 @@ describe('agent-browser isolation', () => { ); }); + it('normalizes a discovered executable to an absolute path before probing it', () => { + mocks.findExecutablePath.mockReturnValue('./tools/agent-browser'); + mocks.execFileSync.mockReturnValueOnce('0.34.0\n'); + const executablePath = path.resolve('./tools/agent-browser'); + + expect(resolveAgentBrowserRuntime({ PATH: './tools' })).toEqual({ + executablePath, + version: '0.34.0', + }); + expect(mocks.execFileSync).toHaveBeenCalledWith( + executablePath, + ['--version'], + expect.any(Object), + ); + }); + it('fails before version probing when agent-browser is unavailable', () => { mocks.findExecutablePath.mockReturnValue(null); diff --git a/src/browser/isolation.ts b/src/browser/isolation.ts index 3c768ba..f0d61be 100644 --- a/src/browser/isolation.ts +++ b/src/browser/isolation.ts @@ -5,6 +5,7 @@ import { execFileSync } from 'child_process'; import { findExecutablePath } from '../utils/process.js'; const REQUIRED_AGENT_BROWSER_VERSION = '0.34.0'; +const REQUIRED_AGENT_BROWSER_VERSION_OUTPUT = /^(?:agent-browser\s+)?0\.34\.0$/i; const INCOMPATIBLE_ENVIRONMENT_KEYS = [ 'AGENT_BROWSER_ARGS', 'AGENT_BROWSER_AUTO_CONNECT', @@ -98,10 +99,11 @@ export type AgentBrowserRuntime = { export function resolveAgentBrowserRuntime( env: NodeJS.ProcessEnv = process.env, ): AgentBrowserRuntime { - const executablePath = findExecutablePath('agent-browser'); - if (!executablePath) { + const discoveredExecutablePath = findExecutablePath('agent-browser'); + if (!discoveredExecutablePath) { throw new Error('agent-browser executable was not found on PATH.'); } + const executablePath = path.resolve(discoveredExecutablePath); const output = execFileSync(executablePath, ['--version'], { encoding: 'utf-8', @@ -110,7 +112,11 @@ export function resolveAgentBrowserRuntime( timeout: 5000, }).trim(); const version = parseAgentBrowserVersion(output); - if (!version || version.join('.') !== REQUIRED_AGENT_BROWSER_VERSION) { + if ( + !version || + version.join('.') !== REQUIRED_AGENT_BROWSER_VERSION || + !REQUIRED_AGENT_BROWSER_VERSION_OUTPUT.test(output) + ) { throw new Error( `ProofShot requires exactly agent-browser ${REQUIRED_AGENT_BROWSER_VERSION}; received ${output || 'no version output'}.`, ); From ff3a2ffd24c6b8ac20c8993122d640610beab368 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:52:45 +0000 Subject: [PATCH 38/74] fix(browser): Redact inline secret flags Co-authored-by: Codex --- src/browser/provenance.test.ts | 7 +++++++ src/browser/provenance.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/browser/provenance.test.ts b/src/browser/provenance.test.ts index 12ef89b..5cbff75 100644 --- a/src/browser/provenance.test.ts +++ b/src/browser/provenance.test.ts @@ -112,6 +112,13 @@ describe('browser interaction provenance', () => { '{"Authorization":"Bearer private-header-token"}', ]).summary, ).toBe('open https://api.example.com/ --headers [REDACTED]'); + expect( + buildSanitizedCommandIntent([ + 'open', + 'https://api.example.com', + '--headers={"Authorization":"Bearer private-inline-token"}', + ]).summary, + ).toBe('open https://api.example.com/ --headers=[REDACTED]'); expect( buildSanitizedCommandIntent([ 'keyboard', diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts index 7fc0491..00563d2 100644 --- a/src/browser/provenance.ts +++ b/src/browser/provenance.ts @@ -319,6 +319,15 @@ function sanitizeArguments(command: string, args: string[]): string[] { redactNext = false; continue; } + const equalsIndex = argument.indexOf('='); + if ( + index > 0 && + equalsIndex > 0 && + SECRET_FLAG.test(argument.slice(0, equalsIndex)) + ) { + sanitized.push(`${argument.slice(0, equalsIndex)}=${REDACTED}`); + continue; + } if (index > 0 && SECRET_FLAG.test(argument)) { sanitized.push(argument); redactNext = true; From e8b3aa49e653e0680bb5e7910108ef03d3e50ebf Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:54:21 +0000 Subject: [PATCH 39/74] fix(evidence): Retain delayed HAR recovery Co-authored-by: Codex --- src/commands/stop.test.ts | 77 +++++++++++++++++++++++++++++++++++ src/commands/stop.ts | 14 ++++--- src/session/lifecycle.test.ts | 27 ++++++++++++ src/session/lifecycle.ts | 8 ++-- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 683c794..625cadc 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -238,6 +238,83 @@ describe('stopCommand retryability', () => { expect(mocks.stopRecording).not.toHaveBeenCalled(); }); + it('retains a stopping session when browser ownership is lost during HAR finalization', async () => { + session.privateEvidenceDir = path.join(session.sessionDir, 'private', 'agent-browser'); + session.networkHarPath = path.join(session.privateEvidenceDir, 'network.har'); + session.networkRequestsPath = path.join(session.privateEvidenceDir, 'requests.json'); + session.networkSummaryPath = path.join(session.sessionDir, 'network-summary.json'); + session.networkCaptureStarted = true; + session.networkCaptureActive = true; + mocks.canAddressOwnedBrowserSession + .mockReturnValueOnce(true) + .mockReturnValue(false); + mocks.finalizePrivateNetworkCapture.mockImplementationOnce(() => { + throw new Error('browser disappeared before HAR flush'); + }); + + await expect(stopCommand({})).rejects.toThrow( + 'browser disappeared before HAR flush', + ); + + expect(session.lifecycleStatus).toBe('stopping'); + expect(session.networkCaptureActive).toBe(true); + expect(session.stoppedAt).toEqual(expect.any(String)); + expect(mocks.stopOwnedBrowser).not.toHaveBeenCalled(); + + mocks.finalizePrivateNetworkCapture.mockReturnValue({ + version: 1, + requestCount: 0, + requests: [], + }); + await stopCommand({}); + + expect(mocks.finalizePrivateNetworkCapture).toHaveBeenLastCalledWith( + session.sessionName, + expect.any(Object), + { allowBrowserCommands: false }, + ); + expect(session.networkEvidenceAvailable).toBe(true); + expect(session.networkCaptureActive).toBe(false); + }); + + it('retains offline network recovery until delayed HAR evidence is available', async () => { + session.privateEvidenceDir = path.join(session.sessionDir, 'private', 'agent-browser'); + session.networkHarPath = path.join(session.privateEvidenceDir, 'network.har'); + session.networkRequestsPath = path.join(session.privateEvidenceDir, 'requests.json'); + session.networkSummaryPath = path.join(session.sessionDir, 'network-summary.json'); + session.networkCaptureStarted = true; + session.networkCaptureActive = true; + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + mocks.finalizePrivateNetworkCapture.mockImplementationOnce(() => { + throw new Error('pending HAR is not complete yet'); + }); + + await expect(stopCommand({})).rejects.toThrow( + 'pending HAR is not complete yet', + ); + + expect(session.lifecycleStatus).toBe('stopping'); + expect(session.networkCaptureActive).toBe(true); + expect(session.bundleComplete).toBe(false); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + expect(mocks.stopOwnedBrowser).not.toHaveBeenCalled(); + + mocks.finalizePrivateNetworkCapture.mockReturnValue({ + version: 1, + requestCount: 0, + requests: [], + }); + await stopCommand({}); + + expect(mocks.finalizePrivateNetworkCapture).toHaveBeenLastCalledWith( + session.sessionName, + expect.any(Object), + { allowBrowserCommands: false }, + ); + expect(session.networkEvidenceAvailable).toBe(true); + expect(session.networkCaptureActive).toBe(false); + }); + it('keeps state after a bundle failure and retries without replacing a valid summary', async () => { fs.writeFileSync(session.videoPath, 'nonempty-original-video'); const sessionLogPath = path.join(session.sessionDir, 'session-log.json'); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 5317650..e5c6a80 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -277,14 +277,18 @@ export async function stopCommand(options: StopOptions): Promise { chalk.yellow('⚠') + ` Private network evidence was incomplete: ${session.networkCaptureError}`, ); - if (allowBrowserCommands) { + const browserSessionStillAvailable = + allowBrowserCommands && canAddressOwnedBrowserSession(session); + if (browserSessionStillAvailable) { session.lifecycleStatus = 'active'; session.stoppedAt = undefined; - session.networkCaptureActive = true; - persistOwnedSession(session); - throw error; } - session.networkCaptureActive = false; + // A browser can disappear while the HAR stop command is flushing its + // pending file. Keep the session retryable so a later stop can adopt + // that local evidence without addressing the lost browser. + session.networkCaptureActive = true; + persistOwnedSession(session); + throw error; } persistOwnedSession(session); } else if (networkSummary) { diff --git a/src/session/lifecycle.test.ts b/src/session/lifecycle.test.ts index 4b2588a..ddf019e 100644 --- a/src/session/lifecycle.test.ts +++ b/src/session/lifecycle.test.ts @@ -151,6 +151,33 @@ describe('owned browser lifecycle', () => { ); }); + it('retains offline network finalization for delayed local recovery', async () => { + mocks.captureAgentBrowserProcessIdentity.mockReturnValue(null); + const state = { + ...session(null), + browserLaunchAttempted: false, + networkCaptureStarted: true, + networkCaptureActive: true, + privateEvidenceDir: '/evidence/private/agent-browser', + networkHarPath: '/evidence/private/agent-browser/network.har', + networkRequestsPath: '/evidence/private/agent-browser/requests.json', + networkSummaryPath: '/evidence/network-summary.json', + }; + mocks.finalizePrivateNetworkCapture.mockImplementationOnce(() => { + throw new Error('pending HAR is not complete yet'); + }); + + await expect(cleanupFailedStart(state)).rejects.toThrow( + 'pending HAR is not complete yet', + ); + + expect(state.networkCaptureActive).toBe(true); + expect(state.networkEvidenceAvailable).toBe(false); + expect(state.networkCaptureError).toContain('pending HAR is not complete yet'); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith(null); + }); + it('falls back to exact termination when graceful browser close fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); mocks.closeBrowser.mockImplementationOnce(() => { diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts index 55cdf59..36d0b41 100644 --- a/src/session/lifecycle.ts +++ b/src/session/lifecycle.ts @@ -154,17 +154,15 @@ export async function cleanupFailedStart(session: SessionState): Promise { sanitizeDiagnosticMessage( error instanceof Error ? error.message : String(error), ) || 'network capture failed'; - session.networkCaptureActive = allowBrowserCommands; - if (allowBrowserCommands) { - cleanupError ||= error; - } + session.networkCaptureActive = true; + cleanupError ||= error; } } // Recording may have started even when its CLI call returned an error. // Never address a session name unless its daemon still has the identity // captured by this start. - if (browserSessionAddressable) { + if (canAddressOwnedBrowserSession(session)) { stopRecording(session.sessionName); } if (session.browserProcess || !session.browserLaunchAttempted) { From 119a86551a4ac395b81e1d04ccadc5ac34c3cc63 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:57:11 +0000 Subject: [PATCH 40/74] fix(browser): Backfill legacy runtime pins Co-authored-by: Codex --- src/commands/exec.ts | 19 ++++--- src/commands/lifecycle.integration.test.ts | 61 ++++++++++++++++++++++ src/commands/session.test.ts | 33 ++++++++++++ src/commands/session.ts | 18 ++++--- src/commands/start.ts | 24 +++++---- src/commands/stop.test.ts | 6 +++ src/commands/stop.ts | 9 ++-- src/session/browser-runtime.test.ts | 45 ++++++++++++++++ src/session/browser-runtime.ts | 22 ++++++++ 9 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 src/session/browser-runtime.test.ts create mode 100644 src/session/browser-runtime.ts diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 65e3104..fc71cdf 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -20,6 +20,7 @@ import { import { resolveSessionControlDir, } from '../session/state.js'; +import { backfillSessionAgentBrowserRuntime } from '../session/browser-runtime.js'; import { canAddressOwnedBrowserSession } from '../session/lifecycle.js'; import { getPageUrl } from '../browser/session.js'; import { @@ -224,14 +225,6 @@ export async function execCommand( operation: 'exec', sessionName: options.session, }); - setAgentBrowserDefaults({ - allowedDomains: session?.agentBrowserAllowedDomains, - configPath: session?.agentBrowserConfigPath || config.browser.configPath, - executablePath: session?.agentBrowserExecutablePath, - namespace: session?.agentBrowserNamespace, - socketDir: session?.agentBrowserSocketRoot || session?.agentBrowserSocketDir, - }); - if (!session) { console.error( 'Error: No active ProofShot session matches this worktree.\n' + @@ -260,6 +253,16 @@ export async function execCommand( const execLease = claimSessionOperation(session, 'exec'); try { + if (backfillSessionAgentBrowserRuntime(session)) { + registerSession(session); + } + setAgentBrowserDefaults({ + allowedDomains: session.agentBrowserAllowedDomains, + configPath: session.agentBrowserConfigPath || config.browser.configPath, + executablePath: session.agentBrowserExecutablePath, + namespace: session.agentBrowserNamespace, + socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, + }); const pageUrlBefore = capturePageUrl(session.sessionName); // Resolve args (screenshot path rewriting) let resolvedArgs = translated.agentBrowserArgs; diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 5d68863..100ca3f 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -607,6 +607,67 @@ describe('isolated CLI lifecycle', () => { ); }, 30000); + it('backfills an exact runtime for legacy session state', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/legacy-runtime', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + const registryPath = path.join( + registryDirectory(env), + `${session.sessionName}.json`, + ); + delete session.agentBrowserExecutablePath; + delete session.agentBrowserVersion; + fs.writeFileSync(registryPath, JSON.stringify(session, null, 2) + '\n'); + + const exec = runCli(audit, env, [ + 'exec', + '--session', + session.sessionName, + 'get', + 'url', + ]); + + expect(exec.status, `${exec.stdout}\n${exec.stderr}`).toBe(0); + expect(exec.stdout).toContain('https://example.invalid/legacy-runtime'); + const [backfilledSession] = readRegisteredSessions(env); + expect(backfilledSession.agentBrowserExecutablePath).toBe( + path.join(tools.binDir, 'agent-browser'), + ); + expect(backfilledSession.agentBrowserVersion).toBe('0.34.0'); + + delete backfilledSession.agentBrowserExecutablePath; + delete backfilledSession.agentBrowserVersion; + fs.writeFileSync( + registryPath, + JSON.stringify(backfilledSession, null, 2) + '\n', + ); + + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + }, 30000); + it('isolates two sessions and requires an exact target only while ambiguous', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index f88ab08..13389c2 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ loadSession: vi.fn(), saveSession: vi.fn(), setAgentBrowserDefaults: vi.fn(), + backfillSessionAgentBrowserRuntime: vi.fn(), ownedProcessTreeIsAlive: vi.fn(), })); @@ -36,6 +37,10 @@ vi.mock('../session/state.js', () => ({ vi.mock('../utils/exec.js', () => ({ setAgentBrowserDefaults: mocks.setAgentBrowserDefaults, })); +vi.mock('../session/browser-runtime.js', () => ({ + backfillSessionAgentBrowserRuntime: + mocks.backfillSessionAgentBrowserRuntime, +})); vi.mock('../utils/process.js', () => ({ ownedProcessTreeIsAlive: mocks.ownedProcessTreeIsAlive, })); @@ -67,6 +72,7 @@ beforeEach(() => { }); mocks.hasActiveSession.mockReturnValue(true); mocks.ownedProcessTreeIsAlive.mockReturnValue(false); + mocks.backfillSessionAgentBrowserRuntime.mockReturnValue(false); }); afterEach(() => { @@ -119,6 +125,33 @@ describe('session commands', () => { expect(mocks.unregisterSession).not.toHaveBeenCalled(); }); + it('persists an exact runtime before cleaning a legacy session', async () => { + const session = buildSession({ agentBrowserExecutablePath: undefined }); + mocks.getRegisteredSession.mockReturnValue(session); + mocks.backfillSessionAgentBrowserRuntime.mockImplementation( + (legacySession) => { + legacySession.agentBrowserExecutablePath = + '/opt/node24/bin/agent-browser'; + legacySession.agentBrowserVersion = '0.34.0'; + return true; + }, + ); + + await sessionCleanCommand({ session: session.sessionName }); + + expect(mocks.registerSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentBrowserExecutablePath: '/opt/node24/bin/agent-browser', + agentBrowserVersion: '0.34.0', + }), + ); + expect(mocks.setAgentBrowserDefaults).toHaveBeenCalledWith( + expect.objectContaining({ + executablePath: '/opt/node24/bin/agent-browser', + }), + ); + }); + it('does not unregister another concurrent session', async () => { const session = buildSession({ lifecycleStatus: 'recovery' }); mocks.getRegisteredSession.mockReturnValue(session); diff --git a/src/commands/session.ts b/src/commands/session.ts index 6acb77c..01fce3d 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -10,6 +10,7 @@ import { unregisterSession, } from '../session/registry.js'; import { sessionHasVerifiedLiveOwnership } from '../session/selection.js'; +import { backfillSessionAgentBrowserRuntime } from '../session/browser-runtime.js'; import type { SessionState } from '../session/state.js'; type SessionStatus = 'active' | 'starting' | 'recovery' | 'stale'; @@ -77,14 +78,17 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise ); continue; } - setAgentBrowserDefaults({ - allowedDomains: session.agentBrowserAllowedDomains, - configPath: session.agentBrowserConfigPath, - executablePath: session.agentBrowserExecutablePath, - namespace: session.agentBrowserNamespace, - socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, - }); try { + if (backfillSessionAgentBrowserRuntime(session)) { + registerSession(session); + } + setAgentBrowserDefaults({ + allowedDomains: session.agentBrowserAllowedDomains, + configPath: session.agentBrowserConfigPath, + executablePath: session.agentBrowserExecutablePath, + namespace: session.agentBrowserNamespace, + socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, + }); await cleanupFailedStart(session); releaseSessionOperation(session, recoveryLease); unregisterSession(session.sessionName); diff --git a/src/commands/start.ts b/src/commands/start.ts index 0da2fe7..6c3d13d 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -28,6 +28,7 @@ import { resolveSessionControlDir, type SessionState, } from '../session/state.js'; +import { backfillSessionAgentBrowserRuntime } from '../session/browser-runtime.js'; import { cleanupFailedStart } from '../session/lifecycle.js'; import { claimSessionOperation, @@ -101,17 +102,20 @@ export async function startCommand(options: StartOptions): Promise { process.exit(1); return; } - setAgentBrowserDefaults({ - allowedDomains: existingSession.agentBrowserAllowedDomains, - configPath: - existingSession.agentBrowserConfigPath || config.browser.configPath, - executablePath: existingSession.agentBrowserExecutablePath, - namespace: existingSession.agentBrowserNamespace, - socketDir: - existingSession.agentBrowserSocketRoot || - existingSession.agentBrowserSocketDir, - }); try { + if (backfillSessionAgentBrowserRuntime(existingSession)) { + registerSession(existingSession); + } + setAgentBrowserDefaults({ + allowedDomains: existingSession.agentBrowserAllowedDomains, + configPath: + existingSession.agentBrowserConfigPath || config.browser.configPath, + executablePath: existingSession.agentBrowserExecutablePath, + namespace: existingSession.agentBrowserNamespace, + socketDir: + existingSession.agentBrowserSocketRoot || + existingSession.agentBrowserSocketDir, + }); await cleanupFailedStart(existingSession); releaseSessionOperation(existingSession, recoveryLease); unregisterSession(existingSession.sessionName); diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 625cadc..bba5084 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ execFileSync: vi.fn(), finalizePrivateNetworkCapture: vi.fn(), loadSanitizedNetworkSummary: vi.fn(), + backfillSessionAgentBrowserRuntime: vi.fn(), })); vi.mock('../utils/config.js', async (importOriginal) => ({ @@ -64,6 +65,10 @@ vi.mock('../session/registry.js', () => ({ vi.mock('../session/selection.js', () => ({ resolveLiveSession: mocks.loadSession, })); +vi.mock('../session/browser-runtime.js', () => ({ + backfillSessionAgentBrowserRuntime: + mocks.backfillSessionAgentBrowserRuntime, +})); vi.mock('../environment/runtime.js', () => ({ stopOwnedEnvironment: mocks.stopOwnedEnvironment, })); @@ -147,6 +152,7 @@ beforeEach(() => { mocks.stopOwnedServer.mockResolvedValue(undefined); mocks.stopOwnedEnvironment.mockResolvedValue(undefined); mocks.canAddressOwnedBrowserSession.mockReturnValue(true); + mocks.backfillSessionAgentBrowserRuntime.mockReturnValue(false); mocks.claimSessionOperation.mockImplementation((claimedSession) => { const lease = { id: 'stop-lease', diff --git a/src/commands/stop.ts b/src/commands/stop.ts index e5c6a80..061c5c9 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -17,6 +17,7 @@ import { resolveSessionControlDir, type SessionState, } from '../session/state.js'; +import { backfillSessionAgentBrowserRuntime } from '../session/browser-runtime.js'; import { canAddressOwnedBrowserSession, stopOwnedBrowser, @@ -103,6 +104,11 @@ export async function stopCommand(options: StopOptions): Promise { ); return; } + const stopLease = claimSessionOperation(session, 'stop'); + try { + if (backfillSessionAgentBrowserRuntime(session)) { + persistOwnedSession(session); + } setAgentBrowserDefaults({ allowedDomains: session.agentBrowserAllowedDomains, configPath: session.agentBrowserConfigPath || config.browser.configPath, @@ -110,9 +116,6 @@ export async function stopCommand(options: StopOptions): Promise { namespace: session.agentBrowserNamespace, socketDir: session.agentBrowserSocketRoot || session.agentBrowserSocketDir, }); - - const stopLease = claimSessionOperation(session, 'stop'); - try { if (session.bundleComplete) { if (session.browserRetained && !options.noClose) { console.log(chalk.dim('Closing retained browser...')); diff --git a/src/session/browser-runtime.test.ts b/src/session/browser-runtime.test.ts new file mode 100644 index 0000000..be38b4d --- /dev/null +++ b/src/session/browser-runtime.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + resolveAgentBrowserRuntime: vi.fn(), +})); + +vi.mock('../browser/isolation.js', () => ({ + resolveAgentBrowserRuntime: mocks.resolveAgentBrowserRuntime, +})); + +import { backfillSessionAgentBrowserRuntime } from './browser-runtime.js'; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveAgentBrowserRuntime.mockReturnValue({ + executablePath: '/opt/node24/bin/agent-browser', + version: '0.34.0', + }); +}); + +describe('session agent-browser runtime', () => { + it('persists an exact runtime for a legacy session', () => { + const session = {} as Parameters[0]; + + expect(backfillSessionAgentBrowserRuntime(session)).toBe(true); + + expect(session.agentBrowserExecutablePath).toBe( + '/opt/node24/bin/agent-browser', + ); + expect(session.agentBrowserVersion).toBe('0.34.0'); + }); + + it('preserves the runtime already pinned to a session', () => { + const session = { + agentBrowserExecutablePath: '/verified/bin/agent-browser', + agentBrowserVersion: '0.34.0', + } as Parameters[0]; + + expect(backfillSessionAgentBrowserRuntime(session)).toBe(false); + expect(mocks.resolveAgentBrowserRuntime).not.toHaveBeenCalled(); + expect(session.agentBrowserExecutablePath).toBe( + '/verified/bin/agent-browser', + ); + }); +}); diff --git a/src/session/browser-runtime.ts b/src/session/browser-runtime.ts new file mode 100644 index 0000000..cb9cf10 --- /dev/null +++ b/src/session/browser-runtime.ts @@ -0,0 +1,22 @@ +import { resolveAgentBrowserRuntime } from '../browser/isolation.js'; +import type { SessionState } from './state.js'; + +/** + * Older registry records predate per-session executable pinning. Resolve the + * supported runtime once and persist it before issuing another browser command. + */ +export function backfillSessionAgentBrowserRuntime( + session: SessionState, +): boolean { + if ( + typeof session.agentBrowserExecutablePath === 'string' && + session.agentBrowserExecutablePath.length > 0 + ) { + return false; + } + + const runtime = resolveAgentBrowserRuntime(); + session.agentBrowserExecutablePath = runtime.executablePath; + session.agentBrowserVersion = runtime.version; + return true; +} From 81c72c31dd36cd251899658eed22a447d8e85e8b Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:15:15 +0000 Subject: [PATCH 41/74] fix(browser): Classify default find clicks Co-authored-by: Codex --- src/browser/provenance.test.ts | 6 ++++++ src/browser/provenance.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/browser/provenance.test.ts b/src/browser/provenance.test.ts index 5cbff75..0cc299d 100644 --- a/src/browser/provenance.test.ts +++ b/src/browser/provenance.test.ts @@ -33,6 +33,12 @@ describe('browser interaction provenance', () => { expect( classifyInteraction(['find', 'nth', '2', 'a', 'click']), ).toBe('pointer-keyboard'); + expect(classifyInteraction(['find', 'text', 'Submit'])).toBe( + 'pointer-keyboard', + ); + expect(classifyInteraction(['find', 'nth', '2', '.card'])).toBe( + 'pointer-keyboard', + ); expect(classifyInteraction(['keyboard', 'inserttext', 'private'])).toBe( 'hybrid', ); diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts index 00563d2..cf604e1 100644 --- a/src/browser/provenance.ts +++ b/src/browser/provenance.ts @@ -84,7 +84,7 @@ function classifyInteractionAtDepth( const nestedAction = args[findActionIndex(args)]?.toLowerCase(); return nestedAction ? classifyInteractionAtDepth([nestedAction], batchDepth) - : 'observation'; + : 'pointer-keyboard'; } if (command === 'keyboard') { const keyboardAction = args[1]?.toLowerCase(); From a22354b69c9f81b94927889d962f3043076b90ab Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:17:25 +0000 Subject: [PATCH 42/74] fix(browser): Reject containment overrides Co-authored-by: Codex --- src/browser/provenance.ts | 12 ++-- src/commands/exec.test.ts | 60 ++++++++++++++++ src/commands/exec.ts | 84 ++++++++++++++++++++++ src/commands/lifecycle.integration.test.ts | 52 ++++++++++++++ 4 files changed, 204 insertions(+), 4 deletions(-) diff --git a/src/browser/provenance.ts b/src/browser/provenance.ts index cf604e1..e715ecd 100644 --- a/src/browser/provenance.ts +++ b/src/browser/provenance.ts @@ -134,10 +134,7 @@ function classifyBatchInteraction( return 'synthetic-dom'; } - const nestedCommands = args - .slice(1) - .filter((argument) => !['--bail', '--json'].includes(argument)) - .flatMap(parseBatchArgument); + const nestedCommands = parseAgentBrowserBatchCommands(args); if (nestedCommands.length === 0) { return 'unknown'; } @@ -153,6 +150,13 @@ function classifyBatchInteraction( return uniqueCategories.size === 1 ? categories[0] : 'unknown'; } +export function parseAgentBrowserBatchCommands(args: string[]): string[][] { + return args + .slice(1) + .filter((argument) => !['--bail', '--json'].includes(argument)) + .flatMap(parseBatchArgument); +} + function parseBatchArgument(value: string): string[][] { let parsed: unknown; try { diff --git a/src/commands/exec.test.ts b/src/commands/exec.test.ts index 6f2ec46..721b32c 100644 --- a/src/commands/exec.test.ts +++ b/src/commands/exec.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { + assertControlledAgentBrowserCommand, buildShellCommand, translateProofShotExecArgs, } from './exec.js'; @@ -42,4 +43,63 @@ describe('buildShellCommand', () => { expectedSelector: '#ready', }); }); + + it.each([ + ['snapshot', '--session=another-session'], + ['open', 'https://example.com', '--allowed-domains=attacker.invalid'], + ['open', 'https://example.com', '--provider', 'browserbase'], + ['open', 'https://example.com', '--cdp=9222'], + ['open', 'https://example.com', '--profile', './shared-profile'], + ['open', 'https://example.com', '--state=./shared-state.json'], + ['open', 'https://example.com', '--config', './untrusted.json'], + ['open', 'https://example.com', '--executable-path=/tmp/browser'], + ['open', 'https://example.com', '--namespace=shared'], + ])('rejects ProofShot-owned global options: %j', (...args) => { + expect(() => assertControlledAgentBrowserCommand(args)).toThrow( + 'owned by ProofShot', + ); + }); + + it('rejects commands that can escape the owned browser lifecycle', () => { + expect(() => assertControlledAgentBrowserCommand(['connect', '9222'])).toThrow( + 'cannot override ProofShot-owned browser state', + ); + expect(() => + assertControlledAgentBrowserCommand(['state', 'load', './shared.json']), + ).toThrow('cannot override ProofShot-owned browser state'); + expect(() => + assertControlledAgentBrowserCommand(['close', '--all']), + ).toThrow('cannot target other ProofShot sessions'); + expect(() => + assertControlledAgentBrowserCommand([ + 'batch', + 'snapshot -i', + 'open https://example.com --provider browserbase', + ]), + ).toThrow('--provider is owned by ProofShot'); + }); + + it('preserves legitimate command-specific options', () => { + expect(() => + assertControlledAgentBrowserCommand(['snapshot', '-i', '--depth', '3']), + ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand([ + 'find', + 'role', + 'button', + 'click', + '--name', + 'Submit', + ]), + ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand([ + 'open', + 'https://example.com', + '--headers', + '{"X-Test":"proof"}', + ]), + ).not.toThrow(); + }); }); diff --git a/src/commands/exec.ts b/src/commands/exec.ts index fc71cdf..9ef0031 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -7,6 +7,7 @@ import { import { buildSanitizedCommandIntent, classifyInteraction, + parseAgentBrowserBatchCommands, sanitizeDiagnosticMessage, sanitizePageUrl, } from '../browser/provenance.js'; @@ -39,6 +40,40 @@ type ExecOptions = { session?: string; }; +const MAX_BATCH_DEPTH = 8; +const RESERVED_AGENT_BROWSER_FLAGS = new Set([ + '--action-policy', + '--allow-file-access', + '--allowed-domains', + '--args', + '--auto-connect', + '--cdp', + '--config', + '--confirm-actions', + '--confirm-interactive', + '--device', + '--download-path', + '--enable', + '--engine', + '--executable-path', + '--extension', + '--headed', + '--ignore-https-errors', + '--init-script', + '--namespace', + '--no-auto-dialog', + '--profile', + '--provider', + '--proxy', + '--proxy-bypass', + '--session', + '--session-name', + '--socket-dir', + '--state', + '--user-agent', + '-p', +]); + /** * For screenshot commands, resolve relative paths into the session directory * so agents can just say `proofshot exec screenshot step-name.png`. @@ -68,6 +103,7 @@ export function buildShellCommand( sessionName?: string, structuredOutput = false, ): string { + assertControlledAgentBrowserCommand(args); if (args[0] === 'eval' && args.length > 1) { const jsCode = args.slice(1).join(' '); const escaped = jsCode.replace(/'/g, "'\\''"); @@ -90,6 +126,53 @@ export function buildShellCommand( }); } +export function assertControlledAgentBrowserCommand(args: string[]): void { + assertControlledAgentBrowserCommandAtDepth(args, 0); +} + +function assertControlledAgentBrowserCommandAtDepth( + args: string[], + batchDepth: number, +): void { + const command = args[0]?.toLowerCase(); + if (command === 'connect' || command === 'state') { + throw new Error( + `agent-browser ${command} cannot override ProofShot-owned browser state.`, + ); + } + if ( + command === 'close' && + args.some((argument) => normalizeFlag(argument) === '--all') + ) { + throw new Error( + 'agent-browser close --all cannot target other ProofShot sessions.', + ); + } + + const reservedFlag = args + .map(normalizeFlag) + .find((argument) => RESERVED_AGENT_BROWSER_FLAGS.has(argument)); + if (reservedFlag) { + throw new Error( + `${reservedFlag} is owned by ProofShot and cannot be passed through proofshot exec.`, + ); + } + + if (command !== 'batch') { + return; + } + if (batchDepth >= MAX_BATCH_DEPTH) { + throw new Error('Nested agent-browser batch commands exceed the safe depth.'); + } + for (const nestedArgs of parseAgentBrowserBatchCommands(args)) { + assertControlledAgentBrowserCommandAtDepth(nestedArgs, batchDepth + 1); + } +} + +function normalizeFlag(argument: string): string { + return argument.toLowerCase().split('=', 1)[0]; +} + export function translateProofShotExecArgs(args: string[]): { agentBrowserArgs: string[]; expectedSelector?: string; @@ -211,6 +294,7 @@ export async function execCommand( args: string[], options: ExecOptions = {}, ): Promise { + assertControlledAgentBrowserCommand(args); const intent = buildSanitizedCommandIntent(args); const action = intent.summary; const translated = translateProofShotExecArgs(args); diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 100ca3f..e3102cc 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -668,6 +668,58 @@ describe('isolated CLI lifecycle', () => { cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); }, 30000); + it('rejects browser containment overrides before invoking agent-browser', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/contained', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); + + const attacks = [ + ['snapshot', '--session=another-session'], + ['snapshot', '--allowed-domains=attacker.invalid'], + ['open', 'https://attacker.invalid', '--provider=browserbase'], + ['connect', '9222'], + ['close', '--all'], + ]; + for (const attack of attacks) { + const result = runCli(audit, env, [ + 'exec', + '--session', + session.sessionName, + '--', + ...attack, + ]); + expect(result.status, `${result.stdout}\n${result.stderr}`).not.toBe(0); + } + + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBefore); + + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + }, 30000); + it('isolates two sessions and requires an exact target only while ambiguous', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); From 6634997ef0b1af13c64c89527c420675351dc7be Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:25:29 +0000 Subject: [PATCH 43/74] fix(browser): Pin runtime file identity per session Co-authored-by: Codex --- src/browser/isolation.test.ts | 63 ++++++++++++++++++--- src/browser/isolation.ts | 34 ++++++++++- src/commands/lifecycle.integration.test.ts | 66 ++++++++++++++++++++++ src/commands/start.test.ts | 2 + src/commands/start.ts | 3 + src/session/browser-runtime.test.ts | 48 +++++++++++++++- src/session/browser-runtime.ts | 35 ++++++++++-- src/session/registry.ts | 1 + src/session/state.ts | 1 + 9 files changed, 237 insertions(+), 16 deletions(-) diff --git a/src/browser/isolation.test.ts b/src/browser/isolation.test.ts index 4d6fff7..6c786a9 100644 --- a/src/browser/isolation.test.ts +++ b/src/browser/isolation.test.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { createHash } from 'crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -17,6 +18,7 @@ vi.mock('../utils/process.js', () => ({ })); import { + assertAgentBrowserRuntime, getIsolatedAgentBrowserEnvironment, loadIsolatedAgentBrowserConfig, parseAgentBrowserVersion, @@ -25,11 +27,16 @@ import { } from './isolation.js'; const createdDirectories: string[] = []; +const RUNTIME_SOURCE = '#!/usr/bin/env node\n'; +let runtimeExecutablePath: string; beforeEach(() => { mocks.execFileSync.mockReset(); mocks.findExecutablePath.mockReset(); - mocks.findExecutablePath.mockReturnValue('/opt/node24/bin/agent-browser'); + const directory = createDirectory(); + runtimeExecutablePath = path.join(directory, 'agent-browser'); + fs.writeFileSync(runtimeExecutablePath, RUNTIME_SOURCE, { mode: 0o755 }); + mocks.findExecutablePath.mockReturnValue(runtimeExecutablePath); }); afterEach(() => { @@ -126,27 +133,32 @@ describe('agent-browser isolation', () => { mocks.execFileSync.mockReturnValueOnce('agent-browser 0.34.0\n'); expect(resolveAgentBrowserRuntime({ PATH: '/usr/bin' })).toEqual({ - executablePath: '/opt/node24/bin/agent-browser', + executablePath: runtimeExecutablePath, + sha256: createHash('sha256').update(RUNTIME_SOURCE).digest('hex'), version: '0.34.0', }); expect(mocks.execFileSync).toHaveBeenLastCalledWith( - '/opt/node24/bin/agent-browser', + runtimeExecutablePath, ['--version'], expect.objectContaining({ env: { PATH: '/usr/bin' } }), ); }); it('normalizes a discovered executable to an absolute path before probing it', () => { - mocks.findExecutablePath.mockReturnValue('./tools/agent-browser'); + const relativeExecutablePath = path.relative( + process.cwd(), + runtimeExecutablePath, + ); + mocks.findExecutablePath.mockReturnValue(relativeExecutablePath); mocks.execFileSync.mockReturnValueOnce('0.34.0\n'); - const executablePath = path.resolve('./tools/agent-browser'); expect(resolveAgentBrowserRuntime({ PATH: './tools' })).toEqual({ - executablePath, + executablePath: runtimeExecutablePath, + sha256: createHash('sha256').update(RUNTIME_SOURCE).digest('hex'), version: '0.34.0', }); expect(mocks.execFileSync).toHaveBeenCalledWith( - executablePath, + runtimeExecutablePath, ['--version'], expect.any(Object), ); @@ -160,6 +172,43 @@ describe('agent-browser isolation', () => { ); expect(mocks.execFileSync).not.toHaveBeenCalled(); }); + + it('rejects same-path executable replacement after a session starts', () => { + mocks.execFileSync.mockReturnValue('agent-browser 0.34.0\n'); + const runtime = resolveAgentBrowserRuntime({ PATH: '/usr/bin' }); + fs.writeFileSync(runtimeExecutablePath, `${RUNTIME_SOURCE}// replaced\n`); + + expect(() => + assertAgentBrowserRuntime(runtime, { PATH: '/usr/bin' }), + ).toThrow('executable changed'); + }); + + it('pins the canonical executable when its discovery symlink is retargeted', () => { + const directory = createDirectory(); + const firstTarget = path.join(directory, 'agent-browser-first'); + const secondTarget = path.join(directory, 'agent-browser-second'); + const symlinkPath = path.join(directory, 'agent-browser'); + fs.writeFileSync(firstTarget, RUNTIME_SOURCE, { mode: 0o755 }); + fs.writeFileSync(secondTarget, `${RUNTIME_SOURCE}// second\n`, { + mode: 0o755, + }); + fs.symlinkSync(firstTarget, symlinkPath); + mocks.findExecutablePath.mockReturnValue(symlinkPath); + mocks.execFileSync.mockReturnValue('agent-browser 0.34.0\n'); + const runtime = resolveAgentBrowserRuntime({ PATH: '/usr/bin' }); + + fs.unlinkSync(symlinkPath); + fs.symlinkSync(secondTarget, symlinkPath); + + expect(() => + assertAgentBrowserRuntime(runtime, { PATH: '/usr/bin' }), + ).not.toThrow(); + expect(mocks.execFileSync).toHaveBeenLastCalledWith( + firstTarget, + ['--version'], + expect.any(Object), + ); + }); }); function createDirectory(): string { diff --git a/src/browser/isolation.ts b/src/browser/isolation.ts index f0d61be..0637edb 100644 --- a/src/browser/isolation.ts +++ b/src/browser/isolation.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; import { findExecutablePath } from '../utils/process.js'; @@ -93,6 +93,7 @@ export function writeIsolatedAgentBrowserConfig( export type AgentBrowserRuntime = { executablePath: string; + sha256: string; version: string; }; @@ -103,7 +104,35 @@ export function resolveAgentBrowserRuntime( if (!discoveredExecutablePath) { throw new Error('agent-browser executable was not found on PATH.'); } - const executablePath = path.resolve(discoveredExecutablePath); + return resolveAgentBrowserRuntimeAtPath(discoveredExecutablePath, env); +} + +export function assertAgentBrowserRuntime( + expected: AgentBrowserRuntime, + env: NodeJS.ProcessEnv = process.env, +): void { + const actual = resolveAgentBrowserRuntimeAtPath(expected.executablePath, env); + if ( + actual.executablePath !== expected.executablePath || + actual.version !== expected.version || + actual.sha256 !== expected.sha256 + ) { + throw new Error( + 'The pinned agent-browser executable changed after this ProofShot session started.', + ); + } +} + +export function resolveAgentBrowserRuntimeAtPath( + discoveredExecutablePath: string, + env: NodeJS.ProcessEnv = process.env, +): AgentBrowserRuntime { + let executablePath: string; + try { + executablePath = fs.realpathSync(path.resolve(discoveredExecutablePath)); + } catch { + throw new Error('The pinned agent-browser executable is no longer available.'); + } const output = execFileSync(executablePath, ['--version'], { encoding: 'utf-8', @@ -123,6 +152,7 @@ export function resolveAgentBrowserRuntime( } return { executablePath, + sha256: createHash('sha256').update(fs.readFileSync(executablePath)).digest('hex'), version: version.join('.'), }; } diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index e3102cc..f28b697 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; +import { createHash } from 'crypto'; import { execFileSync, spawn, @@ -632,6 +633,7 @@ describe('isolated CLI lifecycle', () => { `${session.sessionName}.json`, ); delete session.agentBrowserExecutablePath; + delete session.agentBrowserExecutableSha256; delete session.agentBrowserVersion; fs.writeFileSync(registryPath, JSON.stringify(session, null, 2) + '\n'); @@ -649,9 +651,15 @@ describe('isolated CLI lifecycle', () => { expect(backfilledSession.agentBrowserExecutablePath).toBe( path.join(tools.binDir, 'agent-browser'), ); + expect(backfilledSession.agentBrowserExecutableSha256).toBe( + createHash('sha256') + .update(fs.readFileSync(path.join(tools.binDir, 'agent-browser'))) + .digest('hex'), + ); expect(backfilledSession.agentBrowserVersion).toBe('0.34.0'); delete backfilledSession.agentBrowserExecutablePath; + delete backfilledSession.agentBrowserExecutableSha256; delete backfilledSession.agentBrowserVersion; fs.writeFileSync( registryPath, @@ -720,6 +728,64 @@ describe('isolated CLI lifecycle', () => { cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); }, 30000); + it('refuses a same-path runtime replacement until the pinned file is restored', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + const agentBrowserPath = path.join(tools.binDir, 'agent-browser'); + const originalExecutable = fs.readFileSync(agentBrowserPath); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/runtime-pin', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + cleanupProcesses.push(session.browserProcess); + const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); + + fs.appendFileSync(agentBrowserPath, '\n// same-path replacement\n'); + + const rejectedExec = runCli(audit, env, [ + 'exec', + '--session', + session.sessionName, + 'get', + 'url', + ]); + expect(rejectedExec.status).not.toBe(0); + expect(`${rejectedExec.stdout}\n${rejectedExec.stderr}`).toContain( + 'pinned agent-browser executable changed', + ); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBefore); + + const rejectedStop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(rejectedStop.status).not.toBe(0); + expect(readRegisteredSessions(env)).toHaveLength(1); + + fs.writeFileSync(agentBrowserPath, originalExecutable); + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(session.browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); + }, 30000); + it('isolates two sessions and requires an exact target only while ambiguous', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 30d162e..6548089 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -158,6 +158,7 @@ describe('startCommand', () => { mocks.cleanupFailedStart.mockResolvedValue(undefined); mocks.resolveAgentBrowserRuntime.mockReturnValue({ executablePath: '/opt/node24/bin/agent-browser', + sha256: 'a'.repeat(64), version: '0.34.0', }); mocks.loadIsolatedAgentBrowserConfig.mockReturnValue({}); @@ -306,6 +307,7 @@ describe('startCommand', () => { agentBrowserAllowedDomains: ['127.0.0.1'], agentBrowserConfigPath: '/audit/private/agent-browser/config.json', agentBrowserExecutablePath: '/opt/node24/bin/agent-browser', + agentBrowserExecutableSha256: 'a'.repeat(64), agentBrowserVersion: '0.34.0', }); expect(finalState.controlDir).toBe('/project/proofshot-artifacts'); diff --git a/src/commands/start.ts b/src/commands/start.ts index 6c3d13d..555e0a3 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -163,6 +163,7 @@ export async function startCommand(options: StartOptions): Promise { let socketDir: string; let browserExecutable: string | null; let agentBrowserExecutablePath: string; + let agentBrowserExecutableSha256: string; let agentBrowserVersion: string; let isolatedAgentBrowserConfig: Record; @@ -172,6 +173,7 @@ export async function startCommand(options: StartOptions): Promise { ); const agentBrowserRuntime = resolveAgentBrowserRuntime(); agentBrowserExecutablePath = agentBrowserRuntime.executablePath; + agentBrowserExecutableSha256 = agentBrowserRuntime.sha256; agentBrowserVersion = agentBrowserRuntime.version; socketRoot = prepareAgentBrowserSocketDir( sessionName, @@ -256,6 +258,7 @@ export async function startCommand(options: StartOptions): Promise { agentBrowserAllowedDomains, agentBrowserConfigPath, agentBrowserExecutablePath, + agentBrowserExecutableSha256, agentBrowserVersion, privateEvidenceDir: networkEvidence.privateDirectory, networkHarPath: networkEvidence.harPath, diff --git a/src/session/browser-runtime.test.ts b/src/session/browser-runtime.test.ts index be38b4d..426563a 100644 --- a/src/session/browser-runtime.test.ts +++ b/src/session/browser-runtime.test.ts @@ -1,21 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + assertAgentBrowserRuntime: vi.fn(), resolveAgentBrowserRuntime: vi.fn(), + resolveAgentBrowserRuntimeAtPath: vi.fn(), })); vi.mock('../browser/isolation.js', () => ({ + assertAgentBrowserRuntime: mocks.assertAgentBrowserRuntime, resolveAgentBrowserRuntime: mocks.resolveAgentBrowserRuntime, + resolveAgentBrowserRuntimeAtPath: mocks.resolveAgentBrowserRuntimeAtPath, })); import { backfillSessionAgentBrowserRuntime } from './browser-runtime.js'; beforeEach(() => { vi.clearAllMocks(); - mocks.resolveAgentBrowserRuntime.mockReturnValue({ + const runtime = { executablePath: '/opt/node24/bin/agent-browser', + sha256: 'a'.repeat(64), version: '0.34.0', - }); + }; + mocks.resolveAgentBrowserRuntime.mockReturnValue(runtime); + mocks.resolveAgentBrowserRuntimeAtPath.mockReturnValue(runtime); }); describe('session agent-browser runtime', () => { @@ -27,19 +34,56 @@ describe('session agent-browser runtime', () => { expect(session.agentBrowserExecutablePath).toBe( '/opt/node24/bin/agent-browser', ); + expect(session.agentBrowserExecutableSha256).toBe('a'.repeat(64)); expect(session.agentBrowserVersion).toBe('0.34.0'); }); it('preserves the runtime already pinned to a session', () => { const session = { agentBrowserExecutablePath: '/verified/bin/agent-browser', + agentBrowserExecutableSha256: 'b'.repeat(64), agentBrowserVersion: '0.34.0', } as Parameters[0]; expect(backfillSessionAgentBrowserRuntime(session)).toBe(false); expect(mocks.resolveAgentBrowserRuntime).not.toHaveBeenCalled(); + expect(mocks.assertAgentBrowserRuntime).toHaveBeenCalledWith({ + executablePath: '/verified/bin/agent-browser', + sha256: 'b'.repeat(64), + version: '0.34.0', + }); expect(session.agentBrowserExecutablePath).toBe( '/verified/bin/agent-browser', ); }); + + it('anchors a legacy path to its current exact file identity', () => { + const session = { + agentBrowserExecutablePath: '/opt/node24/bin/agent-browser', + agentBrowserVersion: '0.34.0', + } as Parameters[0]; + + expect(backfillSessionAgentBrowserRuntime(session)).toBe(true); + + expect(mocks.resolveAgentBrowserRuntimeAtPath).toHaveBeenCalledWith( + '/opt/node24/bin/agent-browser', + process.env, + ); + expect(session.agentBrowserExecutableSha256).toBe('a'.repeat(64)); + }); + + it('refuses a replaced executable before a later browser command', () => { + const session = { + agentBrowserExecutablePath: '/verified/bin/agent-browser', + agentBrowserExecutableSha256: 'b'.repeat(64), + agentBrowserVersion: '0.34.0', + } as Parameters[0]; + mocks.assertAgentBrowserRuntime.mockImplementation(() => { + throw new Error('pinned runtime changed'); + }); + + expect(() => backfillSessionAgentBrowserRuntime(session)).toThrow( + 'pinned runtime changed', + ); + }); }); diff --git a/src/session/browser-runtime.ts b/src/session/browser-runtime.ts index cb9cf10..5a99d03 100644 --- a/src/session/browser-runtime.ts +++ b/src/session/browser-runtime.ts @@ -1,4 +1,9 @@ -import { resolveAgentBrowserRuntime } from '../browser/isolation.js'; +import { + assertAgentBrowserRuntime, + resolveAgentBrowserRuntime, + resolveAgentBrowserRuntimeAtPath, + type AgentBrowserRuntime, +} from '../browser/isolation.js'; import type { SessionState } from './state.js'; /** @@ -8,15 +13,35 @@ import type { SessionState } from './state.js'; export function backfillSessionAgentBrowserRuntime( session: SessionState, ): boolean { + const executablePath = session.agentBrowserExecutablePath; + if (typeof executablePath !== 'string' || executablePath.length === 0) { + return persistRuntime(session, resolveAgentBrowserRuntime()); + } + if ( - typeof session.agentBrowserExecutablePath === 'string' && - session.agentBrowserExecutablePath.length > 0 + typeof session.agentBrowserExecutableSha256 !== 'string' || + typeof session.agentBrowserVersion !== 'string' ) { - return false; + return persistRuntime( + session, + resolveAgentBrowserRuntimeAtPath(executablePath, process.env), + ); } - const runtime = resolveAgentBrowserRuntime(); + assertAgentBrowserRuntime({ + executablePath, + sha256: session.agentBrowserExecutableSha256, + version: session.agentBrowserVersion, + }); + return false; +} + +function persistRuntime( + session: SessionState, + runtime: AgentBrowserRuntime, +): true { session.agentBrowserExecutablePath = runtime.executablePath; + session.agentBrowserExecutableSha256 = runtime.sha256; session.agentBrowserVersion = runtime.version; return true; } diff --git a/src/session/registry.ts b/src/session/registry.ts index 957dff9..2e18873 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -340,6 +340,7 @@ function isSessionState(value: unknown): value is SessionState { isOptionalStringArray(session.agentBrowserAllowedDomains) && isOptionalString(session.agentBrowserConfigPath) && isOptionalString(session.agentBrowserExecutablePath) && + isOptionalString(session.agentBrowserExecutableSha256) && isOptionalString(session.agentBrowserVersion) && isOptionalString(session.privateEvidenceDir) && isOptionalString(session.networkHarPath) && diff --git a/src/session/state.ts b/src/session/state.ts index 6829636..3ff7511 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -47,6 +47,7 @@ export interface SessionState { agentBrowserAllowedDomains?: string[]; agentBrowserConfigPath?: string; agentBrowserExecutablePath?: string; + agentBrowserExecutableSha256?: string; agentBrowserVersion?: string; privateEvidenceDir?: string; networkHarPath?: string; From 5929f48a21d7e7db422bfd06589ed2fe3510b4e8 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:25:39 +0000 Subject: [PATCH 44/74] fix(evidence): Finalize unavailable stale network capture Co-authored-by: Codex --- src/session/lifecycle.test.ts | 25 +++++++++++++++++++++++++ src/session/lifecycle.ts | 11 +++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/session/lifecycle.test.ts b/src/session/lifecycle.test.ts index ddf019e..09c112c 100644 --- a/src/session/lifecycle.test.ts +++ b/src/session/lifecycle.test.ts @@ -178,6 +178,31 @@ describe('owned browser lifecycle', () => { expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith(null); }); + it('marks absent network evidence unavailable after the recorded browser tree is gone', async () => { + mocks.processIdentityMatches.mockReturnValue(false); + const state = { + ...session(), + networkCaptureStarted: true, + networkCaptureActive: true, + privateEvidenceDir: '/evidence/private/agent-browser', + networkHarPath: '/evidence/private/agent-browser/network.har', + networkRequestsPath: '/evidence/private/agent-browser/requests.json', + networkSummaryPath: '/evidence/network-summary.json', + }; + mocks.finalizePrivateNetworkCapture.mockImplementationOnce(() => { + throw new Error('no local HAR remains'); + }); + + await cleanupFailedStart(state); + + expect(state.networkCaptureActive).toBe(false); + expect(state.networkEvidenceAvailable).toBe(false); + expect(state.networkCaptureError).toContain('no local HAR remains'); + expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith( + persistedIdentity, + ); + }); + it('falls back to exact termination when graceful browser close fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); mocks.closeBrowser.mockImplementationOnce(() => { diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts index 36d0b41..354b261 100644 --- a/src/session/lifecycle.ts +++ b/src/session/lifecycle.ts @@ -154,8 +154,15 @@ export async function cleanupFailedStart(session: SessionState): Promise { sanitizeDiagnosticMessage( error instanceof Error ? error.message : String(error), ) || 'network capture failed'; - session.networkCaptureActive = true; - cleanupError ||= error; + const recordedBrowserIsGone = Boolean( + !allowBrowserCommands && + session.browserProcess && + !ownedProcessTreeIsAlive(session.browserProcess), + ); + session.networkCaptureActive = !recordedBrowserIsGone; + if (!recordedBrowserIsGone) { + cleanupError ||= error; + } } } From 46c0cd2a263305cfe85b6f49835b3ba884df8bf4 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:27:34 +0000 Subject: [PATCH 45/74] fix(browser): Reserve proof capture lifecycle commands Co-authored-by: Codex --- src/commands/exec.test.ts | 24 ++++++++++++++++-------- src/commands/exec.ts | 15 +++++++++++---- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/commands/exec.test.ts b/src/commands/exec.test.ts index 721b32c..bc53439 100644 --- a/src/commands/exec.test.ts +++ b/src/commands/exec.test.ts @@ -61,15 +61,20 @@ describe('buildShellCommand', () => { }); it('rejects commands that can escape the owned browser lifecycle', () => { - expect(() => assertControlledAgentBrowserCommand(['connect', '9222'])).toThrow( - 'cannot override ProofShot-owned browser state', - ); - expect(() => - assertControlledAgentBrowserCommand(['state', 'load', './shared.json']), - ).toThrow('cannot override ProofShot-owned browser state'); + for (const args of [ + ['auth', 'login', 'shared'], + ['close'], + ['connect', '9222'], + ['record', 'stop'], + ['state', 'load', './shared.json'], + ]) { + expect(() => assertControlledAgentBrowserCommand(args)).toThrow( + 'cannot override ProofShot-owned browser state', + ); + } expect(() => - assertControlledAgentBrowserCommand(['close', '--all']), - ).toThrow('cannot target other ProofShot sessions'); + assertControlledAgentBrowserCommand(['network', 'har', 'stop']), + ).toThrow('HAR capture is owned by ProofShot'); expect(() => assertControlledAgentBrowserCommand([ 'batch', @@ -101,5 +106,8 @@ describe('buildShellCommand', () => { '{"X-Test":"proof"}', ]), ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand(['network', 'requests']), + ).not.toThrow(); }); }); diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 9ef0031..a9f1164 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -41,6 +41,13 @@ type ExecOptions = { }; const MAX_BATCH_DEPTH = 8; +const PROOFSHOT_OWNED_COMMANDS = new Set([ + 'auth', + 'close', + 'connect', + 'record', + 'state', +]); const RESERVED_AGENT_BROWSER_FLAGS = new Set([ '--action-policy', '--allow-file-access', @@ -135,17 +142,17 @@ function assertControlledAgentBrowserCommandAtDepth( batchDepth: number, ): void { const command = args[0]?.toLowerCase(); - if (command === 'connect' || command === 'state') { + if (command && PROOFSHOT_OWNED_COMMANDS.has(command)) { throw new Error( `agent-browser ${command} cannot override ProofShot-owned browser state.`, ); } if ( - command === 'close' && - args.some((argument) => normalizeFlag(argument) === '--all') + command === 'network' && + args[1]?.toLowerCase() === 'har' ) { throw new Error( - 'agent-browser close --all cannot target other ProofShot sessions.', + 'agent-browser network HAR capture is owned by ProofShot.', ); } From 0436bb2740ac69cc93a1e40a53ad2d4f1165d1ef Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:51:32 +0000 Subject: [PATCH 46/74] fix(browser): Fail closed on unsafe commands Pin ProofShot exec to the reviewed agent-browser 0.34.0 interaction surface and keep screenshot output inside the active evidence session. Co-authored-by: Codex --- src/browser/command-policy.ts | 299 ++++++++++++++++++++++++++++++++++ src/commands/exec.test.ts | 93 ++++++++++- src/commands/exec.ts | 120 ++------------ 3 files changed, 402 insertions(+), 110 deletions(-) create mode 100644 src/browser/command-policy.ts diff --git a/src/browser/command-policy.ts b/src/browser/command-policy.ts new file mode 100644 index 0000000..5420fc0 --- /dev/null +++ b/src/browser/command-policy.ts @@ -0,0 +1,299 @@ +import * as path from 'path'; +import { parseAgentBrowserBatchCommands } from './provenance.js'; + +// The runtime pin makes this a closed 0.34.0 contract. Re-audit the complete +// command surface before changing the version or adding an entry here. +const ALLOWED_COMMANDS = new Set([ + 'back', + 'batch', + 'check', + 'click', + 'console', + 'cookies', + 'dblclick', + 'drag', + 'errors', + 'eval', + 'fill', + 'find', + 'focus', + 'forward', + 'get', + 'goto', + 'hover', + 'is', + 'key', + 'keyboard', + 'keydown', + 'keyup', + 'mouse', + 'navigate', + 'network', + 'open', + 'press', + 'reload', + 'screenshot', + 'scroll', + 'scrollinto', + 'scrollintoview', + 'select', + 'set', + 'snapshot', + 'storage', + 'tab', + 'type', + 'uncheck', + 'upload', + 'wait', +]); +const PROOFSHOT_OWNED_COMMANDS = new Set([ + 'auth', + 'close', + 'connect', + 'record', + 'state', +]); +const RESERVED_AGENT_BROWSER_FLAGS = new Set([ + '--action-policy', + '--allow-file-access', + '--allowed-domains', + '--args', + '--auto-connect', + '--cdp', + '--config', + '--confirm-actions', + '--confirm-interactive', + '--device', + '--download-path', + '--enable', + '--engine', + '--executable-path', + '--extension', + '--headed', + '--ignore-https-errors', + '--init-script', + '--namespace', + '--no-auto-dialog', + '--profile', + '--provider', + '--proxy', + '--proxy-bypass', + '--session', + '--session-name', + '--socket-dir', + '--state', + '--user-agent', + '-p', +]); +const ALLOWED_GET_SUBCOMMANDS = new Set([ + 'attr', + 'box', + 'count', + 'html', + 'styles', + 'text', + 'title', + 'url', + 'value', +]); +const ALLOWED_COOKIE_OPERATIONS = new Set(['clear', 'get', 'set']); +const ALLOWED_IS_SUBCOMMANDS = new Set(['checked', 'enabled', 'visible']); +const ALLOWED_KEYBOARD_SUBCOMMANDS = new Set(['inserttext', 'type']); +const ALLOWED_MOUSE_SUBCOMMANDS = new Set(['down', 'move', 'up', 'wheel']); +const ALLOWED_SET_SUBCOMMANDS = new Set([ + 'credentials', + 'device', + 'geo', + 'headers', + 'media', + 'offline', + 'viewport', +]); +const ALLOWED_STORAGE_TYPES = new Set(['local', 'session']); +const ALLOWED_STORAGE_OPERATIONS = new Set(['clear', 'get', 'set']); +const SCREENSHOT_FLAGS = new Set(['--annotate', '--full', '-f']); +const MAX_BATCH_DEPTH = 8; + +export function assertControlledAgentBrowserCommand(args: string[]): void { + assertControlledAgentBrowserCommandAtDepth(args, 0); +} + +export function prepareControlledAgentBrowserCommand( + args: string[], + sessionDir: string, +): string[] { + assertControlledAgentBrowserCommand(args); + if (args[0]?.toLowerCase() !== 'screenshot') { + return args; + } + + const filename = args.at(-1); + assertScreenshotFilename(filename, args); + return [...args.slice(0, -1), path.join(sessionDir, filename)]; +} + +function assertControlledAgentBrowserCommandAtDepth( + args: string[], + batchDepth: number, +): void { + const command = args[0]?.toLowerCase(); + if (!command) { + throw new Error('An agent-browser command is required.'); + } + if (PROOFSHOT_OWNED_COMMANDS.has(command)) { + throw new Error( + `agent-browser ${command} cannot override ProofShot-owned browser state.`, + ); + } + if (!ALLOWED_COMMANDS.has(command)) { + throw new Error( + `agent-browser ${command} is not permitted inside a ProofShot session.`, + ); + } + if (batchDepth > 0 && command === 'screenshot') { + throw new Error( + 'agent-browser screenshot must run as a separate ProofShot action so its output remains session-local.', + ); + } + + const reservedFlag = args + .map(normalizeFlag) + .find((argument) => RESERVED_AGENT_BROWSER_FLAGS.has(argument)); + if (reservedFlag) { + throw new Error( + `${reservedFlag} is owned by ProofShot and cannot be passed through proofshot exec.`, + ); + } + + switch (command) { + case 'batch': + assertBatchCommands(args, batchDepth); + return; + case 'console': + case 'errors': + if (args.length > 1) { + throw new Error( + `agent-browser ${command} options are not permitted because they can destroy ProofShot evidence.`, + ); + } + return; + case 'cookies': + assertOptionalSubcommand( + command, + args[1], + ALLOWED_COOKIE_OPERATIONS, + ); + return; + case 'get': + assertAllowedSubcommand(command, args[1], ALLOWED_GET_SUBCOMMANDS); + return; + case 'is': + assertAllowedSubcommand(command, args[1], ALLOWED_IS_SUBCOMMANDS); + return; + case 'keyboard': + assertAllowedSubcommand(command, args[1], ALLOWED_KEYBOARD_SUBCOMMANDS); + return; + case 'mouse': + assertAllowedSubcommand(command, args[1], ALLOWED_MOUSE_SUBCOMMANDS); + return; + case 'network': + assertNetworkCommand(args); + return; + case 'screenshot': + assertScreenshotFilename(args.at(-1), args); + return; + case 'set': + assertAllowedSubcommand(command, args[1], ALLOWED_SET_SUBCOMMANDS); + return; + case 'storage': + assertAllowedSubcommand(command, args[1], ALLOWED_STORAGE_TYPES); + assertOptionalSubcommand( + command, + args[2], + ALLOWED_STORAGE_OPERATIONS, + ); + return; + default: + return; + } +} + +function assertBatchCommands(args: string[], batchDepth: number): void { + if (batchDepth >= MAX_BATCH_DEPTH) { + throw new Error('Nested agent-browser batch commands exceed the safe depth.'); + } + const nestedCommands = parseAgentBrowserBatchCommands(args); + if (nestedCommands.length === 0) { + throw new Error('agent-browser batch requires parseable commands.'); + } + for (const nestedArgs of nestedCommands) { + assertControlledAgentBrowserCommandAtDepth(nestedArgs, batchDepth + 1); + } +} + +function assertAllowedSubcommand( + command: string, + subcommand: string | undefined, + allowed: Set, +): void { + const normalized = subcommand?.toLowerCase(); + if (!normalized || !allowed.has(normalized)) { + throw new Error( + `agent-browser ${command} ${subcommand || '(missing)'} is not permitted inside a ProofShot session.`, + ); + } +} + +function assertOptionalSubcommand( + command: string, + subcommand: string | undefined, + allowed: Set, +): void { + if (subcommand && !allowed.has(subcommand.toLowerCase())) { + throw new Error( + `agent-browser ${command} ${subcommand} is not permitted inside a ProofShot session.`, + ); + } +} + +function assertNetworkCommand(args: string[]): void { + const subcommand = args[1]?.toLowerCase(); + if (subcommand === 'har') { + throw new Error('agent-browser network HAR capture is owned by ProofShot.'); + } + if (subcommand !== 'requests') { + throw new Error( + `agent-browser network ${subcommand || '(missing)'} is not permitted inside a ProofShot session.`, + ); + } + if (args.slice(2).some((argument) => normalizeFlag(argument) === '--clear')) { + throw new Error( + 'agent-browser network requests --clear is not permitted because it destroys ProofShot evidence.', + ); + } +} + +function assertScreenshotFilename( + filename: string | undefined, + args: string[], +): asserts filename is string { + const flags = args + .slice(1, -1) + .filter((argument) => argument.startsWith('-')); + if ( + !filename || + filename.startsWith('-') || + path.isAbsolute(filename) || + path.basename(filename) !== filename || + path.extname(filename).toLowerCase() !== '.png' || + flags.some((flag) => !SCREENSHOT_FLAGS.has(normalizeFlag(flag))) + ) { + throw new Error( + 'ProofShot screenshots require one PNG filename directly inside the active session.', + ); + } +} + +function normalizeFlag(argument: string): string { + return argument.toLowerCase().split('=', 1)[0]; +} diff --git a/src/commands/exec.test.ts b/src/commands/exec.test.ts index bc53439..1d7b5a0 100644 --- a/src/commands/exec.test.ts +++ b/src/commands/exec.test.ts @@ -3,6 +3,7 @@ import { setAgentBrowserDefaults } from '../utils/exec.js'; import { assertControlledAgentBrowserCommand, buildShellCommand, + prepareControlledAgentBrowserCommand, translateProofShotExecArgs, } from './exec.js'; @@ -63,13 +64,27 @@ describe('buildShellCommand', () => { it('rejects commands that can escape the owned browser lifecycle', () => { for (const args of [ ['auth', 'login', 'shared'], + ['chat', 'inspect the page'], + ['clipboard', 'read'], ['close'], ['connect', '9222'], + ['dashboard', 'start'], + ['doctor', '--fix'], + ['download', '@e1', '/tmp/report.csv'], + ['install'], + ['pdf', '/tmp/page.pdf'], + ['profiles'], + ['pushstate', 'https://example.com/private'], ['record', 'stop'], + ['session', 'list'], ['state', 'load', './shared.json'], + ['stream', 'enable', '--port', '4848'], + ['trace', 'stop', '/tmp/trace.zip'], + ['upgrade'], + ['future-command', 'value'], ]) { expect(() => assertControlledAgentBrowserCommand(args)).toThrow( - 'cannot override ProofShot-owned browser state', + /not permitted|cannot override ProofShot-owned browser state/, ); } expect(() => @@ -82,6 +97,67 @@ describe('buildShellCommand', () => { 'open https://example.com --provider browserbase', ]), ).toThrow('--provider is owned by ProofShot'); + expect(() => + assertControlledAgentBrowserCommand([ + 'batch', + 'snapshot -i', + 'dashboard start --port 4848', + ]), + ).toThrow('dashboard is not permitted'); + expect(() => + assertControlledAgentBrowserCommand([ + 'batch', + 'snapshot -i', + 'screenshot /tmp/outside.png', + ]), + ).toThrow('screenshot must run as a separate ProofShot action'); + }); + + it.each([ + ['get', 'cdp-url'], + ['network', 'har', 'stop'], + ['network', 'request', '1234.5'], + ['network', 'requests', '--clear'], + ['network', 'route', '**/api/*', '--abort'], + ['console', '--clear'], + ['console', '--future-option'], + ['cookies', 'import', './shared.json'], + ['errors', '--clear'], + ['keyboard', 'paste', 'private'], + ['mouse', 'teleport', '1', '2'], + ['set', 'profile', 'shared'], + ['storage', 'indexeddb', 'get'], + ['storage', 'local', 'export'], + ])('rejects unsupported or evidence-destructive subcommands: %j', (...args) => { + expect(() => assertControlledAgentBrowserCommand(args)).toThrow( + /not permitted|owned by ProofShot/, + ); + }); + + it('requires screenshots to use one PNG filename in the session root', () => { + expect( + prepareControlledAgentBrowserCommand( + ['screenshot', '--full', 'step (1).png'], + '/evidence/session', + ), + ).toEqual([ + 'screenshot', + '--full', + '/evidence/session/step (1).png', + ]); + + for (const args of [ + ['screenshot'], + ['screenshot', '--annotate'], + ['screenshot', '../outside.png'], + ['screenshot', '/tmp/outside.png'], + ['screenshot', 'step.jpg'], + ['screenshot', '--screenshot-dir', '/tmp'], + ]) { + expect(() => + prepareControlledAgentBrowserCommand(args, '/evidence/session'), + ).toThrow(/PNG filename directly inside/); + } }); it('preserves legitimate command-specific options', () => { @@ -109,5 +185,20 @@ describe('buildShellCommand', () => { expect(() => assertControlledAgentBrowserCommand(['network', 'requests']), ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand(['keyboard', 'type', 'hello']), + ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand(['set', 'viewport', '1280', '720']), + ).not.toThrow(); + expect(() => + assertControlledAgentBrowserCommand([ + 'storage', + 'local', + 'set', + 'theme', + 'dark', + ]), + ).not.toThrow(); }); }); diff --git a/src/commands/exec.ts b/src/commands/exec.ts index a9f1164..cda69ce 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,4 +1,3 @@ -import * as path from 'path'; import { execSync } from 'child_process'; import { writePrivateAgentBrowserResult, @@ -7,10 +6,13 @@ import { import { buildSanitizedCommandIntent, classifyInteraction, - parseAgentBrowserBatchCommands, sanitizeDiagnosticMessage, sanitizePageUrl, } from '../browser/provenance.js'; +import { + assertControlledAgentBrowserCommand, + prepareControlledAgentBrowserCommand, +} from '../browser/command-policy.js'; import { loadConfig, normalizeViewport } from '../utils/config.js'; import { ab, @@ -40,64 +42,6 @@ type ExecOptions = { session?: string; }; -const MAX_BATCH_DEPTH = 8; -const PROOFSHOT_OWNED_COMMANDS = new Set([ - 'auth', - 'close', - 'connect', - 'record', - 'state', -]); -const RESERVED_AGENT_BROWSER_FLAGS = new Set([ - '--action-policy', - '--allow-file-access', - '--allowed-domains', - '--args', - '--auto-connect', - '--cdp', - '--config', - '--confirm-actions', - '--confirm-interactive', - '--device', - '--download-path', - '--enable', - '--engine', - '--executable-path', - '--extension', - '--headed', - '--ignore-https-errors', - '--init-script', - '--namespace', - '--no-auto-dialog', - '--profile', - '--provider', - '--proxy', - '--proxy-bypass', - '--session', - '--session-name', - '--socket-dir', - '--state', - '--user-agent', - '-p', -]); - -/** - * For screenshot commands, resolve relative paths into the session directory - * so agents can just say `proofshot exec screenshot step-name.png`. - */ -function resolveScreenshotPath(args: string[], sessionDir: string): string[] { - if (args[0] !== 'screenshot' || args.length < 2) return args; - - const screenshotPath = args[args.length - 1]; - const resolved = path.resolve(sessionDir, screenshotPath); - if (path.dirname(resolved) !== path.resolve(sessionDir)) { - throw new Error( - 'ProofShot screenshots must use a filename directly inside the active session.', - ); - } - return [...args.slice(0, -1), resolved]; -} - /** * Build the shell command string for agent-browser. * @@ -133,53 +77,6 @@ export function buildShellCommand( }); } -export function assertControlledAgentBrowserCommand(args: string[]): void { - assertControlledAgentBrowserCommandAtDepth(args, 0); -} - -function assertControlledAgentBrowserCommandAtDepth( - args: string[], - batchDepth: number, -): void { - const command = args[0]?.toLowerCase(); - if (command && PROOFSHOT_OWNED_COMMANDS.has(command)) { - throw new Error( - `agent-browser ${command} cannot override ProofShot-owned browser state.`, - ); - } - if ( - command === 'network' && - args[1]?.toLowerCase() === 'har' - ) { - throw new Error( - 'agent-browser network HAR capture is owned by ProofShot.', - ); - } - - const reservedFlag = args - .map(normalizeFlag) - .find((argument) => RESERVED_AGENT_BROWSER_FLAGS.has(argument)); - if (reservedFlag) { - throw new Error( - `${reservedFlag} is owned by ProofShot and cannot be passed through proofshot exec.`, - ); - } - - if (command !== 'batch') { - return; - } - if (batchDepth >= MAX_BATCH_DEPTH) { - throw new Error('Nested agent-browser batch commands exceed the safe depth.'); - } - for (const nestedArgs of parseAgentBrowserBatchCommands(args)) { - assertControlledAgentBrowserCommandAtDepth(nestedArgs, batchDepth + 1); - } -} - -function normalizeFlag(argument: string): string { - return argument.toLowerCase().split('=', 1)[0]; -} - export function translateProofShotExecArgs(args: string[]): { agentBrowserArgs: string[]; expectedSelector?: string; @@ -301,10 +198,10 @@ export async function execCommand( args: string[], options: ExecOptions = {}, ): Promise { - assertControlledAgentBrowserCommand(args); const intent = buildSanitizedCommandIntent(args); const action = intent.summary; const translated = translateProofShotExecArgs(args); + assertControlledAgentBrowserCommand(translated.agentBrowserArgs); let loggedEntry: SessionLogEntry | null = null; let sessionLogPath: string | null = null; @@ -358,7 +255,7 @@ export async function execCommand( // Resolve args (screenshot path rewriting) let resolvedArgs = translated.agentBrowserArgs; if (session) { - resolvedArgs = resolveScreenshotPath( + resolvedArgs = prepareControlledAgentBrowserCommand( translated.agentBrowserArgs, session.sessionDir, ); @@ -506,6 +403,11 @@ export async function execCommand( } } +export { + assertControlledAgentBrowserCommand, + prepareControlledAgentBrowserCommand, +}; + function capturePageUrl(sessionName: string): string | undefined { try { return sanitizePageUrl(getPageUrl(sessionName) || undefined); From b03fa7f3766fecd3141563ed9d642426716574f8 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:59:53 +0000 Subject: [PATCH 47/74] fix(evidence): Sanitize browser command output Co-authored-by: Codex --- src/browser/evidence.test.ts | 33 +++++- src/browser/evidence.ts | 113 +++++++++++++++++++-- src/commands/exec.ts | 73 ++++++------- src/commands/lifecycle.integration.test.ts | 65 +++++++++++- 4 files changed, 237 insertions(+), 47 deletions(-) diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index 82d600d..5e8c7c4 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildSanitizedNetworkSummary, finalizePrivateNetworkCapture, + formatAgentBrowserOutputForDisplay, + sanitizeAgentBrowserError, writePrivateAgentBrowserResult, } from './evidence.js'; @@ -81,7 +83,7 @@ describe('private browser evidence', () => { ); try { const receipt = writePrivateAgentBrowserResult({ - command: 'cookies', + args: ['cookies', 'get'], sessionDir, rawOutput: JSON.stringify({ success: true, @@ -101,6 +103,35 @@ describe('private browser evidence', () => { } }); + it('keeps safe observations useful while redacting secret-bearing results', () => { + expect( + formatAgentBrowserOutputForDisplay({ + args: ['snapshot', '-i'], + rawOutput: JSON.stringify({ + success: true, + data: { snapshot: 'button "Submit" [ref=e1]' }, + }), + success: true, + }), + ).toBe('button "Submit" [ref=e1]'); + expect( + formatAgentBrowserOutputForDisplay({ + args: ['fill', '@e1', 'private-value'], + rawOutput: JSON.stringify({ + success: true, + data: { value: 'private-value' }, + }), + success: true, + }), + ).toBe('[REDACTED]'); + expect( + sanitizeAgentBrowserError( + ['type', '@e1', 'private-value'], + 'failed to type private-value', + ), + ).not.toContain('private-value'); + }); + it('still stops HAR capture when the request inventory command fails', () => { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'proofshot-network-finalize-'), diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index 0ae1462..ef32249 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -4,12 +4,22 @@ import { randomUUID } from 'crypto'; import { sanitizeDiagnosticMessage } from './provenance.js'; import { ab, quoteShellArgument } from '../utils/exec.js'; +const SECRET_BEARING_FLAG = + /^(?:--)?(?:authorization|body|cookie|credential|headers?|password|secret|token|api[-_]?key)(?:=|$)/i; + export type AgentBrowserResultReceipt = { success: boolean; evidencePath: string; error?: string; }; +type AgentBrowserOutputOptions = { + args: string[]; + rawOutput: string; + success: boolean; + error?: string; +}; + export type PrivateNetworkEvidencePaths = { privateDirectory: string; harPath: string; @@ -186,7 +196,7 @@ export function buildSanitizedNetworkSummary( } export function writePrivateAgentBrowserResult(options: { - command: string; + args: string[]; sessionDir: string; rawOutput: string; success: boolean; @@ -205,7 +215,7 @@ export function writePrivateAgentBrowserResult(options: { ); const result = sanitizeStructuredResult( parseStructuredResult(options.rawOutput, options.success, options.error), - options.command, + options.args, ); writeJsonFile(filePath, result); const evidencePath = path @@ -216,26 +226,113 @@ export function writePrivateAgentBrowserResult(options: { success: readSuccess(result, options.success), evidencePath, ...(options.error - ? { error: sanitizeDiagnosticMessage(options.error) || 'agent-browser failed' } + ? { error: sanitizeAgentBrowserError(options.args, options.error) } : {}), }; } -function sanitizeStructuredResult(value: unknown, command: string): unknown { - if (['auth', 'cookies', 'eval', 'storage'].includes(command)) { +export function formatAgentBrowserOutputForDisplay( + options: AgentBrowserOutputOptions, +): string { + const result = sanitizeStructuredResult( + parseStructuredResult(options.rawOutput, options.success, options.error), + options.args, + ); + if (typeof result !== 'object' || result === null) { + return String(result); + } + const data = (result as { data?: unknown }).data; + if ( + typeof data === 'string' || + typeof data === 'number' || + typeof data === 'boolean' + ) { + return String(data); + } + if (typeof data !== 'object' || data === null) { + return JSON.stringify(result); + } + const record = data as Record; + for (const key of ['url', 'title', 'text', 'value', 'snapshot']) { + if (typeof record[key] === 'string') { + return record[key]; + } + } + return JSON.stringify(result); +} + +export function sanitizeAgentBrowserError( + args: string[], + error: string | undefined, +): string { + if (commandResultMayContainSecrets(args)) { + return 'agent-browser command failed; sensitive details were redacted'; + } + return sanitizeDiagnosticMessage(error) || 'agent-browser failed'; +} + +function sanitizeStructuredResult(value: unknown, args: string[]): unknown { + if (commandResultMayContainSecrets(args)) { const record = readRecord(value); return { success: typeof record.success === 'boolean' ? record.success : true, data: '[REDACTED]', ...(typeof record.error === 'string' - ? { error: sanitizeDiagnosticMessage(record.error) } + ? { error: sanitizeAgentBrowserError(args, record.error) } : {}), }; } return sanitizeStructuredValue(value); } +function commandResultMayContainSecrets(args: string[]): boolean { + const command = args[0]?.toLowerCase(); + if (!command) { + return true; + } + if (args.slice(1).some((argument) => SECRET_BEARING_FLAG.test(argument))) { + return true; + } + if ( + [ + 'auth', + 'batch', + 'cookies', + 'eval', + 'fill', + 'network', + 'select', + 'storage', + 'type', + 'upload', + ].includes(command) + ) { + return true; + } + if ( + command === 'keyboard' && + ['inserttext', 'type'].includes(args[1]?.toLowerCase()) + ) { + return true; + } + if ( + command === 'set' && + ['credentials', 'headers'].includes(args[1]?.toLowerCase()) + ) { + return true; + } + if (command === 'get' && args[1]?.toLowerCase() === 'value') { + return true; + } + if (command === 'find') { + return ['fill', 'select', 'type'].some((action) => + args.some((argument) => argument.toLowerCase() === action), + ); + } + return false; +} + function sanitizeStructuredValue(value: unknown): unknown { if (typeof value === 'string') { return sanitizeDiagnosticMessage(value); @@ -249,13 +346,13 @@ function sanitizeStructuredValue(value: unknown): unknown { const record = value as Record; const namedSecret = typeof record.name === 'string' && - /authorization|cookie|credential|password|secret|token|api[-_]?key/i.test( + /authorization|body|cookie|credential|headers?|password|secret|token|api[-_]?key/i.test( record.name, ); return Object.fromEntries( Object.entries(record).map(([key, entry]) => { const isSensitive = - /authorization|cookie|credential|password|secret|token|api[-_]?key/i.test( + /authorization|body|cookie|credential|headers?|password|secret|token|api[-_]?key/i.test( key, ) || (namedSecret && key === 'value'); return [key, isSensitive ? '[REDACTED]' : sanitizeStructuredValue(entry)]; diff --git a/src/commands/exec.ts b/src/commands/exec.ts index cda69ce..4ac30fb 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,5 +1,7 @@ import { execSync } from 'child_process'; import { + formatAgentBrowserOutputForDisplay, + sanitizeAgentBrowserError, writePrivateAgentBrowserResult, type AgentBrowserResultReceipt, } from '../browser/evidence.js'; @@ -316,11 +318,16 @@ export async function execCommand( ) { const assertionError = new Error( `Expected selector to be visible: ${translated.expectedSelector}`, - ) as Error & { status: number }; + ) as Error & { status: number; stderr: string }; assertionError.status = 1; + assertionError.stderr = assertionError.message; throw assertionError; } - const displayedResult = session ? unwrapStructuredOutput(result) : result; + const displayedResult = formatAgentBrowserOutputForDisplay({ + args, + rawOutput: result, + success: true, + }); if (displayedResult.trim()) { process.stdout.write(displayedResult); // Ensure trailing newline @@ -331,7 +338,7 @@ export async function execCommand( const pageUrl = capturePageUrl(session.sessionName); const agentBrowserResult = session ? writePrivateAgentBrowserResult({ - command: intent.command, + args, sessionDir: session.sessionDir, rawOutput: result, success: true, @@ -347,21 +354,40 @@ export async function execCommand( agentBrowserResult, ); } catch (error: any) { - // Print stderr and exit with the same code const stderr = error?.stderr?.toString?.() || ''; const stdout = error?.stdout?.toString?.() || ''; - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); - if (!stdout && !stderr && error?.message) { - process.stderr.write(`${error.message}\n`); + if (stdout) { + const displayedStdout = formatAgentBrowserOutputForDisplay({ + args, + rawOutput: stdout, + success: false, + }); + if (displayedStdout) { + process.stdout.write(`${displayedStdout}\n`); + } + } + if (stderr) { + process.stderr.write( + `${sanitizeAgentBrowserError(args, stderr.trim())}\n`, + ); + } + if (!stdout && !stderr) { + process.stderr.write( + `agent-browser exited with status ${error?.status || 1}\n`, + ); } const rawErrorOutput = stdout || stderr; - const persistedError = sanitizeDiagnosticMessage( - stderr.trim() || stdout.trim() || `agent-browser exited with status ${error?.status || 1}`, + const errorMessage = + stderr.trim() || + stdout.trim() || + `agent-browser exited with status ${error?.status || 1}`; + const persistedError = sanitizeAgentBrowserError( + args, + errorMessage, ); const agentBrowserResult = session ? writePrivateAgentBrowserResult({ - command: intent.command, + args, sessionDir: session.sessionDir, rawOutput: rawErrorOutput, success: false, @@ -460,28 +486,3 @@ function assertionPassed(rawOutput: string): boolean { return false; } } - -function unwrapStructuredOutput(rawOutput: string): string { - try { - const parsed = JSON.parse(rawOutput) as { data?: unknown }; - if ( - typeof parsed.data === 'string' || - typeof parsed.data === 'number' || - typeof parsed.data === 'boolean' - ) { - return String(parsed.data); - } - if (typeof parsed.data !== 'object' || parsed.data === null) { - return rawOutput; - } - const data = parsed.data as Record; - for (const key of ['url', 'title', 'text', 'value', 'snapshot']) { - if (typeof data[key] === 'string') { - return data[key]; - } - } - return rawOutput; - } catch { - return rawOutput; - } -} diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index f28b697..248f4a7 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -248,7 +248,18 @@ if (command === 'hang') { }); process.exit(0); } -if (command === 'fail') { +if (command === 'fill') { + process.stdout.write(JSON.stringify({ + success: true, + data: { value: detail[1] }, + }) + '\\n'); + process.exit(0); +} +if (command === 'type') { + process.stderr.write('failed to type ' + detail[1] + '\\n'); + process.exit(7); +} +if (command === 'click' && detail[0] === 'fail') { process.stderr.write('simulated action failure\\n'); process.exit(7); } @@ -878,6 +889,18 @@ describe('isolated CLI lifecycle', () => { expect(explicitExec.stdout).toContain(session.targetUrl); } + const failedAssertion = runCli(audit, postStartEnv, [ + 'exec', + '--session', + sessions[0].sessionName, + 'assert-visible', + '#missing-result', + ]); + expect(failedAssertion.status).toBe(1); + expect(failedAssertion.stderr).toContain( + 'Expected selector to be visible: #missing-result', + ); + const secret = 'proofshot-secret-value'; const secretExec = runCli(audit, postStartEnv, [ 'exec', @@ -888,6 +911,8 @@ describe('isolated CLI lifecycle', () => { secret, ]); expect(secretExec.status, `${secretExec.stdout}\n${secretExec.stderr}`).toBe(0); + expect(secretExec.stdout).not.toContain(secret); + expect(secretExec.stderr).not.toContain(secret); const actionLogPath = path.join(sessions[0].sessionDir, 'session-log.json'); const actionLogText = fs.readFileSync(actionLogPath, 'utf-8'); const actionEntries = JSON.parse(actionLogText); @@ -912,17 +937,53 @@ describe('isolated CLI lifecycle', () => { secretEntry.agentBrowserResult.evidencePath, ); expect(fs.statSync(privateResultPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(privateResultPath, 'utf-8')).not.toContain(secret); + + const failedSecret = 'proofshot-failed-secret'; + const failedSecretExec = runCli(audit, postStartEnv, [ + 'exec', + '--session', + sessions[0].sessionName, + 'type', + '@e1', + failedSecret, + ]); + expect(failedSecretExec.status).toBe(7); + expect(failedSecretExec.stdout).not.toContain(failedSecret); + expect(failedSecretExec.stderr).not.toContain(failedSecret); + const failedSecretEntry = JSON.parse( + fs.readFileSync(actionLogPath, 'utf-8'), + ).at(-1); + expect(failedSecretEntry).toMatchObject({ + action: 'type @e1 [REDACTED]', + outcome: 'failed', + error: expect.not.stringContaining(failedSecret), + agentBrowserResult: { + success: false, + evidencePath: expect.stringMatching(/^private\/agent-browser\/actions\//), + }, + }); + expect( + fs.readFileSync( + path.join( + sessions[0].sessionDir, + failedSecretEntry.agentBrowserResult.evidencePath, + ), + 'utf-8', + ), + ).not.toContain(failedSecret); const failedExec = runCli(audit, postStartEnv, [ 'exec', '--session', sessions[0].sessionName, + 'click', 'fail', ]); expect(failedExec.status).toBe(7); const failedEntry = JSON.parse(fs.readFileSync(actionLogPath, 'utf-8')).at(-1); expect(failedEntry).toMatchObject({ - action: 'fail', + action: 'click fail', outcome: 'failed', pageUrl: sessions[0].targetUrl, }); From e93f46f73ead28490d53ea391da77ffbd7026906 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:04:14 +0000 Subject: [PATCH 48/74] fix(browser): Refuse cleanup of live legacy sessions Co-authored-by: Codex --- src/commands/lifecycle.integration.test.ts | 81 ++++++++++++++++++++-- src/commands/session.test.ts | 18 +++++ src/commands/session.ts | 13 +++- src/commands/start.test.ts | 3 + src/commands/start.ts | 11 ++- 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 248f4a7..21ba33d 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -242,7 +242,10 @@ if ( Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000); process.exit(0); } -if (command === 'hang') { +if ( + command === 'wait' && + process.env.FAKE_AGENT_BROWSER_HANG_WAIT === '1' +) { spawnSync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', }); @@ -501,6 +504,72 @@ describe('isolated CLI lifecycle', () => { cleanupProcesses.splice(cleanupProcesses.indexOf(session.browserProcess), 1); }, 30000); + it('refuses force and recovery cleanup for a live legacy browser', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/legacy-live', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const [session] = readRegisteredSessions(env); + const browserProcess = session.browserProcess; + cleanupProcesses.push(browserProcess); + const registryPath = path.join( + registryDirectory(env), + `${session.sessionName}.json`, + ); + delete session.browserProcess; + fs.writeFileSync(registryPath, JSON.stringify(session, null, 2) + '\n'); + const registryBefore = fs.readFileSync(registryPath, 'utf-8'); + const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); + + const forcedStart = runCli(audit, env, [ + 'start', + '--force', + '--url', + 'https://example.invalid/replacement', + '--browser-executable', + tools.browserPath, + ]); + expect(forcedStart.status).toBe(1); + expect(forcedStart.stderr).toContain( + '--force cannot clean a verified live ProofShot session', + ); + + for (const cleanArgs of [ + ['session', 'clean', '--session', session.sessionName], + ['session', 'clean', '--all'], + ]) { + const clean = runCli(audit, env, cleanArgs); + expect(clean.status).toBe(1); + expect(clean.stderr).toContain('browser session is still live'); + } + + expect(fs.readFileSync(registryPath, 'utf-8')).toBe(registryBefore); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBefore); + expect(processIsAlive(browserProcess.pid)).toBe(true); + + const stop = runCli(audit, env, [ + 'stop', + '--session', + session.sessionName, + ]); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + await waitForProcessExit(browserProcess.pid); + cleanupProcesses.splice(cleanupProcesses.indexOf(browserProcess), 1); + }, 30000); + it('refuses --force while stop finalizes after exact browser cleanup', async () => { const { base, audit } = createAuditRoot(); const tools = writeFixtureTools(base); @@ -1225,11 +1294,15 @@ describe('isolated CLI lifecycle', () => { 'exec', '--session', sessions[0].sessionName, - 'hang', + 'wait', + '10000', ], { cwd: audit, - env, + env: { + ...env, + FAKE_AGENT_BROWSER_HANG_WAIT: '1', + }, detached: true, stdio: 'ignore', }, @@ -1261,7 +1334,7 @@ describe('isolated CLI lifecycle', () => { const pendingActions = JSON.parse( fs.readFileSync(interruptedLogPath, 'utf-8'), ); - expect(pendingActions.at(-1).action).toBe('hang'); + expect(pendingActions.at(-1).action).toBe('wait 10000'); expect('outcome' in pendingActions.at(-1)).toBe(false); const unaffectedExec = runCli(audit, env, [ diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts index 13389c2..bc51e63 100644 --- a/src/commands/session.test.ts +++ b/src/commands/session.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + canAddressOwnedBrowserSession: vi.fn(), claimSessionOperation: vi.fn(), cleanupFailedStart: vi.fn(), getRegisteredSession: vi.fn(), @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('../session/lifecycle.js', () => ({ + canAddressOwnedBrowserSession: mocks.canAddressOwnedBrowserSession, cleanupFailedStart: mocks.cleanupFailedStart, })); vi.mock('../session/registry.js', () => ({ @@ -51,6 +53,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); mocks.cleanupFailedStart.mockResolvedValue(undefined); mocks.claimSessionOperation.mockImplementation((session) => { const lease = { @@ -125,6 +128,21 @@ describe('session commands', () => { expect(mocks.unregisterSession).not.toHaveBeenCalled(); }); + it('refuses to clean an addressable live browser session', async () => { + const session = buildSession({ lifecycleStatus: 'recovery' }); + mocks.getRegisteredSession.mockReturnValue(session); + mocks.canAddressOwnedBrowserSession.mockReturnValue(true); + + await sessionCleanCommand({ session: session.sessionName }); + + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('browser session is still live'), + ); + expect(mocks.cleanupFailedStart).not.toHaveBeenCalled(); + expect(mocks.unregisterSession).not.toHaveBeenCalled(); + }); + it('persists an exact runtime before cleaning a legacy session', async () => { const session = buildSession({ agentBrowserExecutablePath: undefined }); mocks.getRegisteredSession.mockReturnValue(session); diff --git a/src/commands/session.ts b/src/commands/session.ts index 01fce3d..803b066 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -1,5 +1,8 @@ import chalk from 'chalk'; -import { cleanupFailedStart } from '../session/lifecycle.js'; +import { + canAddressOwnedBrowserSession, + cleanupFailedStart, +} from '../session/lifecycle.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { claimSessionOperation, @@ -66,6 +69,14 @@ export async function sessionCleanCommand(options: SessionCleanOptions): Promise let failures = 0; for (const session of sessions) { + if (canAddressOwnedBrowserSession(session)) { + failures += 1; + console.error( + `${chalk.red('✗')} Kept ${session.sessionName}: browser session is still live; ` + + `use "proofshot stop --session ${session.sessionName}".`, + ); + continue; + } let recoveryLease; try { recoveryLease = claimSessionOperation(session, 'recovery'); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 6548089..dc3e41e 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ prepareAgentBrowserSocketDir: vi.fn(), resolveAgentBrowserRuntimeDir: vi.fn(), captureAgentBrowserProcessIdentity: vi.fn(), + canAddressOwnedBrowserSession: vi.fn(), cleanupFailedStart: vi.fn(), startOwnedEnvironment: vi.fn(), resolveAgentBrowserRuntime: vi.fn(), @@ -95,6 +96,7 @@ vi.mock('../session/state.js', () => ({ })); vi.mock('../session/lifecycle.js', () => ({ + canAddressOwnedBrowserSession: mocks.canAddressOwnedBrowserSession, cleanupFailedStart: mocks.cleanupFailedStart, })); vi.mock('../environment/runtime.js', () => ({ @@ -155,6 +157,7 @@ describe('startCommand', () => { sessionId: 4001, startTime: '12345', }); + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); mocks.cleanupFailedStart.mockResolvedValue(undefined); mocks.resolveAgentBrowserRuntime.mockReturnValue({ executablePath: '/opt/node24/bin/agent-browser', diff --git a/src/commands/start.ts b/src/commands/start.ts index 555e0a3..ae27f1b 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -29,7 +29,10 @@ import { type SessionState, } from '../session/state.js'; import { backfillSessionAgentBrowserRuntime } from '../session/browser-runtime.js'; -import { cleanupFailedStart } from '../session/lifecycle.js'; +import { + canAddressOwnedBrowserSession, + cleanupFailedStart, +} from '../session/lifecycle.js'; import { claimSessionOperation, registerSession, @@ -76,7 +79,11 @@ export async function startCommand(options: StartOptions): Promise { process.exit(1); return; } - const liveSessions = existingSessions.filter(sessionHasVerifiedLiveOwnership); + const liveSessions = existingSessions.filter( + (session) => + sessionHasVerifiedLiveOwnership(session) || + canAddressOwnedBrowserSession(session), + ); if (liveSessions.length > 0) { console.error( chalk.red('✗') + From f6feae8969418c470d6aeee2314435cab4ba7f65 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:05:07 +0000 Subject: [PATCH 49/74] fix(evidence): Keep output directories private Co-authored-by: Codex --- src/artifacts/bundle.test.ts | 30 ++++++++++++++++++++++++++++++ src/artifacts/bundle.ts | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/artifacts/bundle.test.ts diff --git a/src/artifacts/bundle.test.ts b/src/artifacts/bundle.test.ts new file mode 100644 index 0000000..3aea065 --- /dev/null +++ b/src/artifacts/bundle.test.ts @@ -0,0 +1,30 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ensureOutputDir } from './bundle.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('ensureOutputDir', () => { + it('keeps ProofShot output and session directories private', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proofshot-output-')); + temporaryDirectories.push(root); + const outputDirectory = path.join(root, 'proofshot-artifacts'); + const sessionDirectory = path.join(outputDirectory, 'session'); + + ensureOutputDir(outputDirectory); + fs.chmodSync(outputDirectory, 0o755); + ensureOutputDir(outputDirectory); + ensureOutputDir(sessionDirectory); + + expect(fs.statSync(outputDirectory).mode & 0o777).toBe(0o700); + expect(fs.statSync(sessionDirectory).mode & 0o777).toBe(0o700); + }); +}); diff --git a/src/artifacts/bundle.ts b/src/artifacts/bundle.ts index cab5e40..cfbac12 100644 --- a/src/artifacts/bundle.ts +++ b/src/artifacts/bundle.ts @@ -26,7 +26,8 @@ export interface VerificationResult { * Ensure the output directory exists. */ export function ensureOutputDir(outputDir: string): void { - fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(outputDir, 0o700); } /** From 57f6c53830efe4f8f8246299f4247db8cb07c7fd Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:25:43 +0000 Subject: [PATCH 50/74] fix(browser): Execute agent-browser with argv Co-authored-by: Codex --- package.json | 2 +- src/browser/capture.ts | 17 ++++-- src/browser/evidence.test.ts | 32 +++++------ src/browser/evidence.ts | 12 ++-- src/browser/interact.ts | 10 ++-- src/browser/navigate.ts | 11 ++-- src/browser/session.test.ts | 36 +++++++----- src/browser/session.ts | 34 +++++------ src/commands/exec.test.ts | 44 ++++++++++----- src/commands/exec.ts | 78 ++++++++++---------------- src/utils/exec.test.ts | 102 +++++++++++++++++++++++---------- src/utils/exec.ts | 106 +++++++++++++++++------------------ 12 files changed, 275 insertions(+), 209 deletions(-) diff --git a/package.json b/package.json index 4ddaa2c..1ec9955 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:ffmpeg": "node test/ffmpeg-trim.mjs", "test:watch": "vitest", "prepare": "node prepare.js", - "postinstall": "node -e \"try { require('child_process').execSync('agent-browser install', { stdio: 'inherit' }) } catch(e) { console.log('Note: Run agent-browser install to set up Chromium') }\"" + "postinstall": "node -e \"try { require('child_process').execFileSync('agent-browser', ['install'], { stdio: 'inherit' }) } catch(e) { console.log('Note: Run agent-browser install to set up Chromium') }\"" }, "dependencies": { "agent-browser": "0.34.0", diff --git a/src/browser/capture.ts b/src/browser/capture.ts index 64c9be2..a2138bb 100644 --- a/src/browser/capture.ts +++ b/src/browser/capture.ts @@ -4,7 +4,7 @@ import { ab } from '../utils/exec.js'; * Start video recording to the given file path. */ export function startRecording(outputPath: string, sessionName?: string): void { - ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName }); + ab(['record', 'start', outputPath], { timeoutMs: 10000, session: sessionName }); } /** @@ -12,7 +12,7 @@ export function startRecording(outputPath: string, sessionName?: string): void { */ export function stopRecording(sessionName?: string): void { try { - ab('record stop', { timeoutMs: 15000, session: sessionName }); + ab(['record', 'stop'], { timeoutMs: 15000, session: sessionName }); } catch { // Recording may not have started — that's fine } @@ -22,15 +22,20 @@ export function stopRecording(sessionName?: string): void { * Take a screenshot and save to the given path. */ export function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void { - const fullFlag = fullPage ? ' --full' : ''; - ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName }); + ab(['screenshot', outputPath, ...(fullPage ? ['--full'] : [])], { + timeoutMs: 15000, + session: sessionName, + }); } /** * Take an annotated screenshot (labels interactive elements). */ export function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void { - ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName }); + ab(['screenshot', outputPath, '--annotate'], { + timeoutMs: 15000, + session: sessionName, + }); } /** @@ -44,7 +49,7 @@ export function diffScreenshots( sessionName?: string, ): number | null { try { - const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, { + const result = ab(['diff', 'screenshot', baseline, current, outputPath], { timeoutMs: 15000, session: sessionName, }); diff --git a/src/browser/evidence.test.ts b/src/browser/evidence.test.ts index 5e8c7c4..f5abd6d 100644 --- a/src/browser/evidence.test.ts +++ b/src/browser/evidence.test.ts @@ -143,18 +143,18 @@ describe('private browser evidence', () => { requestsPath: path.join(privateDirectory, 'requests.json'), summaryPath: path.join(sessionDir, 'network-summary.json'), }; - mocks.ab.mockImplementation((command: string) => { - if (command.startsWith('network har stop ')) { + mocks.ab.mockImplementation((command: string[]) => { + if (command.slice(0, 3).join(' ') === 'network har stop') { fs.writeFileSync( `${paths.harPath}.pending`, JSON.stringify({ log: { entries: [] } }), ); return JSON.stringify({ success: true }); } - if (command === 'network requests --json') { + if (command.join(' ') === 'network requests --json') { throw new Error('request inventory failed'); } - throw new Error(`unexpected command: ${command}`); + throw new Error(`unexpected command: ${command.join(' ')}`); }); try { @@ -162,8 +162,8 @@ describe('private browser evidence', () => { expect(summary.requestCount).toBe(0); expect(mocks.ab).toHaveBeenCalledTimes(2); - expect(mocks.ab.mock.calls[0][0]).toContain('network.har.pending'); - expect(mocks.ab.mock.calls[1][0]).toBe('network requests --json'); + expect(mocks.ab.mock.calls[0][0]).toContain(`${paths.harPath}.pending`); + expect(mocks.ab.mock.calls[1][0]).toEqual(['network', 'requests', '--json']); expect(fs.existsSync(`${paths.harPath}.pending`)).toBe(false); expect(fs.existsSync(paths.harPath)).toBe(true); expect(fs.readFileSync(paths.requestsPath, 'utf-8')).toContain( @@ -185,18 +185,18 @@ describe('private browser evidence', () => { requestsPath: path.join(privateDirectory, 'requests.json'), summaryPath: path.join(sessionDir, 'network-summary.json'), }; - mocks.ab.mockImplementation((command: string) => { - if (command.startsWith('network har stop ')) { + mocks.ab.mockImplementation((command: string[]) => { + if (command.slice(0, 3).join(' ') === 'network har stop') { fs.writeFileSync( `${paths.harPath}.pending`, JSON.stringify({ log: { entries: [] } }), ); throw new Error('HAR stop response was lost'); } - if (command === 'network requests --json') { + if (command.join(' ') === 'network requests --json') { return JSON.stringify({ success: true, data: [] }); } - throw new Error(`unexpected command: ${command}`); + throw new Error(`unexpected command: ${command.join(' ')}`); }); try { @@ -236,11 +236,11 @@ describe('private browser evidence', () => { }, }), ); - mocks.ab.mockImplementation((command: string) => { - if (command === 'network requests --json') { + mocks.ab.mockImplementation((command: string[]) => { + if (command.join(' ') === 'network requests --json') { return JSON.stringify({ success: true, data: [] }); } - throw new Error(`unexpected command: ${command}`); + throw new Error(`unexpected command: ${command.join(' ')}`); }); try { @@ -328,12 +328,12 @@ describe('private browser evidence', () => { requestsPath: path.join(privateDirectory, 'requests.json'), summaryPath: path.join(sessionDir, 'network-summary.json'), }; - mocks.ab.mockImplementation((command: string) => { - if (command.startsWith('network har stop ')) { + mocks.ab.mockImplementation((command: string[]) => { + if (command.slice(0, 3).join(' ') === 'network har stop') { fs.writeFileSync(`${paths.harPath}.pending`, '{'); return JSON.stringify({ success: true }); } - throw new Error(`unexpected command: ${command}`); + throw new Error(`unexpected command: ${command.join(' ')}`); }); try { diff --git a/src/browser/evidence.ts b/src/browser/evidence.ts index ef32249..3bbb86b 100644 --- a/src/browser/evidence.ts +++ b/src/browser/evidence.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; import { sanitizeDiagnosticMessage } from './provenance.js'; -import { ab, quoteShellArgument } from '../utils/exec.js'; +import { ab } from '../utils/exec.js'; const SECRET_BEARING_FLAG = /^(?:--)?(?:authorization|body|cookie|credential|headers?|password|secret|token|api[-_]?key)(?:=|$)/i; @@ -59,7 +59,9 @@ export function preparePrivateNetworkEvidence( } export function startPrivateNetworkCapture(sessionName: string): void { - ab('network har start --content text', { session: sessionName }); + ab(['network', 'har', 'start', '--content', 'text'], { + session: sessionName, + }); } export function finalizePrivateNetworkCapture( @@ -79,7 +81,9 @@ export function finalizePrivateNetworkCapture( } else if (allowBrowserCommands) { let requests: string; try { - requests = ab('network requests --json', { session: sessionName }); + requests = ab(['network', 'requests', '--json'], { + session: sessionName, + }); } catch (error) { requests = JSON.stringify({ success: false, @@ -123,7 +127,7 @@ function finalizePrivateHarCapture( let stopError: unknown; try { - ab(`network har stop ${quoteShellArgument(pendingPath)} --json`, { + ab(['network', 'har', 'stop', pendingPath, '--json'], { session: sessionName, }); } catch (error) { diff --git a/src/browser/interact.ts b/src/browser/interact.ts index e1ae392..544ba7f 100644 --- a/src/browser/interact.ts +++ b/src/browser/interact.ts @@ -4,33 +4,33 @@ import { ab } from '../utils/exec.js'; * Click an element by its ref (e.g., @e3). */ export function click(ref: string, sessionName?: string): void { - ab(`click ${ref}`, { timeoutMs: 10000, session: sessionName }); + ab(['click', ref], { timeoutMs: 10000, session: sessionName }); } /** * Fill a form field by its ref. */ export function fill(ref: string, value: string, sessionName?: string): void { - ab(`fill ${ref} "${value.replace(/"/g, '\\"')}"`, { timeoutMs: 10000, session: sessionName }); + ab(['fill', ref, value], { timeoutMs: 10000, session: sessionName }); } /** * Type text (keyboard input, not targeting a specific element). */ export function type(text: string, sessionName?: string): void { - ab(`type "${text.replace(/"/g, '\\"')}"`, { timeoutMs: 10000, session: sessionName }); + ab(['type', text], { timeoutMs: 10000, session: sessionName }); } /** * Press a key (e.g., Enter, Tab, Escape). */ export function press(key: string, sessionName?: string): void { - ab(`press ${key}`, { timeoutMs: 5000, session: sessionName }); + ab(['press', key], { timeoutMs: 5000, session: sessionName }); } /** * Scroll the page in a direction. */ export function scroll(direction: 'up' | 'down' = 'down', amount = 3, sessionName?: string): void { - ab(`scroll ${direction} ${amount}`, { timeoutMs: 5000, session: sessionName }); + ab(['scroll', direction, String(amount)], { timeoutMs: 5000, session: sessionName }); } diff --git a/src/browser/navigate.ts b/src/browser/navigate.ts index 5528843..0687cfa 100644 --- a/src/browser/navigate.ts +++ b/src/browser/navigate.ts @@ -1,15 +1,18 @@ -import { ab, quoteShellArgument } from '../utils/exec.js'; +import { ab } from '../utils/exec.js'; /** * Navigate to a URL and wait for the page to load. */ export function navigateTo(url: string, sessionName?: string): void { - ab(`open ${quoteShellArgument(url)}`, { + ab(['open', url], { timeoutMs: 60000, session: sessionName, }); try { - ab('wait --load networkidle', { timeoutMs: 30000, session: sessionName }); + ab(['wait', '--load', 'networkidle'], { + timeoutMs: 30000, + session: sessionName, + }); } catch { // networkidle may timeout on some pages — that's okay, // we still have the page in whatever state it loaded to @@ -22,7 +25,7 @@ export function navigateTo(url: string, sessionName?: string): void { */ export function getSnapshot(sessionName?: string): string { try { - return ab('snapshot -i', { timeoutMs: 15000, session: sessionName }); + return ab(['snapshot', '-i'], { timeoutMs: 15000, session: sessionName }); } catch { return ''; } diff --git a/src/browser/session.test.ts b/src/browser/session.test.ts index ad5b2f6..ff1753b 100644 --- a/src/browser/session.test.ts +++ b/src/browser/session.test.ts @@ -24,15 +24,18 @@ afterEach(() => { describe('buildOpenBrowserCommand', () => { it('builds a default open command without extra flags', () => { - expect(buildOpenBrowserCommand('http://localhost:3000')).toBe( - "open 'http://localhost:3000'", - ); + expect(buildOpenBrowserCommand('http://localhost:3000')).toEqual([ + 'open', + 'http://localhost:3000', + ]); }); it('includes headed mode when headless is disabled', () => { - expect(buildOpenBrowserCommand('http://localhost:3000', false)).toBe( - "open 'http://localhost:3000' --headed", - ); + expect(buildOpenBrowserCommand('http://localhost:3000', false)).toEqual([ + 'open', + 'http://localhost:3000', + '--headed', + ]); }); it('includes configurable browser flags from ProofShot config', () => { @@ -41,15 +44,22 @@ describe('buildOpenBrowserCommand', () => { ignoreHttpsErrors: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', }), - ).toBe( - 'open \'https://localhost:3000\' --ignore-https-errors --executable-path "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"', - ); + ).toEqual([ + 'open', + 'https://localhost:3000', + '--ignore-https-errors', + '--executable-path', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ]); }); - it('quotes data URLs containing shell metacharacters', () => { + it('keeps data URLs containing shell metacharacters in one argument', () => { expect( buildOpenBrowserCommand("data:text/html,

Ready

"), - ).toBe("open 'data:text/html,

Ready

'"); + ).toEqual([ + 'open', + "data:text/html,

Ready

", + ]); }); }); @@ -70,10 +80,10 @@ describe('openBrowser', () => { 'slow-page', ); - expect(mocks.ab).toHaveBeenNthCalledWith(2, 'get url', { + expect(mocks.ab).toHaveBeenNthCalledWith(2, ['get', 'url'], { session: 'slow-page', }); - expect(mocks.ab).toHaveBeenNthCalledWith(3, 'set viewport 1280 720', { + expect(mocks.ab).toHaveBeenNthCalledWith(3, ['set', 'viewport', '1280', '720'], { session: 'slow-page', }); expect(warn).toHaveBeenCalledOnce(); diff --git a/src/browser/session.ts b/src/browser/session.ts index 885f243..f4fbbbd 100644 --- a/src/browser/session.ts +++ b/src/browser/session.ts @@ -1,7 +1,6 @@ import { ab, ProofShotError, - quoteShellArgument, } from '../utils/exec.js'; import type { BrowserConfig, ViewportConfig } from '../utils/config.js'; @@ -9,15 +8,16 @@ export function buildOpenBrowserCommand( url: string, headless = true, browserConfig?: BrowserConfig, -): string { - const flags: string[] = []; +): string[] { + const args = ['open', url]; - if (!headless) flags.push('--headed'); - if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors'); - if (browserConfig?.executablePath) flags.push(`--executable-path "${browserConfig.executablePath.replace(/"/g, '\\"')}"`); + if (!headless) args.push('--headed'); + if (browserConfig?.ignoreHttpsErrors) args.push('--ignore-https-errors'); + if (browserConfig?.executablePath) { + args.push('--executable-path', browserConfig.executablePath); + } - const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : ''; - return `open ${quoteShellArgument(url)}${suffix}`; + return args; } /** @@ -45,7 +45,9 @@ export function openBrowser( 'Browser reached the target URL before its load event timed out; continuing with the active page.', ); } - ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName }); + ab(['set', 'viewport', String(viewport.width), String(viewport.height)], { + session: sessionName, + }); } function isNavigationTimeout(error: unknown): boolean { @@ -67,7 +69,7 @@ function urlsMatch(actual: string, expected: string): boolean { * Close the browser session. */ export function closeBrowser(sessionName?: string): void { - ab('close', { session: sessionName }); + ab(['close'], { session: sessionName }); } /** @@ -75,7 +77,7 @@ export function closeBrowser(sessionName?: string): void { */ export function checkAgentBrowser(): boolean { try { - ab('--version', 5000); + ab(['--version'], 5000); return true; } catch { return false; @@ -86,14 +88,14 @@ export function checkAgentBrowser(): boolean { * Get any console errors from the current page. */ export function getConsoleErrors(sessionName?: string): string { - return ab('errors', { session: sessionName }); + return ab(['errors'], { session: sessionName }); } /** * Get console output from the current page. */ export function getConsoleOutput(sessionName?: string): string { - return ab('console', { session: sessionName }); + return ab(['console'], { session: sessionName }); } export interface ConsoleMessage { @@ -106,7 +108,7 @@ export interface ConsoleMessage { * Get console output as structured JSON with per-message timestamps. */ export function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] { - const raw = ab('console --json', { session: sessionName }); + const raw = ab(['console', '--json'], { session: sessionName }); const parsed = JSON.parse(raw); // agent-browser wraps JSON output: {success, data: {messages: [...]}, error} const messages = parsed?.data?.messages ?? parsed; @@ -121,7 +123,7 @@ export function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] { */ export function getPageTitle(sessionName?: string): string { try { - return ab('get title', { session: sessionName }); + return ab(['get', 'title'], { session: sessionName }); } catch { return ''; } @@ -132,7 +134,7 @@ export function getPageTitle(sessionName?: string): string { */ export function getPageUrl(sessionName?: string): string { try { - return ab('get url', { session: sessionName }); + return ab(['get', 'url'], { session: sessionName }); } catch { return ''; } diff --git a/src/commands/exec.test.ts b/src/commands/exec.test.ts index 1d7b5a0..9995a45 100644 --- a/src/commands/exec.test.ts +++ b/src/commands/exec.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { assertControlledAgentBrowserCommand, - buildShellCommand, + buildExecInvocation, prepareControlledAgentBrowserCommand, translateProofShotExecArgs, } from './exec.js'; -describe('buildShellCommand', () => { +describe('buildExecInvocation', () => { beforeEach(() => { setAgentBrowserDefaults({ executablePath: '/opt/node24/bin/agent-browser', @@ -19,21 +19,39 @@ describe('buildShellCommand', () => { }); it('routes regular commands through the active ProofShot session', () => { - expect(buildShellCommand(['click', '@e2'], 'proofshot-2026-04-07_22-30-00')).toBe( - "'/opt/node24/bin/agent-browser' --session 'proofshot-2026-04-07_22-30-00' click @e2", - ); + expect(buildExecInvocation(['click', '@e2'], 'proofshot-2026-04-07_22-30-00')).toEqual({ + executablePath: '/opt/node24/bin/agent-browser', + args: [ + '--session', + 'proofshot-2026-04-07_22-30-00', + 'click', + '@e2', + ], + }); }); - it('preserves eval shell quoting while adding the session flag', () => { - expect(buildShellCommand(['eval', "console.log('hello')"], 'proofshot-dev')).toBe( - "'/opt/node24/bin/agent-browser' --session 'proofshot-dev' eval 'console.log('\\''hello'\\'')'", - ); + it('preserves eval source as one argument while adding the session flag', () => { + expect(buildExecInvocation(['eval', "console.log('hello')"], 'proofshot-dev')).toEqual({ + executablePath: '/opt/node24/bin/agent-browser', + args: [ + '--session', + 'proofshot-dev', + 'eval', + "console.log('hello')", + ], + }); }); - it('quotes regular arguments that contain shell metacharacters', () => { - expect(buildShellCommand(['screenshot', 'step (1).png'], 'proofshot-dev')).toBe( - "'/opt/node24/bin/agent-browser' --session 'proofshot-dev' screenshot 'step (1).png'", - ); + it('preserves regular arguments that contain shell metacharacters', () => { + expect(buildExecInvocation(['screenshot', 'step $(touch nope); (1).png'], 'proofshot-dev')).toEqual({ + executablePath: '/opt/node24/bin/agent-browser', + args: [ + '--session', + 'proofshot-dev', + 'screenshot', + 'step $(touch nope); (1).png', + ], + }); }); it('translates visible-selector assertions into an evidence-bearing browser query', () => { diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 4ac30fb..e7e2314 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,4 +1,3 @@ -import { execSync } from 'child_process'; import { formatAgentBrowserOutputForDisplay, sanitizeAgentBrowserError, @@ -18,9 +17,10 @@ import { import { loadConfig, normalizeViewport } from '../utils/config.js'; import { ab, - buildAgentBrowserCommand, - getAgentBrowserEnvironment, + buildAgentBrowserInvocation, + executeAgentBrowser, setAgentBrowserDefaults, + type AgentBrowserInvocation, } from '../utils/exec.js'; import { resolveSessionControlDir, @@ -45,35 +45,15 @@ type ExecOptions = { }; /** - * Build the shell command string for agent-browser. - * - * For `eval` commands, we need to pass the JS code as a single quoted argument - * to prevent the shell from interpreting parentheses, brackets, etc. - * For other commands, simple joining is fine. + * Build an executable + argv invocation for agent-browser. */ -export function buildShellCommand( +export function buildExecInvocation( args: string[], sessionName?: string, structuredOutput = false, -): string { +): AgentBrowserInvocation { assertControlledAgentBrowserCommand(args); - if (args[0] === 'eval' && args.length > 1) { - const jsCode = args.slice(1).join(' '); - const escaped = jsCode.replace(/'/g, "'\\''"); - return buildAgentBrowserCommand(`eval '${escaped}'`, { - json: structuredOutput, - session: sessionName, - }); - } - - const quotedArgs = args.map((arg) => { - if (/[(){}[\]$`!#&|;<>*? "'\\]/.test(arg)) { - const escaped = arg.replace(/'/g, "'\\''"); - return `'${escaped}'`; - } - return arg; - }); - return buildAgentBrowserCommand(quotedArgs.join(' '), { + return buildAgentBrowserInvocation(normalizeExecArgs(args), { json: structuredOutput, session: sessionName, }); @@ -125,18 +105,21 @@ function captureElementData( // Strategy 1: Try id-based selector (works for inputs with id attributes) let elemId = ''; - try { elemId = ab(`get attr ${ref} id`, { session: sessionName }); } catch { /* empty */ } + try { elemId = ab(['get', 'attr', ref, 'id'], { session: sessionName }); } catch { /* empty */ } if (elemId) { try { - const raw = ab(`get box '#${elemId}'`, { session: sessionName }); + const raw = ab(['get', 'box', `#${elemId}`], { session: sessionName }); bbox = JSON.parse(raw); } catch { /* empty */ } // For inputs, get label from associated