import { chmodSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, unlinkSync, writeSync, } from "node:fs"; import { dirname, basename, resolve } from "node:path"; import { BROWSER_RUNTIME_OPERATIONS, FAST_BROWSER_REPORT_VERSION, type BrowserErrorDetails, type PrivateArtifactMetadata, type ScenarioOperationReport as RuntimeScenarioOperationReport, type ScenarioReport as RuntimeScenarioReport, } from "./contracts.ts"; export const SCENARIO_REPORT_VERSION = FAST_BROWSER_REPORT_VERSION; const MAX_REPORT_TEXT = 2_000; const MAX_REPORT_ITEMS = 64; const MAX_EVIDENCE = 32; const MAX_PATH = 4_000; const FIXED_EVIDENCE_REASONS = new Set([ "browser session is unavailable", "evidence capture was cancelled", "browser transport is unavailable", "browser returned no complete failure evidence", "partial failure evidence", ]); /** Published runtime report type; kept as an alias so callers use contracts.ts as the source of truth. */ export type PublishedScenarioReport = RuntimeScenarioReport; export type ScenarioStatus = "passed" | "failed" | "skipped" | "blocked" | "not_run" | "validated"; export type CleanupStatus = "passed" | "failed" | "not_run"; export type EvidenceKind = "screenshot" | "console" | "request" | "other"; export interface ScenarioEvidenceReference { kind: EvidenceKind; artifactId: string; status: "captured" | "failed"; /** A basename or caller-selected local relative label; never a URL/content blob. */ file?: string; /** Private filesystem path retained for local report/evidence lifetime checks. */ retainedPath?: string; private: true; forwardedToModel: false; error?: string; } export interface ScenarioCaseReport { id: string; status: ScenarioStatus; elapsedMs: number; operations?: RuntimeScenarioOperationReport[]; phase?: string; browser?: { product?: string; headless?: boolean; owned?: boolean; profile?: string; sessionId?: string; }; failure?: { phase: string; message: string; }; evidence?: ScenarioEvidenceReference[]; cleanup?: { status: CleanupStatus; detail: string; }; } /** CLI validation/matrix envelope; execution reports use contracts.ts ScenarioReport. */ export interface ScenarioReportEnvelope { version: typeof SCENARIO_REPORT_VERSION; kind: "fast-browser-scenario-report"; generatedAt: string; status: ScenarioStatus; elapsedMs: number; scenarios: ScenarioCaseReport[]; evidence: ScenarioEvidenceReference[]; cleanup: { status: CleanupStatus; detail: string; }; /** Original failure is independent of evidence-capture/cleanup failures. */ failure?: { scenarioId?: string; phase: string; message: string; }; } export interface ScenarioFailureInput { scenarioId?: unknown; phase?: unknown; error?: unknown; secrets?: Iterable; } export interface BuildScenarioReportInput { /** Known caller-held secrets used only for final report redaction. */ secrets?: Iterable; status: ScenarioStatus; elapsedMs?: unknown; scenarios?: readonly unknown[]; cleanup?: unknown; failure?: ScenarioFailureInput; evidence?: readonly unknown[]; generatedAt?: string; } export class ScenarioReportError extends Error { override name = "ScenarioReportError"; } function text(value: unknown, fallback: string, max = MAX_REPORT_TEXT): string { if (typeof value !== "string") return fallback; const normalized = value.replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim(); if (!normalized) return fallback; return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized; } function knownSecrets(secrets: Iterable): string[] { return [...secrets].filter((secret) => typeof secret === "string" && secret.length > 0); } function redactKnownText(value: string, secrets: readonly string[]): string { let result = value; for (const secret of secrets) result = result.split(secret).join("[redacted]"); return result; } function safeNumber(value: unknown, fallback = 0, max = 120_000): number { return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(0, Math.round(value))) : fallback; } function safeId(value: unknown, fallback = "scenario"): string { const result = text(value, fallback, 128); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(result) ? result : fallback; } function redactUrl(value: unknown): string | undefined { if (typeof value !== "string") return undefined; try { const parsed = new URL(value); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return "[redacted-url]"; // Reports carry only an origin. Paths, queries, fragments, and tokens are // deliberately private to the browser worker. return parsed.origin; } catch { return undefined; } } /** Sanitize an error without exposing credential values or provider responses. */ export function sanitizeReportError(error: unknown, secrets: Iterable = []): string { let message = error instanceof Error ? error.message : typeof error === "string" ? error : "operation failed"; const replacements = new Set(); for (const secret of secrets) if (typeof secret === "string" && secret.length > 0) replacements.add(secret); for (const secret of replacements) message = message.split(secret).join("[redacted]"); message = message .replace(/(?:https?|wss?):\/\/[^\s)]+/gi, (url) => url.toLowerCase().startsWith("ws") ? "[redacted-url]" : (redactUrl(url) ?? "[redacted-url]")) .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^,;\s]+/gi, "$1: [redacted]") .replace(/\b(?:bearer|basic)\s+[^\s]+/gi, "$1 [redacted]") .replace(/([?&](?:token|key|secret|password|passwd|auth|code)=)[^&#\s]*/gi, "$1[redacted]") .replace(/\b(cookie|set-cookie|password|passwd|secret|token|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, (_match, key: string) => `${key}=[redacted]`); return text(message, "operation failed"); } function safeArtifactId(value: unknown): string { const id = text(value, "evidence", 128); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : "evidence"; } function safeEvidenceReason(value: unknown, secrets: readonly string[]): string { const reason = sanitizeReportError(value, secrets); return FIXED_EVIDENCE_REASONS.has(reason) ? reason : "failure evidence unavailable"; } function sanitizeEvidence(value: unknown, secrets: readonly string[] = []): ScenarioEvidenceReference | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value as Record; const kind = source.kind === "screenshot" || source.kind === "console" || source.kind === "request" || source.kind === "other" ? source.kind : undefined; if (!kind) return undefined; const file = source.file === undefined ? undefined : redactKnownText(text(source.file, "", 255), secrets); const retainedPath = source.retainedPath === undefined ? undefined : redactKnownText(text(source.retainedPath, "", MAX_PATH), secrets); if (file?.includes("://") || file?.includes("\\") || file?.includes("\0")) return undefined; if (retainedPath?.includes("://") || retainedPath?.includes("\\") || retainedPath?.includes("\0")) return undefined; const error = source.error === undefined ? undefined : safeEvidenceReason(source.error, secrets); const result: ScenarioEvidenceReference = { kind, artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "evidence", 128), secrets)), status: source.status === "captured" ? "captured" : "failed", private: true, forwardedToModel: false, ...(retainedPath ? { file: retainedPath } : file ? { file: basename(file) } : {}), ...(retainedPath ? { retainedPath } : {}), // Only fixed runtime producer reasons cross this boundary; arbitrary // diagnostics are replaced with a generic unavailable reason. ...(error ? { error } : {}), }; return result; } function sanitizeBrowser(value: unknown, secrets: readonly string[] = []): ScenarioCaseReport["browser"] { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value as Record; const browser: NonNullable = {}; if (source.product === "vivaldi" || source.product === "chrome" || source.product === "chromium") browser.product = source.product; if (typeof source.headless === "boolean") browser.headless = source.headless; if (typeof source.owned === "boolean") browser.owned = source.owned; if (source.profile !== undefined) { const profile = redactKnownText(text(source.profile, "", 64), secrets); if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) browser.profile = profile; } if (source.sessionId !== undefined) browser.sessionId = redactKnownText(text(source.sessionId, "", 200), secrets); return Object.keys(browser).length ? browser : undefined; } function sanitizeCleanup(value: unknown, secrets: readonly string[] = []): { status: CleanupStatus; detail: string } { if (!value || typeof value !== "object" || Array.isArray(value)) return { status: "not_run", detail: "cleanup not run" }; const source = value as Record; const status: CleanupStatus = source.status === "passed" || source.status === "failed" ? source.status : "not_run"; const fallback = status === "passed" ? "cleanup completed" : "cleanup not run"; return { status, detail: source.detail === undefined ? fallback : sanitizeReportError(source.detail, secrets) }; } function sanitizeScenarioOperation(value: unknown, index: number, secrets: Iterable): RuntimeScenarioOperationReport | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value as Record; const operationNames = ["step", "read", "assert", "navigate", "reload", "listTabs", "selectTab", "waitPopup", "closeTab"] as const; if (!operationNames.includes(source.op as typeof operationNames[number])) return undefined; return { index: safeNumber(source.index, index, MAX_REPORT_ITEMS), op: source.op as RuntimeScenarioOperationReport["op"], status: source.status === "passed" || source.status === "skipped" ? source.status : "failed", elapsedMs: safeNumber(source.elapsedMs), mutationState: source.mutationState === "not_attempted" || source.mutationState === "verified" ? source.mutationState : "unknown", ...(source.error ? { error: sanitizeRuntimeError(source.error, secrets) } : {}), }; } function sanitizeCase(value: unknown, secrets: readonly string[], index: number): ScenarioCaseReport { if (!value || typeof value !== "object" || Array.isArray(value)) { return { id: `scenario-${index + 1}`, status: "failed", elapsedMs: 0, failure: { phase: "report", message: "invalid scenario result" } }; } const source = value as Record; const statuses: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"]; const status = statuses.includes(source.status as ScenarioStatus) ? source.status as ScenarioStatus : "failed"; const result: ScenarioCaseReport = { id: safeId(source.id, `scenario-${index + 1}`), status, elapsedMs: safeNumber(source.elapsedMs), ...(source.phase === undefined ? {} : { phase: text(source.phase, "unknown", 128) }), ...(sanitizeBrowser(source.browser, secrets) === undefined ? {} : { browser: sanitizeBrowser(source.browser, secrets) }), }; if (Array.isArray(source.operations)) { const operations = source.operations.slice(0, MAX_REPORT_ITEMS).map((entry, operationIndex) => sanitizeScenarioOperation(entry, operationIndex, secrets)).filter((entry): entry is RuntimeScenarioOperationReport => Boolean(entry)); if (operations.length) result.operations = operations; } const rawFailure = source.failure; if (rawFailure && typeof rawFailure === "object" && !Array.isArray(rawFailure)) { const failure = rawFailure as Record; result.failure = { phase: text(failure.phase, "unknown", 128), message: sanitizeReportError(failure.message, secrets), }; } if (source.cleanup !== undefined) result.cleanup = sanitizeCleanup(source.cleanup, secrets); if (Array.isArray(source.evidence)) { const evidence = source.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry)); if (evidence.length) result.evidence = evidence; } return result; } function reportStatus(status: unknown): ScenarioStatus { const values: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"]; return values.includes(status as ScenarioStatus) ? status as ScenarioStatus : "failed"; } /** Build a bounded machine-readable report from runtime-neutral result data. */ export function createScenarioReport(input: BuildScenarioReportInput): ScenarioReportEnvelope { const secrets = knownSecrets(input.secrets ?? input.failure?.secrets ?? []); const cases = Array.isArray(input.scenarios) ? input.scenarios.slice(0, MAX_REPORT_ITEMS).map((entry, index) => sanitizeCase(entry, secrets, index)) : []; const evidence = Array.isArray(input.evidence) ? input.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry)) : []; const cleanup = sanitizeCleanup(input.cleanup, secrets); const report: ScenarioReportEnvelope = { version: SCENARIO_REPORT_VERSION, kind: "fast-browser-scenario-report", generatedAt: input.generatedAt && !Number.isNaN(Date.parse(input.generatedAt)) ? new Date(input.generatedAt).toISOString() : new Date().toISOString(), status: reportStatus(input.status), elapsedMs: safeNumber(input.elapsedMs), scenarios: cases, evidence, cleanup, }; if (input.failure) { report.failure = { ...(input.failure.scenarioId === undefined ? {} : { scenarioId: safeId(input.failure.scenarioId) }), phase: text(input.failure.phase, "unknown", 128), message: sanitizeReportError(input.failure.error, secrets), }; } return report; } function sanitizeArtifactMetadata(value: unknown, secrets: readonly string[] = []): PrivateArtifactMetadata | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value as Record; const kind = source.kind === "screenshot" || source.kind === "diagnostics" ? source.kind : undefined; const contentType = source.contentType === "image/png" || source.contentType === "application/json" ? source.contentType : undefined; const owner = source.owner; if (!kind || !contentType || !owner || typeof owner !== "object" || Array.isArray(owner)) return undefined; const ownerRecord = owner as Record; const path = typeof source.path === "string" && !source.path.includes("\\") && !source.path.includes("://") ? redactKnownText(basename(source.path), secrets) : "private-artifact"; return { artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "artifact", 128), secrets)), kind, contentType, path, bytes: safeNumber(source.bytes, 0, 60 * 1024 * 1024), owner: { sessionId: redactKnownText(text(ownerRecord.sessionId, "session", 200), secrets), runId: redactKnownText(text(ownerRecord.runId, "run", 200), secrets), operationId: redactKnownText(text(ownerRecord.operationId, "operation", 200), secrets), }, permissions: "0600", localOnly: true, modelForwarded: false, }; } /** Convert a published private artifact to a report-only local reference. */ export function evidenceReference(metadata: PrivateArtifactMetadata): ScenarioEvidenceReference | undefined { const safe = sanitizeArtifactMetadata(metadata); if (!safe) return undefined; return { kind: safe.kind === "screenshot" ? "screenshot" : "other", artifactId: safe.artifactId, status: "captured", file: safe.path, ...(typeof metadata.path === "string" ? { retainedPath: metadata.path } : {}), private: true, forwardedToModel: false, }; } function sanitizeRuntimeError(value: unknown, secrets: Iterable): BrowserErrorDetails | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value as Record; const codes = ["aborted", "timeout", "stale", "policy_denied", "invalid_request", "no_tab", "ambiguous_tab", "browser_closed", "unsupported", "worker_failure"] as const; const retries = ["never", "safe"] as const; const mutations = ["not_attempted", "verified", "unknown"] as const; const operationValue = text(source.operation, "unknown", 64); const operation = BROWSER_RUNTIME_OPERATIONS.includes(operationValue as typeof BROWSER_RUNTIME_OPERATIONS[number]) ? operationValue as BrowserErrorDetails["operation"] : "observe"; const code = codes.includes(source.code as typeof codes[number]) ? source.code as typeof codes[number] : "worker_failure"; const mutationState = mutations.includes(source.mutationState as typeof mutations[number]) ? source.mutationState as typeof mutations[number] : "unknown"; const retry = retries.includes(source.retry as typeof retries[number]) ? source.retry as typeof retries[number] : "never"; return { code, operation, phase: text(source.phase, "unknown", 128), message: sanitizeReportError(source.message, secrets), mutationState, retry, }; } /** Sanitize the published runtime report without changing its contract shape. */ export function sanitizeRuntimeScenarioReport(report: RuntimeScenarioReport, secrets: Iterable = []): RuntimeScenarioReport { const safeSecrets = knownSecrets(secrets); const operations: RuntimeScenarioOperationReport[] = Array.isArray(report.operations) ? report.operations.slice(0, MAX_REPORT_ITEMS).map((operation, index) => ({ index: safeNumber(operation?.index, index, MAX_REPORT_ITEMS), op: operation?.op, status: operation?.status === "passed" || operation?.status === "skipped" ? operation.status : "failed", elapsedMs: safeNumber(operation?.elapsedMs), mutationState: operation?.mutationState === "not_attempted" || operation?.mutationState === "verified" ? operation.mutationState : "unknown", ...(operation?.error ? { error: sanitizeRuntimeError(operation.error, safeSecrets) } : {}), })) : []; const evidence = Array.isArray(report.evidence) ? report.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeArtifactMetadata(entry, safeSecrets)).filter((entry): entry is PrivateArtifactMetadata => Boolean(entry)) : []; return { version: FAST_BROWSER_REPORT_VERSION, scenarioId: safeId(report.scenarioId), status: report.status === "passed" || report.status === "skipped" ? report.status : "failed", elapsedMs: safeNumber(report.elapsedMs), operations, evidence, cleanup: report.cleanup === "complete" ? "complete" : "failed", }; } /** * Serialize only the public report shape. This second pass protects callers * that retain a report object and later mutate an untrusted input object. */ export function sanitizeScenarioReport(report: ScenarioReportEnvelope, secrets: Iterable = []): ScenarioReportEnvelope { return createScenarioReport({ status: report.status, elapsedMs: report.elapsedMs, generatedAt: report.generatedAt, scenarios: report.scenarios, evidence: report.evidence, cleanup: report.cleanup, ...(report.failure ? { failure: { ...report.failure, error: report.failure.message } } : {}), secrets: knownSecrets(secrets), }); } function assertNoSymlinkPath(path: string): void { let current = resolve(path); while (true) { try { lstatSync(current); break; } catch { const next = dirname(current); if (next === current) return; current = next; } } if (realpathSync(current) !== current) throw new ScenarioReportError("report path may not contain symlinks"); } function assertSafeReportDirectory(path: string): void { const absolute = resolve(path); const info = lstatSync(absolute); if (!info.isDirectory() || info.isSymbolicLink()) throw new ScenarioReportError("report directory must be a regular directory"); if (realpathSync(absolute) !== absolute) throw new ScenarioReportError("report directory may not contain symlinks"); if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new ScenarioReportError("report directory is not owned by this user"); // A normal owned 0755 project directory is safe; group/world writable // parents are not, and are never chmodded by report output. if ((info.mode & 0o022) !== 0) throw new ScenarioReportError("report directory must not be group/world writable"); let parent = dirname(absolute); while (true) { const parentInfo = lstatSync(parent); if (parentInfo.isSymbolicLink()) throw new ScenarioReportError("report parent may not contain symlinks"); // A sticky system scratch parent (for example /tmp) is acceptable when // the actual report directory is owned and non-writable by others. if ((parentInfo.mode & 0o022) !== 0 && (parentInfo.mode & 0o1000) === 0) throw new ScenarioReportError("report parent must not be group/world writable"); const next = dirname(parent); if (next === parent) break; parent = next; } } function validateOutputPath(path: string): string { if (typeof path !== "string" || path.length === 0 || path.length > MAX_PATH || path.includes("\0") || path.includes("://")) { throw new ScenarioReportError("report path must be a local bounded path"); } return resolve(path); } /** Atomically write a mode-0600 JSON report beneath an owned, non-writable-by-others directory. */ export function writeScenarioReport(path: string, report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable = []): void { const target = validateOutputPath(path); const directory = dirname(target); assertNoSymlinkPath(directory); mkdirSync(directory, { recursive: true, mode: 0o700 }); assertSafeReportDirectory(directory); const existing = (() => { try { return lstatSync(target); } catch { return undefined; } })(); if (existing) { if (existing.isSymbolicLink() || !existing.isFile()) throw new ScenarioReportError("report destination must be a regular file"); if (typeof process.getuid === "function" && existing.uid !== process.getuid()) throw new ScenarioReportError("report destination is not owned by this user"); } const safe = "scenarioId" in report ? sanitizeRuntimeScenarioReport(report, secrets) : sanitizeScenarioReport(report, secrets); const content = `${JSON.stringify(safe, null, 2)}\n`; const temporary = resolve(directory, `.${basename(target)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`); let fd: number | undefined; try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); writeSync(fd, content, undefined, "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined; chmodSync(temporary, 0o600); renameSync(temporary, target); } catch (error) { if (fd !== undefined) { try { closeSync(fd); } catch { /* best effort */ } } try { unlinkSync(temporary); } catch { /* preserve an existing report */ } if (error instanceof ScenarioReportError) throw error; throw new ScenarioReportError("unable to write scenario report"); } } /** Human-readable output never includes steps, field values, page text, or URLs. */ export function formatScenarioReport(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable = []): string { const safeSecrets = knownSecrets(secrets); if ("scenarioId" in report) { const safe = sanitizeRuntimeScenarioReport(report, safeSecrets); const lines = [ "Fast-browser scenario report", `Status: ${safe.status}`, `Scenario: ${safe.scenarioId}`, `Elapsed: ${safe.elapsedMs}ms`, `Operations: ${safe.operations.length}`, `Cleanup: ${safe.cleanup}`, ]; for (const operation of safe.operations) { lines.push(`${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`); if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`); } if (safe.evidence.length) lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`); return `${lines.join("\n")}\n`; } const safe = sanitizeScenarioReport(report, safeSecrets); const lines = [ "Fast-browser scenario report", `Status: ${safe.status}`, `Scenarios: ${safe.scenarios.length}`, `Elapsed: ${safe.elapsedMs}ms`, ]; for (const scenario of safe.scenarios) { lines.push(`${scenario.status.toUpperCase()} ${scenario.id} (${scenario.elapsedMs}ms)`); for (const operation of scenario.operations ?? []) { lines.push(` ${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`); if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`); } if (scenario.failure) lines.push(` Failure (${scenario.failure.phase}): ${scenario.failure.message}`); if (scenario.cleanup) lines.push(` Cleanup: ${scenario.cleanup.status} — ${scenario.cleanup.detail}`); } if (safe.failure) lines.push(`Failure (${safe.failure.phase}): ${safe.failure.message}`); lines.push(`Cleanup: ${safe.cleanup.status} — ${safe.cleanup.detail}`); if (safe.evidence.length) { lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`); for (const evidence of safe.evidence) { if (evidence.status === "failed" && evidence.error) lines.push(` Evidence ${evidence.kind}: ${evidence.error}`); } } return `${lines.join("\n")}\n`; } /** Serialize either report envelope through the same defensive sanitizer used by human output. */ export function formatScenarioReportJson(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable = []): string { const safeSecrets = knownSecrets(secrets); const safe = "scenarioId" in report ? sanitizeRuntimeScenarioReport(report, safeSecrets) : sanitizeScenarioReport(report, safeSecrets); return `${JSON.stringify(safe, null, 2)}\n`; }