import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { resolve } from "node:path"; import type { CredentialBindings, PageAssertion, Postcondition, ScenarioDefinition, ScenarioFixtureConfig, ScenarioHttpReadiness, ScenarioOperation, ScenarioOperationIndex, ScenarioProcessHook, ScenarioServerConfig, ScenarioTabIndex, ScenarioTabReference, ScenarioTabSelection, Step, } from "./contracts.ts"; import { FAST_BROWSER_SCENARIO_FILE_VERSION } from "./contracts.ts"; import { validateSteps } from "./controller.ts"; import { createCredentialResolver } from "./credentials.ts"; /** * The scenario loader is deliberately independent of browser execution. The * runtime contract owns session, assertion, and evidence execution; this file * only validates the declarative input that a caller explicitly selected. */ export const SCENARIO_SCHEMA_VERSION = FAST_BROWSER_SCENARIO_FILE_VERSION; export const MAX_SCENARIO_FILE_BYTES = 512 * 1024; export const MAX_SCENARIO_FILES = 32; export const MAX_SCENARIOS_PER_FILE = 32; export const MAX_SCENARIO_ID = 128; export const MAX_SCENARIO_DESCRIPTION = 4_000; export const MAX_HOOK_ARGUMENTS = 32; export const MAX_HOOK_ENVIRONMENT = 32; export const MAX_HOOK_TIMEOUT_MS = 120_000; export class ScenarioValidationError extends Error { override name = "ScenarioValidationError"; } export interface ScenarioHook { /** Executable name/path. The loader never turns page data into a command. */ command: string; /** Explicit argv entries; shell execution is never enabled by this schema. */ args: string[]; cwd?: string; /** Values are either explicit safe strings or references to process env. */ env?: Record; timeoutMs?: number; } export interface ScenarioHooks { setup?: ScenarioHook; reset?: ScenarioHook; cleanup?: ScenarioHook; } export interface ScenarioEvidencePolicy { /** Failure evidence is local-only and never forwarded to a model. */ onFailure: boolean; /** Explicit request evidence is local-only and never forwarded to a model. */ onRequest: boolean; } /** * A validated source definition. Browser operations use the published * ScenarioOperation union; settings/hooks are caller-owned scenario metadata * consumed by the eventual runner and never inferred from page content. */ export interface DeclarativeScenario extends ScenarioDefinition { description?: string; settings?: Record; credentials?: CredentialBindings; hooks?: ScenarioHooks; evidence?: ScenarioEvidencePolicy; budgets?: { maxSteps?: number; timeoutMs?: number; minConfidence?: number; }; } export interface ScenarioDocument { version: typeof SCENARIO_SCHEMA_VERSION; scenarios: DeclarativeScenario[]; } export interface LoadedScenario { scenario: DeclarativeScenario; path: string; index: number; } export interface LoadedScenarios { files: string[]; scenarios: LoadedScenario[]; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function boundedString(value: unknown, label: string, max: number, options: { trim?: boolean } = {}): string { if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) { throw new ScenarioValidationError(`${label} must be a bounded string`); } const result = options.trim === false ? value : value.trim(); if (!result) throw new ScenarioValidationError(`${label} must not be empty`); return result; } function assertKeys(value: Record, allowed: readonly string[], label: string): void { const accepted = new Set(allowed); if (Object.keys(value).some((key) => !accepted.has(key))) { throw new ScenarioValidationError(`${label} contains an unknown field`); } } function parseEnvironmentName(value: unknown, label: string): string { const name = boundedString(value, label, 256); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new ScenarioValidationError(`${label} must be an environment variable name`); } return name; } function parseHook(value: unknown, label: string): ScenarioHook { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["command", "args", "argv", "cwd", "env", "timeoutMs", "shell"], label); if (value.shell !== undefined && value.shell !== false) { throw new ScenarioValidationError(`${label}.shell must be false; shell execution is disabled`); } const hasCommand = value.command !== undefined; const hasArgv = value.argv !== undefined; if (hasCommand === hasArgv) { throw new ScenarioValidationError(`${label} requires exactly one command or argv`); } let command: string; let args: string[]; if (hasArgv) { if (!Array.isArray(value.argv) || value.argv.length < 1 || value.argv.length > MAX_HOOK_ARGUMENTS + 1) { throw new ScenarioValidationError(`${label}.argv must contain a bounded executable and arguments`); } const argv = value.argv.map((entry, index) => boundedString(entry, `${label}.argv[${index}]`, 4_000, { trim: false })); command = argv[0]!; args = argv.slice(1); } else { command = boundedString(value.command, `${label}.command`, 4_000, { trim: false }); if (value.args === undefined) args = []; else if (!Array.isArray(value.args) || value.args.length > MAX_HOOK_ARGUMENTS) { throw new ScenarioValidationError(`${label}.args must contain at most ${MAX_HOOK_ARGUMENTS} entries`); } else { args = value.args.map((entry, index) => boundedString(entry, `${label}.args[${index}]`, 4_000, { trim: false })); } } if (command.startsWith("-") || command.includes("\r") || command.includes("\n") || args.some((arg) => arg.includes("\r") || arg.includes("\n"))) { throw new ScenarioValidationError(`${label}.command is not a valid executable`); } const cwd = value.cwd === undefined ? undefined : boundedString(value.cwd, `${label}.cwd`, 4_000, { trim: false }); let env: Record | undefined; if (value.env !== undefined) { if (!isRecord(value.env) || Object.keys(value.env).length > MAX_HOOK_ENVIRONMENT) { throw new ScenarioValidationError(`${label}.env must be a bounded object`); } env = {}; for (const [name, raw] of Object.entries(value.env)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new ScenarioValidationError(`${label}.env contains an invalid variable name`); } if (typeof raw === "string") { if (raw.length > 4_000 || raw.includes("\0") || raw.includes("\r") || raw.includes("\n")) { throw new ScenarioValidationError(`${label}.env contains an oversized value`); } env[name] = raw; } else if (isRecord(raw) && Object.keys(raw).length === 1 && Object.hasOwn(raw, "env")) { env[name] = { env: parseEnvironmentName(raw.env, `${label}.env.${name}.env`) }; } else { throw new ScenarioValidationError(`${label}.env values must be strings or environment references`); } } } let timeoutMs: number | undefined; if (value.timeoutMs !== undefined) { const rawTimeout = value.timeoutMs; if (typeof rawTimeout !== "number" || !Number.isSafeInteger(rawTimeout) || rawTimeout < 1 || rawTimeout > MAX_HOOK_TIMEOUT_MS) { throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`); } timeoutMs = rawTimeout; } return { command, args, ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), ...(timeoutMs === undefined ? {} : { timeoutMs }), }; } function parseHooks(value: unknown, label: string): ScenarioHooks { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["setup", "reset", "cleanup"], label); return { ...(value.setup === undefined ? {} : { setup: parseHook(value.setup, `${label}.setup`) }), ...(value.reset === undefined ? {} : { reset: parseHook(value.reset, `${label}.reset`) }), ...(value.cleanup === undefined ? {} : { cleanup: parseHook(value.cleanup, `${label}.cleanup`) }), }; } function parseEvidence(value: unknown, label: string): ScenarioEvidencePolicy { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["onFailure", "onRequest"], label); if (value.onFailure !== undefined && typeof value.onFailure !== "boolean") { throw new ScenarioValidationError(`${label}.onFailure must be a boolean`); } if (value.onRequest !== undefined && typeof value.onRequest !== "boolean") { throw new ScenarioValidationError(`${label}.onRequest must be a boolean`); } return { onFailure: value.onFailure ?? true, onRequest: value.onRequest ?? false }; } function parseReadiness(value: unknown, label: string): ScenarioHttpReadiness { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["url", "expectedStatus", "timeoutMs"], label); const expectedStatus = value.expectedStatus === undefined ? undefined : value.expectedStatus; if (expectedStatus !== undefined && (typeof expectedStatus !== "number" || !Number.isSafeInteger(expectedStatus) || expectedStatus < 100 || expectedStatus > 599)) { throw new ScenarioValidationError(`${label}.expectedStatus is out of bounds`); } const timeoutMs = value.timeoutMs === undefined ? undefined : value.timeoutMs; if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)) { throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`); } return { url: parseHttpUrl(value.url, `${label}.url`), ...(expectedStatus === undefined ? {} : { expectedStatus }), ...(timeoutMs === undefined ? {} : { timeoutMs }), }; } function parseFixture(value: unknown, label: string): ScenarioFixtureConfig { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["baseUrl", "server"], label); let server: ScenarioServerConfig | undefined; if (value.server !== undefined) { if (!isRecord(value.server)) throw new ScenarioValidationError(`${label}.server must be an object`); if (value.server.mode === "external") { assertKeys(value.server, ["mode", "readiness"], `${label}.server`); server = { mode: "external", readiness: parseReadiness(value.server.readiness, `${label}.server.readiness`) }; } else if (value.server.mode === "managed") { assertKeys(value.server, ["mode", "start", "readiness", "shutdownTimeoutMs"], `${label}.server`); const shutdownTimeoutMs = value.server.shutdownTimeoutMs === undefined ? undefined : value.server.shutdownTimeoutMs; if (shutdownTimeoutMs !== undefined && (typeof shutdownTimeoutMs !== "number" || !Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 10_000)) { throw new ScenarioValidationError(`${label}.server.shutdownTimeoutMs is out of bounds`); } server = { mode: "managed", start: parseHook(value.server.start, `${label}.server.start`) as ScenarioProcessHook, readiness: parseReadiness(value.server.readiness, `${label}.server.readiness`), ...(shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs }), }; } else { throw new ScenarioValidationError(`${label}.server.mode must be managed or external`); } } return { baseUrl: parseHttpUrl(value.baseUrl, `${label}.baseUrl`), ...(server === undefined ? {} : { server }), }; } function parseBudgets(value: unknown, label: string, stepCount: number): DeclarativeScenario["budgets"] { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["maxSteps", "timeoutMs", "minConfidence"], label); const maxSteps = value.maxSteps === undefined ? undefined : value.maxSteps; const timeoutMs = value.timeoutMs === undefined ? undefined : value.timeoutMs; const minConfidence = value.minConfidence === undefined ? undefined : value.minConfidence; if (maxSteps !== undefined && (typeof maxSteps !== "number" || !Number.isSafeInteger(maxSteps) || maxSteps < 1 || maxSteps > 10 || stepCount > maxSteps)) { throw new ScenarioValidationError(`${label}.maxSteps is out of bounds`); } if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)) { throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`); } if (minConfidence !== undefined && (typeof minConfidence !== "number" || !Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1)) { throw new ScenarioValidationError(`${label}.minConfidence is out of bounds`); } return { ...(maxSteps === undefined ? {} : { maxSteps: maxSteps as number }), ...(timeoutMs === undefined ? {} : { timeoutMs: timeoutMs as number }), ...(minConfidence === undefined ? {} : { minConfidence }), }; } function parseStep(value: unknown, index: number): Step { if (!isRecord(value)) throw new ScenarioValidationError(`step[${index}] must be an object`); assertKeys(value, ["instruction", "action", "text", "compose", "credential", "postcondition"], `step[${index}]`); if (value.postcondition !== undefined) { if (!isRecord(value.postcondition)) throw new ScenarioValidationError(`step[${index}].postcondition must be an object`); assertKeys(value.postcondition, ["kind", "value"], `step[${index}].postcondition`); } return { instruction: value.instruction as string, action: value.action as Step["action"], ...(value.text === undefined ? {} : { text: value.text as string }), ...(value.compose === undefined ? {} : { compose: value.compose as string }), ...(value.credential === undefined ? {} : { credential: value.credential as string }), ...(value.postcondition === undefined ? {} : { postcondition: value.postcondition as Postcondition }), }; } interface PriorScenarioOperation { index: number; id?: string; op: ScenarioOperation["op"]; } function parseHttpUrl(value: unknown, label: string): string { const url = boundedString(value, label, 8_000); let parsed: URL; try { parsed = new URL(url); } catch { throw new ScenarioValidationError(`${label} must be an HTTP(S) URL`); } if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) { throw new ScenarioValidationError(`${label} must be an HTTP(S) URL without credentials`); } return url; } function parseOrigin(value: unknown, label: string): string { const url = parseHttpUrl(value, label); const parsed = new URL(url); if (url !== parsed.origin) throw new ScenarioValidationError(`${label} must be an exact HTTP(S) origin`); return url; } function parseBoundedIndex(value: unknown, label: string): ScenarioTabIndex { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 31) { throw new ScenarioValidationError(`${label} must be an integer from 0 through 31`); } return value as ScenarioTabIndex; } function parseOperationIndex(value: unknown, label: string): ScenarioOperationIndex { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 31) { throw new ScenarioValidationError(`${label} must be an integer from 0 through 31`); } return value as ScenarioOperationIndex; } function parseOperationRef(value: unknown, label: string, prior: readonly PriorScenarioOperation[]): PriorScenarioOperation { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["index", "id"], label); if ((value.index === undefined) === (value.id === undefined)) throw new ScenarioValidationError(`${label} must contain exactly one of index or id`); let operation: PriorScenarioOperation | undefined; if (value.index !== undefined) { const index = parseOperationIndex(value.index, `${label}.index`); operation = prior.find((candidate) => candidate.index === index); } else { const id = boundedString(value.id, `${label}.id`, MAX_SCENARIO_ID); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new ScenarioValidationError(`${label}.id has invalid characters`); operation = prior.find((candidate) => candidate.id === id); } if (!operation) throw new ScenarioValidationError(`${label} must reference an earlier operation`); return operation; } function parseTabReference(value: unknown, label: string, prior: readonly PriorScenarioOperation[]): ScenarioTabReference { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["source", "target", "operation", "result", "index"], label); if (value.source === "initial") { if (value.target !== "selected" || value.operation !== undefined || value.result !== undefined || value.index !== undefined) { throw new ScenarioValidationError(`${label} initial references must target selected`); } return { source: "initial", target: "selected" }; } if (value.source !== "operation" || value.target !== undefined) throw new ScenarioValidationError(`${label}.source is unsupported`); if (value.result !== "selectedTab" && value.result !== "popup" && value.result !== "listedTab") { throw new ScenarioValidationError(`${label}.result is unsupported`); } const operation = parseOperationRef(value.operation, `${label}.operation`, prior); if (value.result === "selectedTab" && operation.op !== "selectTab") throw new ScenarioValidationError(`${label}.result selectedTab requires an earlier selectTab operation`); if (value.result === "popup" && operation.op !== "waitPopup") throw new ScenarioValidationError(`${label}.result popup requires an earlier waitPopup operation`); if (value.result === "listedTab" && operation.op !== "listTabs") throw new ScenarioValidationError(`${label}.result listedTab requires an earlier listTabs operation`); if (value.result !== "listedTab" && value.index !== undefined) throw new ScenarioValidationError(`${label}.index is only valid for listedTab`); return value.result === "listedTab" ? { source: "operation", operation: operation.id === undefined ? { index: operation.index as ScenarioOperationIndex } : { id: operation.id }, result: "listedTab", index: parseBoundedIndex(value.index, `${label}.index`) } : { source: "operation", operation: operation.id === undefined ? { index: operation.index as ScenarioOperationIndex } : { id: operation.id }, result: value.result }; } function parseTabSelection(value: unknown, label: string): ScenarioTabSelection { if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); assertKeys(value, ["kind", "index", "origin"], label); if (value.kind === "index") { if (value.origin !== undefined) throw new ScenarioValidationError(`${label}.origin is not valid for index selection`); return { kind: "index", index: parseBoundedIndex(value.index, `${label}.index`) }; } if (value.kind === "origin") { if (value.index !== undefined) throw new ScenarioValidationError(`${label}.index is not valid for origin selection`); return { kind: "origin", origin: parseOrigin(value.origin, `${label}.origin`) }; } throw new ScenarioValidationError(`${label}.kind is unsupported`); } function parseOperationId(value: unknown, label: string): string | undefined { if (value === undefined) return undefined; const id = boundedString(value, label, MAX_SCENARIO_ID); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new ScenarioValidationError(`${label} has invalid characters`); return id; } function parseScenarioOperation(value: unknown, index: number, prior: readonly PriorScenarioOperation[]): ScenarioOperation { const label = `operations[${index}]`; if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`); const op = value.op; const id = parseOperationId(value.id, `${label}.id`); const metadata = id === undefined ? {} : { id }; if (op === "step") { assertKeys(value, ["op", "step", "id"], label); return { op: "step", step: parseStep(value.step, index), ...metadata }; } if (op === "read") { assertKeys(value, ["op", "request", "id"], label); if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); assertKeys(value.request, ["tab"], `${label}.request`); return { op: "read", request: value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata }; } if (op === "assert") { assertKeys(value, ["op", "request", "id"], label); if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); assertKeys(value.request, ["tab", "assertion", "wait"], `${label}.request`); if (value.request.wait !== "none" && value.request.wait !== "bounded") throw new ScenarioValidationError(`${label}.request.wait must be none or bounded`); if (!isRecord(value.request.assertion)) throw new ScenarioValidationError(`${label}.request.assertion must be an object`); assertKeys(value.request.assertion, ["kind", "match", "value"], `${label}.request.assertion`); const assertion = value.request.assertion; if (assertion.kind !== "url" && assertion.kind !== "title" && assertion.kind !== "visibleText") throw new ScenarioValidationError(`${label}.request.assertion.kind is unsupported`); const allowedMatches = assertion.kind === "url" ? ["exact", "origin"] : assertion.kind === "title" ? ["exact", "contains"] : ["contains"]; if (!allowedMatches.includes(assertion.match as string)) throw new ScenarioValidationError(`${label}.request.assertion.match is unsupported`); const assertionValue = assertion.kind === "url" ? parseHttpUrl(assertion.value, `${label}.request.assertion.value`) : boundedString(assertion.value, `${label}.request.assertion.value`, 4_000, { trim: false }); const parsedAssertion = { kind: assertion.kind, match: assertion.match, value: assertionValue } as PageAssertion; return { op: "assert", request: { assertion: parsedAssertion, wait: value.request.wait, ...(value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }) }, ...metadata }; } if (op === "navigate") { assertKeys(value, ["op", "request", "id"], label); if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); assertKeys(value.request, ["tab", "url"], `${label}.request`); return { op: "navigate", request: { url: parseHttpUrl(value.request.url, `${label}.request.url`), ...(value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }) }, ...metadata }; } if (op === "reload") { assertKeys(value, ["op", "request", "id"], label); if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); assertKeys(value.request, ["tab"], `${label}.request`); return { op: "reload", request: value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata }; } if (op === "listTabs") { assertKeys(value, ["op", "request", "id"], label); if (value.request === undefined) return { op: "listTabs", ...metadata }; if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); assertKeys(value.request, ["select"], `${label}.request`); return { op: "listTabs", request: value.request.select === undefined ? {} : { select: parseTabSelection(value.request.select, `${label}.request.select`) }, ...metadata }; } if (op === "selectTab" || op === "waitPopup" || op === "closeTab") { assertKeys(value, ["op", "request", "id"], label); if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`); const key = op === "waitPopup" ? "opener" : "tab"; assertKeys(value.request, [key], `${label}.request`); if (value.request[key] === undefined) throw new ScenarioValidationError(`${label}.request.${key} is required`); if (op === "selectTab") return { op, request: { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata }; if (op === "waitPopup") return { op, request: { opener: parseTabReference(value.request.opener, `${label}.request.opener`, prior) }, ...metadata }; return { op, request: { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata }; } throw new ScenarioValidationError(`${label}.op is not supported by scenarios`); } function parseScenario(value: unknown, index: number, documentVersion: number): DeclarativeScenario { if (!isRecord(value)) throw new ScenarioValidationError(`scenarios[${index}] must be an object`); // Runtime-owned fields can be added later, but these source fields must not // be silently ignored while the schema is version 1. assertKeys(value, [ "version", "id", "description", "operations", "settings", "credentials", "hooks", "evidence", "budgets", "fixture", ], `scenarios[${index}]`); if (value.version !== SCENARIO_SCHEMA_VERSION) { throw new ScenarioValidationError(`scenarios[${index}].version is unsupported`); } if (documentVersion !== SCENARIO_SCHEMA_VERSION) throw new ScenarioValidationError("scenario document version is unsupported"); const id = boundedString(value.id, `scenarios[${index}].id`, MAX_SCENARIO_ID); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) { throw new ScenarioValidationError(`scenarios[${index}].id has invalid characters`); } const rawOperations = value.operations; if (!Array.isArray(rawOperations) || rawOperations.length < 1 || rawOperations.length > 32) { throw new ScenarioValidationError(`scenarios[${index}].operations must contain 1–32 operations`); } const prior: PriorScenarioOperation[] = []; const operations = rawOperations.map((operation, operationIndex) => { const parsed = parseScenarioOperation(operation, operationIndex, prior); if (parsed.id !== undefined && prior.some((candidate) => candidate.id === parsed.id)) { throw new ScenarioValidationError(`operations[${operationIndex}].id is duplicated`); } prior.push({ index: operationIndex, ...(parsed.id === undefined ? {} : { id: parsed.id }), op: parsed.op }); return parsed; }); const steps = operations.filter((operation): operation is { op: "step"; step: Step } => operation.op === "step").map((operation) => operation.step); const budgets = value.budgets === undefined ? undefined : parseBudgets(value.budgets, `scenarios[${index}].budgets`, steps.length); const fixture = value.fixture === undefined ? undefined : parseFixture(value.fixture, `scenarios[${index}].fixture`); try { if (steps.length) validateSteps(steps, budgets?.maxSteps ?? 10); } catch { throw new ScenarioValidationError(`scenarios[${index}].steps are invalid`); } const description = value.description === undefined ? undefined : boundedString(value.description, `scenarios[${index}].description`, MAX_SCENARIO_DESCRIPTION, { trim: false }); let settings: Record | undefined; if (value.settings !== undefined) { if (!isRecord(value.settings) || Object.keys(value.settings).length > 32) { throw new ScenarioValidationError(`scenarios[${index}].settings must be a bounded object`); } settings = structuredClone(value.settings); } const hasComposition = steps.some((step) => step.compose !== undefined); if (hasComposition && (typeof settings?.textModel !== "string" || !settings.textModel.trim())) { throw new ScenarioValidationError(`scenarios[${index}] composition requires an explicit settings.textModel`); } let credentials: CredentialBindings | undefined; if (value.credentials !== undefined) { if (!isRecord(value.credentials) || Object.keys(value.credentials).length > 40) { throw new ScenarioValidationError(`scenarios[${index}].credentials must be a bounded object`); } credentials = structuredClone(value.credentials) as CredentialBindings; // Validate declarations now; resolver access remains lazy and no value is read. try { createCredentialResolver(credentials, { cwd: process.cwd(), env: {} }); } catch { throw new ScenarioValidationError(`scenarios[${index}].credentials are invalid`); } } const hooks = value.hooks === undefined ? undefined : parseHooks(value.hooks, `scenarios[${index}].hooks`); const evidence = value.evidence === undefined ? undefined : parseEvidence(value.evidence, `scenarios[${index}].evidence`); return { version: SCENARIO_SCHEMA_VERSION, id, ...(description === undefined ? {} : { description }), operations, ...(settings === undefined ? {} : { settings }), ...(credentials === undefined ? {} : { credentials }), ...(hooks === undefined ? {} : { hooks }), ...(evidence === undefined ? {} : { evidence }), ...(budgets === undefined ? {} : { budgets }), ...(fixture === undefined ? {} : { fixture }), }; } /** Parse and validate one already-decoded declarative JSON value. */ export function parseScenarioDocument(value: unknown): ScenarioDocument { if (!isRecord(value)) throw new ScenarioValidationError("scenario file must contain a JSON object"); if (value.version !== SCENARIO_SCHEMA_VERSION) { throw new ScenarioValidationError("scenario document version is unsupported"); } let rawScenarios: unknown[]; if (Object.hasOwn(value, "scenarios")) { assertKeys(value, ["version", "scenarios"], "scenario document"); if (!Array.isArray(value.scenarios) || value.scenarios.length < 1 || value.scenarios.length > MAX_SCENARIOS_PER_FILE) { throw new ScenarioValidationError("scenario document scenarios must be a bounded non-empty array"); } rawScenarios = value.scenarios; } else { // A single scenario file keeps the explicit version at the document root. assertKeys(value, ["version", "id", "description", "operations", "settings", "credentials", "hooks", "evidence", "budgets", "fixture"], "scenario document"); rawScenarios = [value]; } const scenarios = rawScenarios.map((entry, index) => parseScenario(entry, index, value.version as number)); const ids = new Set(); for (const scenario of scenarios) { if (ids.has(scenario.id)) throw new ScenarioValidationError(`duplicate scenario id: ${scenario.id}`); ids.add(scenario.id); } return { version: SCENARIO_SCHEMA_VERSION, scenarios }; } async function readBoundedRegularFile(path: string): Promise { let handle; try { handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); const info = await handle.stat(); if (!info.isFile() || info.size > MAX_SCENARIO_FILE_BYTES) { throw new ScenarioValidationError("scenario file must be a bounded regular file"); } const bytes = Buffer.alloc(MAX_SCENARIO_FILE_BYTES + 1); let used = 0; while (used <= MAX_SCENARIO_FILE_BYTES) { const result = await handle.read(bytes, used, bytes.length - used, null); if (result.bytesRead === 0) break; used += result.bytesRead; if (used > MAX_SCENARIO_FILE_BYTES) throw new ScenarioValidationError("scenario file exceeds the size limit"); } try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used)); } catch { throw new ScenarioValidationError("scenario file is not valid UTF-8"); } } catch (error) { if (error instanceof ScenarioValidationError) throw error; throw new ScenarioValidationError("scenario file could not be read"); } finally { try { await handle?.close(); } catch { /* preserve the original read outcome */ } } } /** Read one explicit scenario path without accepting stdin, pipes, or devices. */ export async function readScenarioFile(path: string, cwd = process.cwd()): Promise { if (typeof path !== "string" || path.length === 0 || path.length > 4_000 || path.includes("\0") || path === "-") { throw new ScenarioValidationError("scenario path must be an explicit bounded file path"); } let text: string; try { text = await readBoundedRegularFile(resolve(cwd, path)); } catch (error) { if (error instanceof ScenarioValidationError) throw error; throw new ScenarioValidationError("scenario file could not be read"); } let value: unknown; try { value = JSON.parse(text) as unknown; } catch { throw new ScenarioValidationError("scenario file contains invalid JSON"); } return parseScenarioDocument(value); } /** Load explicitly selected files and reject duplicate scenario identities. */ export async function loadScenarioFiles(paths: readonly string[], options: { cwd?: string } = {}): Promise { if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_SCENARIO_FILES) { throw new ScenarioValidationError(`provide 1–${MAX_SCENARIO_FILES} scenario files`); } const cwd = options.cwd ?? process.cwd(); const scenarios: LoadedScenario[] = []; const ids = new Set(); const files: string[] = []; for (const path of paths) { const document = await readScenarioFile(path, cwd); const absolute = resolve(cwd, path); files.push(absolute); document.scenarios.forEach((scenario, index) => { if (ids.has(scenario.id)) throw new ScenarioValidationError(`duplicate scenario id: ${scenario.id}`); ids.add(scenario.id); scenarios.push({ scenario, path: absolute, index }); }); } if (scenarios.length > MAX_SCENARIOS_PER_FILE * MAX_SCENARIO_FILES) { throw new ScenarioValidationError("scenario selection is too large"); } return { files, scenarios }; }