← Files VeraARCHIVED FILE
modules/browser-automation/scripts/capability_runtime.mjs
58.2 KB · Oct 2, 2026 · 00:29 UTC
/**
* Execute a reviewed browser capability through the connected Chrome tab API.
*
* The runner is deterministic because action dispatch, origin enforcement,
* postcondition checks, extraction shape, hashing, and receipts are mechanical
* contract work. Page meaning, workflow authoring, and repair remain model-led.
*/
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { DEFAULT_DOWNLOAD_DIRECTORY, observeDownloadDirectory } from "./download_directory.mjs";
import { browserFailureCode } from "./browser_session.mjs";
export const RUNTIME_VERSION = "browser-capability-runtime/17";
export const RECEIPT_SCHEMA = "browser-run-receipt/v3";
export const RECOVERY_PROPOSAL_SCHEMA = "browser-recovery-proposals/v2";
const EXECUTABLE_STATES = new Set(["discovered", "validated_local"]);
const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_TIMEOUT_MS = 30_000;
const MAX_TRANSITIONS = 100;
const SHA256 = /^[a-f0-9]{64}$/;
const ACTION_OPERATIONS = new Set([
"goto",
"wait_for",
"click",
"fill",
"press",
"select",
"set_checked",
"extract",
"download",
]);
const ACTION_EFFECTS = new Set(["read_only", "reversible", "consequential"]);
const RECOVERY_LOCATOR_KINDS = new Set(["role", "label", "placeholder", "test_id", "text"]);
const EMAIL_ADDRESS = /(^|[^A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}($|[^A-Za-z0-9.-])/;
class LocatorResolutionError extends Error {
constructor(message, {
targetKind = "action_locator",
fieldName = null,
locatorCandidates = [],
} = {}) {
super(message);
this.name = "LocatorResolutionError";
this.targetKind = targetKind;
this.fieldName = fieldName;
this.locatorCandidates = structuredClone(locatorCandidates);
}
}
class DownloadObservationError extends Error {
constructor(message, {
evidenceCode,
mechanismHint = null,
observedPage = null,
}) {
super(message);
this.name = "DownloadObservationError";
this.evidenceCode = evidenceCode;
this.mechanismHint = mechanismHint;
this.observedPage = observedPage;
}
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function canonicalize(value) {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (isObject(value)) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalize(value[key])]),
);
}
return value;
}
export function canonicalJson(value) {
return `${JSON.stringify(canonicalize(value), null, 2)}\n`;
}
export function sha256Text(value) {
return createHash("sha256").update(value, "utf8").digest("hex");
}
export function executionContract(capability) {
const projected = structuredClone(capability);
delete projected.status;
delete projected.validation;
return projected;
}
export function executionContractSha256(capability) {
return sha256Text(canonicalJson(executionContract(capability)));
}
export async function loadCapability(path) {
return JSON.parse(await readFile(resolve(path), "utf8"));
}
function boundedTimeout(value) {
if (value == null) {
return DEFAULT_TIMEOUT_MS;
}
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) {
throw new Error(`timeout_ms must be between 1 and ${MAX_TIMEOUT_MS}`);
}
return value;
}
function exactKeys(value, expected) {
return isObject(value) &&
Object.keys(value).length === expected.size &&
Object.keys(value).every((key) => expected.has(key));
}
function validateRecoveryLocatorCandidate(candidate, { allowStructuredCss = false } = {}) {
const expectedKeys = new Set(["kind", "role", "value", "exact"]);
if (!exactKeys(candidate, expectedKeys)) {
throw new Error("recovery locator candidate has an unsupported shape");
}
if (!RECOVERY_LOCATOR_KINDS.has(candidate.kind) && !(allowStructuredCss && candidate.kind === "css")) {
throw new Error("recovery requires a semantic locator candidate");
}
if (typeof candidate.exact !== "boolean") {
throw new Error("recovery locator exact must be boolean");
}
if (candidate.kind === "css") {
if (
candidate.role !== null ||
candidate.exact !== false ||
typeof candidate.value !== "string" ||
candidate.value.trim() === ""
) {
throw new Error("structured field CSS recovery requires text, null role, and exact false");
}
} else if (candidate.kind === "role") {
if (typeof candidate.role !== "string" || candidate.role.trim() === "") {
throw new Error("role recovery locator requires a role");
}
if (candidate.value !== null && typeof candidate.value !== "string") {
throw new Error("role recovery locator value must be text or null");
}
} else if (
candidate.role !== null ||
typeof candidate.value !== "string" ||
candidate.value.trim() === ""
) {
throw new Error(`${candidate.kind} recovery locator requires a text value and null role`);
}
if (typeof candidate.value === "string") {
if (candidate.value.length > 160 || EMAIL_ADDRESS.test(candidate.value)) {
throw new Error("recovery locator value is unsafe for retained proposal evidence");
}
if (candidate.value.includes("{{")) {
throw new Error("recovery locator must not introduce runtime templates");
}
}
return structuredClone(candidate);
}
function validateRuntimeShape(capability) {
if (!isObject(capability)) {
throw new Error("capability must be an object");
}
if (capability.schema_version !== "browser-capability/v2") {
throw new Error("runtime requires browser-capability/v2");
}
if (!EXECUTABLE_STATES.has(capability.status)) {
throw new Error(`capability status ${JSON.stringify(capability.status)} is not executable`);
}
if (!SAFE_ID.test(capability.capability_id ?? "")) {
throw new Error("capability_id must be a lower-case slug");
}
if (!Array.isArray(capability.site?.allowed_origins) || capability.site.allowed_origins.length === 0) {
throw new Error("capability requires allowed origins");
}
if (!Array.isArray(capability.milestones) || capability.milestones.length === 0) {
throw new Error("capability requires milestones");
}
if (!Array.isArray(capability.outputs) || capability.outputs.length === 0) {
throw new Error("capability requires outputs");
}
const outputNames = new Set();
for (const output of capability.outputs) {
if (!SAFE_ID.test(output?.name ?? "") || outputNames.has(output.name)) {
throw new Error("capability output names must be unique lower-case slugs");
}
outputNames.add(output.name);
if (output.type === "download_set" && output.delivery !== "artifact_only") {
throw new Error("download_set outputs must use artifact_only delivery");
}
}
if (!SAFE_ID.test(capability.entry_milestone ?? "")) {
throw new Error("capability requires an entry milestone");
}
const milestoneIds = new Set();
const actionIds = new Set();
for (const milestone of capability.milestones) {
if (!SAFE_ID.test(milestone?.id ?? "") || milestoneIds.has(milestone.id)) {
throw new Error("milestone ids must be unique lower-case slugs");
}
milestoneIds.add(milestone.id);
if (!Array.isArray(milestone.actions) || !Array.isArray(milestone.transitions)) {
throw new Error(`milestone ${milestone.id} requires actions and transitions`);
}
for (const action of milestone.actions) {
if (!SAFE_ID.test(action?.id ?? "") || actionIds.has(action.id)) {
throw new Error("action ids must be unique lower-case slugs");
}
actionIds.add(action.id);
if (!ACTION_OPERATIONS.has(action.operation)) {
throw new Error(`action ${action.id} has an unsupported operation`);
}
if (!ACTION_EFFECTS.has(action.effect)) {
throw new Error(`action ${action.id} has an unsupported effect`);
}
const expectedConfirmation =
action.effect === "consequential" ? "action_time" : "none";
if (action.confirmation !== expectedConfirmation) {
throw new Error(
`action ${action.id} confirmation must be ${expectedConfirmation}`,
);
}
}
}
if (!milestoneIds.has(capability.entry_milestone)) {
throw new Error("entry milestone is not declared");
}
const frames = Object.hasOwn(capability.runtime ?? {}, "frame_selectors") ? capability.runtime.frame_selectors : [];
if (!Array.isArray(frames) || frames.length > 5 || frames.some((selector) =>
typeof selector !== "string" || !selector.trim() || selector.length > 300 || selector.includes("{{"))) {
throw new Error("frame_selectors must contain at most five fixed selectors");
}
if (
capability.runtime?.browser !== "existing_chrome" ||
capability.runtime?.controller !== "chrome_extension" ||
capability.runtime?.semantic_driver !== "model" ||
capability.runtime?.mechanical_driver !== "playwright" ||
capability.runtime?.os_fallback !== "operator_handoff_on_native_gap"
) {
throw new Error("capability runtime contract is unsupported");
}
if (
capability.authority?.operator_authorized !== true ||
capability.authority?.authentication !== "operator_only" ||
capability.authority?.secret_policy !== "never_request_read_store" ||
capability.authority?.consequential_actions !== "confirm_at_action_time"
) {
throw new Error("capability authority contract is unsupported");
}
if (
capability.provenance?.source !== "authorized_live_discovery" ||
!SHA256.test(capability.provenance?.discovery_record_sha256 ?? "") ||
!SAFE_ID.test(capability.provenance?.discovery_approval_id ?? "") ||
typeof capability.provenance?.discovery_approved_at !== "string" ||
capability.provenance?.portable_bundle_contains_private_evidence !== false
) {
throw new Error("capability lacks reviewed discovery provenance");
}
}
function resolveInputs(capability, supplied) {
const resolvedInputs = {};
const declarations = new Map((capability.inputs ?? []).map((item) => [item.name, item]));
for (const key of Object.keys(supplied ?? {})) {
if (!declarations.has(key)) {
throw new Error(`undeclared runtime input: ${key}`);
}
}
for (const declaration of declarations.values()) {
const value = supplied?.[declaration.name];
if (value == null) {
if (declaration.required) {
throw new Error(`missing required runtime input: ${declaration.name}`);
}
continue;
}
const valid =
(declaration.type === "text" && typeof value === "string") ||
(declaration.type === "number" && typeof value === "number" && Number.isFinite(value)) ||
(declaration.type === "boolean" && typeof value === "boolean") ||
(declaration.type === "date" && typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) ||
(declaration.type === "enum" && typeof value === "string" && declaration.enum_values.includes(value));
if (!valid) {
throw new Error(`runtime input ${declaration.name} does not match type ${declaration.type}`);
}
resolvedInputs[declaration.name] = value;
}
return resolvedInputs;
}
function initialOutputs(capability) {
return Object.fromEntries(
(capability.outputs ?? []).map((output) => {
if (output.type === "record_set" || output.type === "download_set") {
return [output.name, []];
}
return [output.name, null];
}),
);
}
function renderTemplate(value, inputs) {
if (typeof value !== "string") {
return value;
}
return value.replace(/\{\{([a-z0-9]+(?:-[a-z0-9]+)*)\}\}/g, (_match, name) => {
if (!(name in inputs)) {
throw new Error(`template references missing input: ${name}`);
}
return String(inputs[name]);
});
}
function normalizeOrigin(value) {
return new URL(value).origin;
}
function queryFreePath(value) {
const url = new URL(value);
return url.pathname;
}
function urlIncludesRenderedValue(url, renderedValue) {
if (url.includes(renderedValue)) {
return true;
}
try {
return decodeURIComponent(url.replaceAll("+", " ")).includes(renderedValue);
} catch {
return false;
}
}
async function waitForUrlCondition(tab, predicate, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (true) {
if (predicate(await tab.url())) {
return true;
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return false;
}
await tab.playwright.waitForTimeout(Math.min(100, remainingMs));
}
}
function assertAllowedUrl(url, allowedOrigins) {
const origin = normalizeOrigin(url);
if (!allowedOrigins.has(origin)) {
throw new Error(`browser left allowed origins: ${origin}`);
}
return { origin, path: queryFreePath(url) };
}
async function executionScope(context) {
// Frame identity and origins are mechanical boundaries, not page interpretation.
// Resolve again before each read/action: SPA frames can be replaced or navigate.
assertAllowedUrl(await context.tab.url(), context.allowedOrigins);
let scope = context.tab.playwright;
for (const selector of context.capability.runtime.frame_selectors ?? []) {
const unique = await scope.locator(selector).evaluateAll((elements) =>
elements.length === 1 && elements[0].tagName.toLowerCase() === "iframe");
if (!unique) throw new Error("execution frame_not_unique");
scope = scope.frameLocator(selector);
const origin = await scope.locator("html").evaluate((element) =>
new URL(element.ownerDocument.URL).origin);
if (!context.allowedOrigins.has(origin)) throw new Error("execution frame_origin_not_allowed");
}
return scope;
}
function locatorFromCandidate(base, candidate, inputs) {
const value = renderTemplate(candidate.value, inputs);
const options = { exact: candidate.exact };
switch (candidate.kind) {
case "role":
return base.getByRole(candidate.role, value == null ? {} : { name: value, ...options });
case "label":
return base.getByLabel(value, options);
case "placeholder":
return base.getByPlaceholder(value, options);
case "test_id":
return base.getByTestId(value);
case "text":
return base.getByText(value, options);
case "css":
return base.locator(value);
default:
throw new Error(`unsupported locator kind: ${candidate.kind}`);
}
}
async function resolveLocator(base, candidates, inputs, { wait = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const failures = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const locator = locatorFromCandidate(base, candidate, inputs);
try {
if (!(await locator.isVisible())) {
failures.push(`${candidate.kind}[${index}] not visible`);
continue;
}
return { locator, candidateIndex: index, candidateKind: candidate.kind };
} catch (error) {
failures.push(`${candidate.kind}[${index}]: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (wait && candidates.length > 0) {
// Waiting for alternatives is mechanical presence detection. Run the
// candidate waits concurrently so one absent DOM variant cannot consume
// the whole bounded state-detection budget before another variant is tried.
const attempts = candidates.map(async (candidate, index) => {
const locator = locatorFromCandidate(base, candidate, inputs);
await locator.waitFor({ state: "visible", timeoutMs });
return { locator, candidateIndex: index, candidateKind: candidate.kind };
});
try {
await Promise.any(attempts);
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const locator = locatorFromCandidate(base, candidate, inputs);
if (await locator.isVisible()) {
return { locator, candidateIndex: index, candidateKind: candidate.kind };
}
}
} catch (error) {
const details = error instanceof AggregateError ? error.errors : [error];
failures.push(
...details.map((detail, index) =>
`wait[${index}]: ${detail instanceof Error ? detail.message : String(detail)}`
),
);
}
}
throw new LocatorResolutionError(`no locator candidate matched (${failures.join("; ")})`, {
locatorCandidates: candidates,
});
}
async function readLocator(locator, read, timeoutMs) {
if (read.kind === "inner_text") {
return (await locator.innerText({ timeoutMs })).trim();
}
if (read.kind === "text_content") {
return ((await locator.textContent({ timeoutMs })) ?? "").trim();
}
if (read.kind === "attribute") {
return await locator.getAttribute(read.attribute, { timeoutMs });
}
throw new Error(`unsupported read kind: ${read.kind}`);
}
async function readField(container, field, inputs, timeoutMs) {
try {
if (field.locator_candidates.length === 0) {
return await readLocator(container, field.read, timeoutMs);
}
const failures = [];
for (const candidate of field.locator_candidates) {
try {
const resolved = await resolveLocator(container, [candidate], inputs, { timeoutMs });
return await readLocator(resolved.locator, field.read, timeoutMs);
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
}
throw new LocatorResolutionError(
`no extraction field locator could be read (${failures.join("; ")})`,
{
targetKind: "extraction_field_locator",
fieldName: field.name,
locatorCandidates: field.locator_candidates,
},
);
} catch (error) {
if (!field.required) {
return null;
}
if (
error instanceof LocatorResolutionError &&
error.targetKind === "extraction_field_locator"
) {
throw error;
}
if (field.locator_candidates.length > 0) {
throw new LocatorResolutionError(error.message, {
targetKind: "extraction_field_locator",
fieldName: field.name,
locatorCandidates: field.locator_candidates,
});
}
throw error;
}
}
function coerceFieldValue(value, declaration, itemIndex = null) {
const suffix = itemIndex == null ? "" : ` at item ${itemIndex}`;
if (value == null || String(value).trim() === "") {
if (declaration.required) {
throw new Error(`required extracted field is empty: ${declaration.name}${suffix}`);
}
return null;
}
const text = String(value).trim();
if (declaration.type === "text") return text;
if (declaration.type === "date") {
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw new Error(`extracted field is not an ISO date: ${declaration.name}${suffix}`);
}
return text;
}
if (declaration.type === "number") {
const number = Number(text);
if (!Number.isFinite(number)) {
throw new Error(`extracted field is not a number: ${declaration.name}${suffix}`);
}
return number;
}
if (declaration.type === "boolean") {
if (text.toLowerCase() === "true") return true;
if (text.toLowerCase() === "false") return false;
throw new Error(`extracted field is not a boolean: ${declaration.name}${suffix}`);
}
throw new Error(`unsupported extracted field type: ${declaration.type}`);
}
async function extractRecords(action, rootLocator, declaration, inputs, timeoutMs) {
const extraction = action.extract;
let maxItems = extraction.max_items;
if (extraction.limit_input_ref != null) {
const suppliedLimit = inputs[extraction.limit_input_ref];
if (!Number.isInteger(suppliedLimit) || suppliedLimit < 1) {
throw new Error(`extraction limit must be a positive integer: ${extraction.limit_input_ref}`);
}
maxItems = Math.min(maxItems, suppliedLimit);
}
const fieldDeclarations = new Map(declaration.fields.map((field) => [field.name, field]));
const readRecord = async (container, itemIndex = null) => {
const record = {};
for (const field of extraction.fields) {
const fieldDeclaration = fieldDeclarations.get(field.name);
if (fieldDeclaration == null) {
throw new Error(`extraction references an undeclared output field: ${field.name}`);
}
const value = await readField(container, field, inputs, timeoutMs);
record[field.name] = coerceFieldValue(value, fieldDeclaration, itemIndex);
}
return record;
};
const records = [];
if (extraction.mode === "single") {
records.push(await readRecord(rootLocator.nth(0)));
} else {
const count = Math.min(await rootLocator.count(), maxItems);
for (let index = 0; index < count; index += 1) {
records.push(await readRecord(rootLocator.nth(index), index));
}
}
if (!extraction.empty_allowed && records.length === 0) {
throw new Error(`extraction produced no records for ${action.output_ref}`);
}
if (extraction.dedupe_by.length === 0) {
return records;
}
const seen = new Set();
return records.filter((record) => {
const key = canonicalJson(extraction.dedupe_by.map((field) => record[field]));
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
async function extractOutput(action, rootLocator, declaration, inputs, timeoutMs) {
if (declaration.type === "scalar" || declaration.type === "summary") {
if (action.extract.mode !== "text") {
throw new Error(`output ${declaration.name} requires text extraction mode`);
}
const value = (await rootLocator.innerText({ timeoutMs })).trim();
if (!action.extract.empty_allowed && value === "") {
throw new Error(`text extraction produced an empty value for ${declaration.name}`);
}
return value;
}
if (declaration.type === "record" && action.extract.mode !== "single") {
throw new Error(`record output ${declaration.name} requires single extraction mode`);
}
if (declaration.type === "record_set" && action.extract.mode !== "list") {
throw new Error(`record_set output ${declaration.name} requires list extraction mode`);
}
const records = await extractRecords(action, rootLocator, declaration, inputs, timeoutMs);
return declaration.type === "record" ? records[0] ?? null : records;
}
function isTransientExtractionFailure(error) {
if (error instanceof LocatorResolutionError) {
return true;
}
const detail = error instanceof Error ? error.message : String(error);
return detail.startsWith("required extracted field is empty:") ||
detail.startsWith("extraction produced no records for ");
}
async function extractWithCandidateFallback(action, context, declaration, timeoutMs) {
const failures = [];
let fieldFailure = null;
const firstResolved = await resolveLocator(
await executionScope(context),
action.locator_candidates,
context.inputs,
{ wait: true, timeoutMs },
);
const candidateIndexes = [
firstResolved.candidateIndex,
...action.locator_candidates
.map((_candidate, index) => index)
.filter((index) => index !== firstResolved.candidateIndex),
];
for (const index of candidateIndexes) {
const candidate = action.locator_candidates[index];
let resolved;
if (index === firstResolved.candidateIndex) {
resolved = firstResolved;
} else {
try {
resolved = await resolveLocator(await executionScope(context), [candidate], context.inputs);
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
continue;
}
}
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await executionScope(context);
const value = await extractOutput(
action,
resolved.locator,
declaration,
context.inputs,
timeoutMs,
);
return {
value,
locatorCandidate: {
index,
kind: candidate.kind,
},
};
} catch (error) {
if (!isTransientExtractionFailure(error)) {
throw error;
}
if (
error instanceof LocatorResolutionError &&
error.targetKind === "extraction_field_locator"
) {
fieldFailure = error;
}
failures.push(error instanceof Error ? error.message : String(error));
if (attempt === 0) {
// Gmail can expose the row container before its metadata descendants
// settle. One short retry is reproducible, bounded, and does not
// interpret page meaning or broaden the declared output fields.
await context.tab.playwright.waitForTimeout(Math.min(200, timeoutMs));
}
}
}
}
if (fieldFailure != null) {
throw fieldFailure;
}
throw new LocatorResolutionError(
`no extraction locator candidate produced records (${failures.join("; ")})`,
{ locatorCandidates: action.locator_candidates },
);
}
async function downloadedFileEvidence(path, { mechanismHint, observedPage }) {
let fileStat;
try {
fileStat = await stat(path);
} catch (error) {
throw new DownloadObservationError(
`download path could not be read: ${error instanceof Error ? error.message : String(error)}`,
{
evidenceCode: "download-file-unreadable",
mechanismHint,
observedPage,
},
);
}
if (!fileStat.isFile()) {
throw new DownloadObservationError("download path is not a regular file", {
evidenceCode: "download-path-not-regular-file",
mechanismHint,
observedPage,
});
}
const hash = createHash("sha256");
try {
for await (const chunk of createReadStream(path)) {
hash.update(chunk);
}
} catch (error) {
throw new DownloadObservationError(
`download bytes could not be read: ${error instanceof Error ? error.message : String(error)}`,
{
evidenceCode: "download-file-unreadable",
mechanismHint,
observedPage,
},
);
}
return {
path,
byte_length: fileStat.size,
sha256: hash.digest("hex"),
};
}
async function downloadMechanismHint(locator, currentUrl, allowedOrigins) {
// These categories come from mechanically observable control attributes.
// They diagnose transport shape without persisting hrefs, tokens, or page meaning.
let downloadAttribute = null;
let href = null;
let target = null;
try {
[downloadAttribute, href, target] = await Promise.all([
locator.getAttribute("download"),
locator.getAttribute("href"),
locator.getAttribute("target"),
]);
} catch {
return "control-attributes-unavailable";
}
if (downloadAttribute !== null) return "download-attribute";
if (typeof href !== "string" || href.trim() === "") {
return "control-without-href";
}
const normalizedHref = href.trim().toLowerCase();
if (normalizedHref.startsWith("blob:")) return "blob-url";
if (normalizedHref.startsWith("data:")) return "data-url";
if (normalizedHref.startsWith("javascript:")) return "javascript-url";
let resolved;
try {
resolved = new URL(href, currentUrl);
} catch {
return "unclassified-href";
}
if (!new Set(["http:", "https:"]).has(resolved.protocol)) {
return "non-http-url";
}
if (target?.trim().toLowerCase() === "_blank") return "new-tab-url";
const currentOrigin = new URL(currentUrl).origin;
if (resolved.origin === currentOrigin) return "same-origin-url";
if (allowedOrigins.has(normalizeOrigin(resolved.origin))) return "allowed-cross-origin-url";
return "outside-allowed-origin-url";
}
function downloadObservationPage(error) {
return error instanceof DownloadObservationError ? error.observedPage : null;
}
function downloadEvidenceCode(error) {
return error instanceof DownloadObservationError ? error.evidenceCode : null;
}
function downloadMechanism(error) {
return error instanceof DownloadObservationError ? error.mechanismHint : null;
}
function outputCount(value) {
if (Array.isArray(value)) {
return value.length;
}
return value == null ? 0 : 1;
}
function receiptOutputPayload(declaration, value) {
if (declaration?.type !== "download_set" || !Array.isArray(value)) {
return value;
}
return value.map(({ byte_length, sha256 }) => ({ byte_length, sha256 }));
}
function receiptOutputSha256(declaration, value) {
return sha256Text(canonicalJson(receiptOutputPayload(declaration, value)));
}
function sanitizedErrorMetadata(code, detail, reasonCode = null) {
return {
code,
reason_code: reasonCode,
detail_sha256: sha256Text(String(detail)),
};
}
function classifyRunFailure(error) {
const browserFailure = browserFailureCode(error);
if (browserFailure !== null) return browserFailure;
const detail = error instanceof Error ? error.message : String(error);
if (error instanceof DownloadObservationError) return "native_gap";
if (error instanceof LocatorResolutionError) return "locator_resolution_failed";
if (detail.startsWith("browser left allowed origins:")) return "origin_boundary_violation";
if (detail.startsWith("postcondition failed:")) return "postcondition_failed";
if (detail.startsWith("required output is missing or incomplete:")) {
return "required_output_incomplete";
}
if (detail.startsWith("consequential action requires current operator approval:")) {
return "operator_confirmation_required";
}
if (detail.startsWith("no transition matched after milestone:")) {
return "branch_resolution_failed";
}
if (detail.startsWith("capability exceeded ")) return "transition_limit_exceeded";
if (detail.startsWith("connected Chrome runtime lacks ")) return "native_gap";
return "run_failed";
}
function safeRecoveryText(value, field) {
if (typeof value !== "string" || value.trim() === "" || value.length > 500) {
throw new Error(`recovery ${field} must be non-empty text of at most 500 characters`);
}
if (EMAIL_ADDRESS.test(value)) {
throw new Error(`recovery ${field} must not contain an email address`);
}
return value.trim();
}
function recoveryTarget(action, error) {
if (error.targetKind === "extraction_field_locator") {
return {
kind: "extraction_field_locator",
field_name: error.fieldName,
locator_candidates: structuredClone(error.locatorCandidates),
};
}
return {
kind: "action_locator",
field_name: null,
locator_candidates: structuredClone(action.locator_candidates),
};
}
function recoveryRequest({ capability, milestone, action, page, error }) {
const target = recoveryTarget(action, error);
return {
schema_version: "browser-recovery-request/v1",
capability: {
capability_id: capability.capability_id,
version: capability.version,
site_name: capability.site.name,
allowed_origins: capability.site.allowed_origins,
process_objective: capability.process.objective,
},
milestone: {
milestone_id: milestone.id,
intent: milestone.intent,
},
action: {
action_id: action.id,
intent: action.intent,
operation: action.operation,
effect: action.effect,
locator_candidates: structuredClone(action.locator_candidates),
postcondition_kind: action.postcondition.kind,
},
recovery_target: target,
page,
failure: sanitizedErrorMetadata("locator_not_found", error.message),
constraints: {
permitted_change:
target.kind === "extraction_field_locator"
? "one_bounded_structured_field_locator_candidate_or_resolved_action_root"
: "one_semantic_locator_candidate",
same_action_intent_required: true,
same_operation_required: true,
same_effect_required: true,
same_origin_boundary_required: true,
no_capability_mutation: true,
no_consequential_recovery: true,
},
};
}
async function attemptModelRecovery({
recoveryHandler,
capability,
milestone,
action,
context,
error,
recoveryProposals,
}) {
if (
!(error instanceof LocatorResolutionError) ||
action.effect === "consequential" ||
action.operation === "goto"
) {
return null;
}
const page = assertAllowedUrl(await context.tab.url(), context.allowedOrigins);
const request = recoveryRequest({ capability, milestone, action, page, error });
const target = request.recovery_target;
if (typeof recoveryHandler !== "function") {
context.pendingRecoveryRequest = request;
return null;
}
const response = await recoveryHandler(request);
if (response == null) {
context.pendingRecoveryRequest = request;
return null;
}
const locatorResponse = exactKeys(
response,
new Set(["locator_candidate", "rationale", "uncertainty"]),
);
const rootResponse = exactKeys(
response,
new Set(["use_resolved_action_root", "rationale", "uncertainty"]),
);
if (!locatorResponse && !rootResponse) {
throw new Error("recovery handler returned an unsupported response shape");
}
if (
rootResponse &&
(response.use_resolved_action_root !== true || target.kind !== "extraction_field_locator")
) {
throw new Error("resolved action root recovery requires an extraction field target");
}
const resolution = rootResponse ? "resolved_action_root" : "locator_candidate";
const candidate = locatorResponse
? validateRecoveryLocatorCandidate(response.locator_candidate, {
allowStructuredCss: target.kind === "extraction_field_locator",
})
: null;
if (
candidate != null &&
target.locator_candidates.some(
(declared) => canonicalJson(declared) === canonicalJson(candidate),
)
) {
throw new Error("recovery locator must differ from declared candidates");
}
const proposal = {
sequence: recoveryProposals.length + 1,
milestone_id: milestone.id,
action_id: action.id,
action_intent: action.intent,
operation: action.operation,
effect: action.effect,
target_kind: target.kind,
field_name: target.field_name,
origin: page.origin,
path: page.path,
original_locator_candidates_sha256: sha256Text(
canonicalJson(target.locator_candidates),
),
resolution,
candidate_index: candidate == null ? null : target.locator_candidates.length,
candidate,
candidate_sha256: candidate == null ? null : sha256Text(canonicalJson(candidate)),
rationale: safeRecoveryText(response.rationale, "rationale"),
uncertainty: safeRecoveryText(response.uncertainty, "uncertainty"),
original_failure: sanitizedErrorMetadata("locator_not_found", error.message),
outcome: null,
outcome_error: null,
approved_for_persistence: false,
};
const patchedAction = structuredClone(action);
if (target.kind === "extraction_field_locator") {
const field = patchedAction.extract?.fields.find(
(candidateField) => candidateField.name === target.field_name,
);
if (field == null) {
throw new Error("recovery target field is not declared by the extraction action");
}
if (resolution === "resolved_action_root") {
field.locator_candidates = [];
} else {
field.locator_candidates.push(candidate);
}
} else {
patchedAction.locator_candidates.push(candidate);
}
try {
const evidence = await executeAction(patchedAction, context);
proposal.outcome = "passed";
recoveryProposals.push(proposal);
return evidence;
} catch (recoveryError) {
proposal.outcome = "failed";
proposal.outcome_error = sanitizedErrorMetadata(
"recovery_failed",
recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
);
recoveryProposals.push(proposal);
throw recoveryError;
}
}
async function evaluateCondition(condition, context) {
const { tab, capability, inputs, outputs } = context;
const timeoutMs = boundedTimeout(condition.timeout_ms);
// Boundary failures must never count as a hidden invoice or an absent popup.
const scope = await executionScope(context);
switch (condition.kind) {
case "always":
return true;
case "url_path_equals": {
const renderedValue = renderTemplate(condition.value, inputs);
return waitForUrlCondition(
tab,
(currentUrl) => queryFreePath(currentUrl) === renderedValue,
timeoutMs,
);
}
case "url_includes": {
const renderedValue = renderTemplate(condition.value, inputs);
return waitForUrlCondition(
tab,
(currentUrl) => urlIncludesRenderedValue(currentUrl, renderedValue),
timeoutMs,
);
}
case "locator_visible": {
try {
await resolveLocator(scope, condition.locator_candidates, inputs, {
wait: true,
timeoutMs,
});
return true;
} catch {
return false;
}
}
case "locator_hidden": {
try {
await resolveLocator(scope, condition.locator_candidates, inputs, { timeoutMs });
return false;
} catch {
await executionScope(context);
return true;
}
}
case "locator_text_contains": {
try {
const { locator } = await resolveLocator(scope, condition.locator_candidates, inputs, {
wait: true,
timeoutMs,
});
const expected = renderTemplate(condition.value, inputs);
const deadline = Date.now() + timeoutMs;
do {
if ((await locator.innerText({ timeoutMs })).includes(expected)) {
return true;
}
await tab.playwright.waitForTimeout(100);
} while (Date.now() < deadline);
return false;
} catch {
return false;
}
}
case "output_empty":
return outputCount(outputs[condition.output_ref]) === 0;
case "output_nonempty":
return outputCount(outputs[condition.output_ref]) > 0;
case "output_count": {
const count = outputCount(outputs[condition.output_ref]);
if (condition.comparator === "eq") return count === condition.expected;
if (condition.comparator === "gte") return count >= condition.expected;
if (condition.comparator === "lte") return count <= condition.expected;
throw new Error(`unsupported output comparator: ${condition.comparator}`);
}
default:
throw new Error(`unsupported condition kind: ${condition.kind}`);
}
}
async function verifyPostcondition(postcondition, context) {
if (postcondition.kind === "none") {
return true;
}
const passed = await evaluateCondition(postcondition, context);
if (!passed) {
throw new Error(`postcondition failed: ${postcondition.kind}`);
}
return true;
}
async function gotoWithCommittedTargetCheck(tab, targetUrl, timeoutMs) {
try {
await tab.goto(targetUrl);
return;
} catch (navigationError) {
let currentUrl;
try {
currentUrl = await tab.url();
} catch {
throw navigationError;
}
if (typeof currentUrl !== "string" || new URL(currentUrl).href !== targetUrl) {
throw navigationError;
}
try {
await tab.playwright.waitForLoadState({
state: "domcontentloaded",
timeoutMs,
});
} catch {
throw navigationError;
}
}
}
async function executeAction(action, context) {
const {
tab,
capability,
inputs,
outputs,
outputDeclarations,
approvedConsequentialActions,
} = context;
const timeoutMs = boundedTimeout(action.timeout_ms);
let evidenceCode = null;
let mechanism = null;
if (action.effect === "consequential" && !approvedConsequentialActions.has(action.id)) {
throw new Error(`consequential action requires current operator approval: ${action.id}`);
}
if (action.operation !== "goto") {
await executionScope(context);
}
let locatorCandidate = null;
let locator = null;
if (!["goto", "extract"].includes(action.operation)) {
const resolved = await resolveLocator(await executionScope(context), action.locator_candidates, inputs, {
wait: action.operation === "wait_for",
timeoutMs,
});
locator = resolved.locator;
locatorCandidate = {
index: resolved.candidateIndex,
kind: resolved.candidateKind,
};
}
// Locator resolution is asynchronous: the page can navigate while it runs.
// A post-action check alone would disclose inputs before rejecting the run.
if (action.operation !== "goto") {
await executionScope(context);
}
if (action.operation === "goto") {
const targetOrigin = action.target_origin ?? capability.site.allowed_origins[0];
if (!capability.site.allowed_origins.includes(targetOrigin)) {
throw new Error(`goto target origin is not allowed: ${targetOrigin}`);
}
const targetUrl = new URL(renderTemplate(action.path, inputs), targetOrigin).href;
await gotoWithCommittedTargetCheck(tab, targetUrl, timeoutMs);
} else if (action.operation === "wait_for") {
if (!(await locator.isEnabled())) {
throw new Error(`waited control is disabled: ${action.id}`);
}
} else if (action.operation === "click") {
await locator.click({ timeoutMs });
} else if (action.operation === "fill") {
await locator.fill(String(inputs[action.input_ref]), { timeoutMs });
} else if (action.operation === "press") {
await locator.press(action.key, { timeoutMs });
} else if (action.operation === "select") {
await locator.selectOption(String(inputs[action.input_ref]), { timeoutMs });
} else if (action.operation === "set_checked") {
await locator.setChecked(Boolean(inputs[action.input_ref]), { timeoutMs });
} else if (action.operation === "extract") {
const declaration = outputDeclarations.get(action.output_ref);
if (declaration == null || declaration.type === "download_set") {
throw new Error(`extract action references an incompatible output: ${action.output_ref}`);
}
const extraction = await extractWithCandidateFallback(
action,
context,
declaration,
timeoutMs,
);
outputs[action.output_ref] = extraction.value;
locatorCandidate = extraction.locatorCandidate;
} else if (action.operation === "download") {
const declaration = outputDeclarations.get(action.output_ref);
if (declaration?.type !== "download_set" || declaration.delivery !== "artifact_only") {
throw new Error(`download action requires an artifact-only download_set output`);
}
const beforeUrl = await tab.url();
const beforePage = assertAllowedUrl(beforeUrl, context.allowedOrigins);
const mechanismHint = await downloadMechanismHint(
locator,
beforeUrl,
context.allowedOrigins,
);
if (typeof tab.playwright.waitForEvent !== "function") {
throw new DownloadObservationError(
"connected Chrome runtime lacks the download event API",
{
evidenceCode: "download-event-api-unavailable",
mechanismHint,
observedPage: beforePage,
},
);
}
let directoryObservation = null;
try {
if (context.downloadDirectory !== null) {
directoryObservation = await observeDownloadDirectory(context.downloadDirectory);
}
let downloadPromise;
try {
downloadPromise = tab.playwright.waitForEvent("download", { timeoutMs });
} catch (error) {
throw new DownloadObservationError(
`download event listener could not start: ${error instanceof Error ? error.message : String(error)}`,
{
evidenceCode: "download-event-listener-failed",
mechanismHint,
observedPage: beforePage,
},
);
}
const downloadOutcomePromise = Promise.resolve(downloadPromise).then(
(download) => ({ download, error: null }),
(error) => ({ download: null, error }),
);
await locator.click({ timeoutMs });
const downloadOutcome = await downloadOutcomePromise;
const afterUrl = await tab.url();
const afterPage = assertAllowedUrl(afterUrl, context.allowedOrigins);
if (downloadOutcome.error !== null || downloadOutcome.download == null) {
const pageChanged = new URL(beforeUrl).href !== new URL(afterUrl).href;
const evidenceCode = pageChanged
? "download-event-not-observed-after-navigation"
: "download-event-not-observed-page-unchanged";
const detail = downloadOutcome.error instanceof Error
? downloadOutcome.error.message
: String(downloadOutcome.error ?? "download event returned no object");
throw new DownloadObservationError(`download event was not observed: ${detail}`, {
evidenceCode,
mechanismHint,
observedPage: afterPage,
});
}
if (directoryObservation !== null) {
outputs[action.output_ref].push(await directoryObservation.wait({ timeoutMs }));
evidenceCode = "download-directory-bytes-verified";
} else {
const download = downloadOutcome.download;
if (typeof download?.path !== "function") {
throw new DownloadObservationError(
"connected Chrome runtime lacks download path evidence",
{
evidenceCode: "download-path-api-unavailable",
mechanismHint,
observedPage: afterPage,
},
);
}
let path;
try {
path = await download.path({ timeoutMs });
} catch (error) {
throw new DownloadObservationError(
`download path resolution failed: ${error instanceof Error ? error.message : String(error)}`,
{
evidenceCode: "download-path-resolution-failed",
mechanismHint,
observedPage: afterPage,
},
);
}
if (path == null) {
throw new DownloadObservationError(
`download did not produce a local path: ${action.id}`,
{
evidenceCode: "download-path-not-returned",
mechanismHint,
observedPage: afterPage,
},
);
}
outputs[action.output_ref].push(
await downloadedFileEvidence(path, {
mechanismHint,
observedPage: afterPage,
}),
);
evidenceCode = "download-bytes-verified";
}
mechanism = mechanismHint;
} catch (error) {
if (error instanceof DownloadObservationError) throw error;
throw new DownloadObservationError("local download verification failed", {
evidenceCode: error.evidenceCode ?? "download-directory-read-failed",
mechanismHint,
observedPage: beforePage,
});
} finally {
await directoryObservation?.close();
}
} else {
throw new Error(`unsupported operation: ${action.operation}`);
}
const page = assertAllowedUrl(await tab.url(), context.allowedOrigins);
await executionScope(context);
await verifyPostcondition(action.postcondition, context);
const outputValue = action.output_ref == null ? null : outputs[action.output_ref];
const outputDeclaration =
action.output_ref == null ? null : outputDeclarations.get(action.output_ref);
return {
locator_candidate: locatorCandidate,
origin: page.origin,
path: page.path,
output_ref: action.output_ref,
output_count: outputCount(outputValue),
output_sha256:
outputValue == null
? null
: receiptOutputSha256(outputDeclaration, outputValue),
evidence_code: evidenceCode,
mechanism_hint: mechanism,
};
}
function requiredOutputSatisfied(declaration, value) {
if (declaration.type === "record_set") return Array.isArray(value);
if (declaration.type === "download_set") return Array.isArray(value) && value.length > 0;
if (declaration.type === "record") return isObject(value);
if (declaration.type === "scalar") return value !== null && !isObject(value) && !Array.isArray(value);
if (declaration.type === "summary") return typeof value === "string" && value.trim() !== "";
return false;
}
async function writeOwnerOnly(path, text) {
await writeFile(path, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
await chmod(path, 0o600);
}
function receiptOutputEntries(capability, outputs) {
return capability.outputs.map((declaration) => {
const value = outputs[declaration.name];
return {
name: declaration.name,
type: declaration.type,
sensitivity: declaration.sensitivity,
delivery: declaration.delivery,
record_count: outputCount(value),
sha256: receiptOutputSha256(declaration, value),
artifact: "outputs.json",
};
});
}
function publicSummary(
receiptPath,
outputsPath,
lockPath,
recoveryProposalsPath,
recoveryProposalCount,
receipt,
capability,
outputs,
recoveryRequestForModel,
) {
const deliveredOutputs = Object.fromEntries(
capability.outputs
.filter((declaration) => declaration.delivery !== "artifact_only")
.map((declaration) => [declaration.name, outputs[declaration.name]]),
);
return {
run_id: receipt.run_id,
result: receipt.result,
execution_mode: receipt.environment.execution_mode,
validation_eligible: receipt.result === "passed" &&
receipt.environment.execution_mode === "live_connected_chrome" &&
!receipt.locator_changes_during_run,
capability_id: receipt.capability_id,
completed_milestones: receipt.completed_milestones,
terminal_milestone: receipt.terminal_milestone,
outputs: receipt.outputs.map(({ name, type, record_count, sha256 }) => ({
name,
type,
record_count,
sha256,
})),
delivered_outputs: deliveredOutputs,
receipt_path: receiptPath,
outputs_path: outputsPath,
lock_path: lockPath,
recovery_proposals_path: recoveryProposalsPath,
recovery_proposal_count: recoveryProposalCount,
recovery_request: recoveryRequestForModel,
error: receipt.error,
};
}
export async function executeCapability({
tab,
capability,
inputs = {},
runDirectory,
runId,
approvedConsequentialActions = [],
recoveryHandler = null,
clock = () => new Date().toISOString(),
environment = {},
downloadDirectory = DEFAULT_DOWNLOAD_DIRECTORY,
}) {
validateRuntimeShape(capability);
const executionMode = environment.execution_mode ?? "unverified";
if (!["unverified", "simulated", "live_connected_chrome"].includes(executionMode)) {
throw new Error("unsupported browser execution mode");
}
if (!SAFE_ID.test(runId ?? "")) {
throw new Error("runId must be a lower-case slug");
}
const consequentialActionIds = new Set(
capability.milestones.flatMap((milestone) =>
milestone.actions
.filter((action) => action.effect === "consequential")
.map((action) => action.id),
),
);
for (const actionId of approvedConsequentialActions) {
if (!consequentialActionIds.has(actionId)) {
throw new Error(`approval does not name a consequential action: ${actionId}`);
}
}
const resolvedInputs = resolveInputs(capability, inputs);
const resolvedRunDirectory = resolve(runDirectory);
await mkdir(dirname(resolvedRunDirectory), { recursive: true, mode: 0o700 });
await mkdir(resolvedRunDirectory, { mode: 0o700 });
await chmod(resolvedRunDirectory, 0o700);
const startedAt = clock();
const allowedOrigins = new Set(capability.site.allowed_origins.map(normalizeOrigin));
const inputHashes = Object.fromEntries(
Object.entries(resolvedInputs).map(([name, value]) => [name, sha256Text(canonicalJson(value))]),
);
const outputs = initialOutputs(capability);
const outputDeclarations = new Map(capability.outputs.map((output) => [output.name, output]));
const milestones = new Map(capability.milestones.map((milestone) => [milestone.id, milestone]));
const completedMilestones = [];
const actionResults = [];
const recoveryProposals = [];
const approved = new Set(approvedConsequentialActions);
const executionHash = executionContractSha256(capability);
let currentMilestoneId = capability.entry_milestone;
let terminalMilestone = null;
let failure = null;
const context = {
tab,
capability,
inputs: resolvedInputs,
outputs,
outputDeclarations,
allowedOrigins,
approvedConsequentialActions: approved,
pendingRecoveryRequest: null,
downloadDirectory,
};
try {
if (!tab?.playwright || typeof tab.url !== "function" || typeof tab.goto !== "function") {
throw new Error("connected Chrome runtime lacks the required tab API");
}
for (let transitionCount = 0; transitionCount < MAX_TRANSITIONS; transitionCount += 1) {
const milestone = milestones.get(currentMilestoneId);
if (milestone == null) {
throw new Error(`unknown milestone: ${currentMilestoneId}`);
}
for (const action of milestone.actions) {
const actionStartedAt = clock();
try {
let evidence;
try {
evidence = await executeAction(action, context);
} catch (error) {
evidence = await attemptModelRecovery({
recoveryHandler,
capability,
milestone,
action,
context,
error,
recoveryProposals,
});
if (evidence == null) {
throw error;
}
}
actionResults.push({
milestone_id: milestone.id,
action_id: action.id,
operation: action.operation,
result: "passed",
started_at: actionStartedAt,
finished_at: clock(),
...evidence,
error: null,
});
} catch (error) {
const observedPage = downloadObservationPage(error);
actionResults.push({
milestone_id: milestone.id,
action_id: action.id,
operation: action.operation,
result: "failed",
started_at: actionStartedAt,
finished_at: clock(),
locator_candidate: null,
origin: observedPage?.origin ?? null,
path: observedPage?.path ?? null,
output_ref: action.output_ref,
output_count: 0,
output_sha256: null,
evidence_code: downloadEvidenceCode(error),
mechanism_hint: downloadMechanism(error),
error: sanitizedErrorMetadata(
"action_failed",
error instanceof Error ? error.message : String(error),
downloadEvidenceCode(error),
),
});
throw error;
}
}
completedMilestones.push(milestone.id);
let transition = null;
for (const candidate of milestone.transitions) {
if (await evaluateCondition(candidate.when, context)) {
transition = candidate;
break;
}
}
if (transition == null) {
throw new Error(`no transition matched after milestone: ${milestone.id}`);
}
if (transition.terminal) {
terminalMilestone = milestone.id;
break;
}
currentMilestoneId = transition.next_milestone;
}
if (terminalMilestone == null) {
throw new Error(`capability exceeded ${MAX_TRANSITIONS} milestone transitions`);
}
if (!capability.completion.terminal_milestones.includes(terminalMilestone)) {
throw new Error(`undeclared terminal milestone: ${terminalMilestone}`);
}
for (const output of capability.completion.required_outputs) {
const declaration = outputDeclarations.get(output);
if (declaration == null || !requiredOutputSatisfied(declaration, outputs[output])) {
throw new Error(`required output is missing or incomplete: ${output}`);
}
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
failure = sanitizedErrorMetadata(
classifyRunFailure(error),
detail,
downloadEvidenceCode(error),
);
}
const outputsPath = join(resolvedRunDirectory, "outputs.json");
const receiptPath = join(resolvedRunDirectory, "run.receipt.json");
const lockPath = join(resolvedRunDirectory, "run.lock.json");
const recoveryProposalsPath =
recoveryProposals.length === 0
? null
: join(resolvedRunDirectory, "recovery.proposals.json");
await writeOwnerOnly(outputsPath, canonicalJson(outputs));
let recoveryProposalsText = null;
if (recoveryProposalsPath != null) {
recoveryProposalsText = canonicalJson({
schema_version: RECOVERY_PROPOSAL_SCHEMA,
runtime_version: RUNTIME_VERSION,
run_id: runId,
capability_id: capability.capability_id,
capability_version: capability.version,
execution_contract_sha256: executionHash,
discovery_record_sha256: capability.provenance.discovery_record_sha256,
proposals: recoveryProposals,
portable: false,
requires_operator_review_before_persistence: true,
});
await writeOwnerOnly(recoveryProposalsPath, recoveryProposalsText);
}
const receipt = {
schema_version: RECEIPT_SCHEMA,
runtime_version: RUNTIME_VERSION,
run_id: runId,
capability_id: capability.capability_id,
capability_version: capability.version,
execution_contract_sha256: executionHash,
discovery_record_sha256: capability.provenance.discovery_record_sha256,
started_at: startedAt,
finished_at: clock(),
result: failure == null ? "passed" : "failed",
entry_milestone: capability.entry_milestone,
completed_milestones: completedMilestones,
terminal_milestone: terminalMilestone,
action_results: actionResults,
outputs: receiptOutputEntries(capability, outputs),
input_hashes: inputHashes,
locator_changes_during_run: recoveryProposals.length > 0,
private_evidence_retained: false,
environment: {
execution_mode: executionMode,
browser: executionMode === "live_connected_chrome" ? "existing_chrome" : executionMode,
controller: executionMode === "live_connected_chrome" ? "chrome_extension" : executionMode,
origin_ui: capability.site.name,
locale: environment.locale ?? "unknown",
},
error: failure,
};
const receiptText = canonicalJson(receipt);
await writeOwnerOnly(receiptPath, receiptText);
const lock = {
schema_version:
recoveryProposalsText == null ? "browser-run-lock/v1" : "browser-run-lock/v2",
run_id: runId,
capability_id: capability.capability_id,
execution_contract_sha256: executionHash,
outputs_sha256: sha256Text(canonicalJson(outputs)),
receipt_sha256: sha256Text(receiptText),
...(recoveryProposalsText == null
? {}
: { recovery_proposals_sha256: sha256Text(recoveryProposalsText) }),
};
await writeOwnerOnly(lockPath, canonicalJson(lock));
const summary = publicSummary(
receiptPath,
outputsPath,
lockPath,
recoveryProposalsPath,
recoveryProposals.length,
receipt,
capability,
outputs,
context.pendingRecoveryRequest,
);
if (failure != null) {
const error = new Error(
`browser capability run failed: ${failure.code} (${failure.detail_sha256})`,
);
error.code = failure.code;
error.reasonCode = failure.reason_code;
error.detailSha256 = failure.detail_sha256;
error.runSummary = summary;
throw error;
}
return summary;
}
SHA-256: fe3052420fd1795f6f834a0928f7c7c6bf51478fb87acbe5896b4ecf67314a11