← Files VeraARCHIVED FILE

modules/browser-automation/scripts/discovery_runtime.mjs

14.3 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

/**
 * Observe bounded browser state changes while an operator demonstrates a process.
 *
 * This runtime is intentionally read-only. It captures query-free paths and
 * visible control metadata while excluding form values and controls inside
 * tables, grids, and rows. The model interprets the state changes and authors
 * the semantic discovery record; this module only makes repeated observation
 * and hashing reproducible.
 */

import { canonicalJson, sha256Text } from "./capability_runtime.mjs";

export const DISCOVERY_RUNTIME_VERSION = "browser-discovery-runtime/4";

const DEFAULT_MAX_CONTROLS = 120;
const MAX_CONTROLS = 250;
const DEFAULT_POLL_INTERVAL_MS = 500;
const MAX_GUIDED_WINDOW_MS = 60_000;
const PRIVATE_IDENTIFIER_MARKER = "[private identifier]";
const CONTROL_TEXT_FIELDS = ["name", "label", "placeholder", "test_id"];

// This fixed redaction runs before control metadata leaves the local runtime.
// It is deterministic because excluding recognizable identifier shapes is a
// mechanically testable security boundary, not a semantic relevance decision.
const PRIVATE_IDENTIFIER_PATTERNS = [
  /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu,
  /\b[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\b/giu,
  /\b[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]\b/giu,
  /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/giu,
  /\b(?=[A-Z0-9._/-]{8,}\b)(?=[A-Z0-9._/-]*[A-Z])(?=[A-Z0-9._/-]*\d)[A-Z0-9._/-]+\b/giu,
  /\b\d{2,}(?:[\s./_-]+\d{2,})+\b/gu,
  /\b\d{4,}\b/gu,
];

function redactPrivateIdentifiers(value) {
  if (typeof value !== "string" || !value) {
    return { value, redacted: false };
  }
  let sanitized = value;
  for (const pattern of PRIVATE_IDENTIFIER_PATTERNS) {
    sanitized = sanitized.replace(pattern, PRIVATE_IDENTIFIER_MARKER);
  }
  sanitized = sanitized
    .replace(/(?:\[private identifier\]\s*){2,}/gu, `${PRIVATE_IDENTIFIER_MARKER} `)
    .replace(/\s+/gu, " ")
    .trim();
  return { value: sanitized, redacted: sanitized !== value };
}

function sanitizeControlMetadata(control) {
  const sanitized = { ...control };
  const redactedFields = [];
  for (const field of CONTROL_TEXT_FIELDS) {
    const result = redactPrivateIdentifiers(control[field]);
    if (!result.redacted) continue;
    sanitized[field] = field === "test_id" ? null : result.value;
    redactedFields.push(field);
  }
  if (redactedFields.length > 0) {
    sanitized.redacted_fields = redactedFields;
  }
  return sanitized;
}

function normalizeOrigin(value) {
  return new URL(value).origin;
}

function queryFreePath(value) {
  return new URL(value).pathname;
}

function allowedPage(url, allowedOrigins) {
  const parsed = new URL(url);
  if (!allowedOrigins.has(parsed.origin)) {
    throw new Error(`guided discovery left allowed origins: ${parsed.origin}`);
  }
  return { origin: parsed.origin, path: parsed.pathname };
}

function validateOptions({ allowedOrigins, maxControls, includeStructuredControls }) {
  if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
    throw new Error("guided discovery requires allowed origins");
  }
  if (!Number.isInteger(maxControls) || maxControls < 1 || maxControls > MAX_CONTROLS) {
    throw new Error(`maxControls must be between 1 and ${MAX_CONTROLS}`);
  }
  if (typeof includeStructuredControls !== "boolean") {
    throw new Error("includeStructuredControls must be boolean");
  }
  return new Set(allowedOrigins.map(normalizeOrigin));
}

function stateProjection(state) {
  return {
    origin: state.origin,
    path: state.path,
    controls: state.controls,
    truncated: state.truncated,
    frame_depth: state.frame_depth,
    frame_origin: state.frame_origin,
    frame_path: state.frame_path,
  };
}

/** Capture one privacy-bounded page state through the connected Playwright API. */
export async function captureControlState({
  tab,
  allowedOrigins,
  maxControls = DEFAULT_MAX_CONTROLS,
  includeStructuredControls = false,
  frameSelectors = [],
}) {
  // Explicit selection prevents unrelated frames from entering model context.
  if (!Array.isArray(frameSelectors) || frameSelectors.length > 5 ||
      frameSelectors.some((selector) => typeof selector !== "string" || !selector.trim())) {
    throw new Error("frameSelectors must contain at most five non-empty selectors");
  }
  const normalizedOrigins = validateOptions({
    allowedOrigins,
    maxControls,
    includeStructuredControls,
  });
  const currentUrl = await tab.url();
  const page = allowedPage(currentUrl, normalizedOrigins);
  let scope = tab.playwright;
  for (const selector of frameSelectors) {
    // Select exactly one frame through Chrome, including isolated frame documents.
    // Read only origin metadata until the explicitly allowed boundary is checked.
    let unique;
    try {
      unique = await scope.locator(selector).evaluateAll((elements) =>
        elements.length === 1 && elements[0].tagName.toLowerCase() === "iframe");
    } catch {
      throw new Error("guided discovery: frame_unavailable");
    }
    if (!unique) throw new Error("guided discovery: frame_not_unique");
    let origin;
    try {
      scope = scope.frameLocator(selector);
      origin = await scope.locator("html").evaluate((element) =>
        new URL(element.ownerDocument.URL).origin);
    } catch {
      throw new Error("guided discovery: frame_unavailable");
    }
    if (!normalizedOrigins.has(origin)) {
      throw new Error("guided discovery: frame_origin_not_allowed");
    }
  }
  const project = (elementOrOptions, frameOptions) => {
      const { controlLimit, includeStructured, frames, origins } = frameOptions ?? elementOrOptions;
      const observedDocument = frameOptions ? elementOrOptions.ownerDocument : document;
      if (!origins.includes(new URL(observedDocument.URL).origin)) {
        return { failure: frames.length ? "frame_origin_not_allowed" : "page_origin_not_allowed" };
      }
      const interactiveSelector = [
        "a[href]",
        "button",
        "input",
        "select",
        "textarea",
        "[contenteditable='true']",
        "[role]",
      ].join(",");
      const structuredSelector = "table,[role='grid'],[role='row'],[role='treegrid']";
      const compact = (value) => {
        if (typeof value !== "string") return null;
        const normalized = value.replace(/\s+/g, " ").trim();
        if (!normalized) return null;
        return normalized.slice(0, 120);
      };
      const isVisible = (element) => {
        const style = observedDocument.defaultView.getComputedStyle(element);
        const bounds = element.getBoundingClientRect();
        return (
          style.visibility !== "hidden" &&
          style.display !== "none" &&
          bounds.width > 0 &&
          bounds.height > 0
        );
      };
      const associatedLabel = (element) => {
        if (element.labels?.length) return compact(element.labels[0].innerText);
        const id = element.getAttribute("id");
        if (!id) return null;
        const label = Array.from(observedDocument.querySelectorAll("label")).find(
          (candidate) => candidate.htmlFor === id,
        );
        return compact(label?.innerText);
      };
      const inferredRole = (element) => {
        const explicit = compact(element.getAttribute("role"));
        if (explicit) return explicit;
        const tag = element.tagName.toLowerCase();
        if (tag === "button") return "button";
        if (tag === "a") return "link";
        if (tag === "select") return "combobox";
        if (tag === "textarea") return "textbox";
        if (tag === "input") {
          const type = (element.getAttribute("type") || "text").toLowerCase();
          if (type === "checkbox") return "checkbox";
          if (type === "radio") return "radio";
          if (["button", "submit", "reset"].includes(type)) return "button";
          return "textbox";
        }
        return null;
      };
      const controlName = (element, structuredContext) => {
        const direct =
          compact(element.getAttribute("aria-label")) ||
          associatedLabel(element) ||
          compact(element.getAttribute("placeholder")) ||
          compact(element.getAttribute("title"));
        if (direct) return direct;
        if (structuredContext) return null;
        const tag = element.tagName.toLowerCase();
        if (tag === "button" || tag === "a") return compact(element.innerText);
        return null;
      };
      const nodes = Array.from(observedDocument.querySelectorAll(interactiveSelector)).filter(
        (element) =>
          isVisible(element) &&
          (includeStructured || element.closest(structuredSelector) == null),
      );
      const controls = nodes.slice(0, controlLimit).map((element) => {
        const structuredContext = element.closest(structuredSelector) != null;
        return {
          tag: element.tagName.toLowerCase(),
          role: inferredRole(element),
          name: controlName(element, structuredContext),
          label: associatedLabel(element),
          placeholder: compact(element.getAttribute("placeholder")),
          test_id:
            compact(element.getAttribute("data-testid")) ||
            compact(element.getAttribute("data-test-id")),
          type: compact(element.getAttribute("type")),
          disabled:
            element.matches(":disabled") ||
            element.getAttribute("aria-disabled") === "true",
          structured_context: structuredContext,
        };
      });
      return {
        controls,
        total_control_count: nodes.length,
        truncated: nodes.length > controlLimit,
        frame_count: observedDocument.querySelectorAll("iframe").length,
        frame_origin: frames.length ? new URL(observedDocument.URL).origin : null,
        frame_path: frames.length ? new URL(observedDocument.URL).pathname : null,
      };
  };
  const options = {
    controlLimit: maxControls,
    includeStructured: includeStructuredControls,
    frames: frameSelectors,
    origins: [...normalizedOrigins],
  };
  let inventory;
  try {
    inventory = frameSelectors.length
      ? await scope.locator("html").evaluate(project, options)
      : await tab.playwright.evaluate(project, options);
  } catch (error) {
    if (frameSelectors.length) throw new Error("guided discovery: frame_unavailable");
    throw error;
  }
  if (inventory.failure) throw new Error(`guided discovery: ${inventory.failure}`);
  const state = {
    schema_version: "browser-control-state/v1",
    runtime_version: DISCOVERY_RUNTIME_VERSION,
    origin: page.origin,
    path: page.path,
    controls: inventory.controls.map(sanitizeControlMetadata),
    total_control_count: inventory.total_control_count,
    truncated: inventory.truncated,
    frame_depth: frameSelectors.length,
    frame_origin: inventory.frame_origin ?? null,
    frame_path: inventory.frame_path ?? null,
    unobserved_frame_count: inventory.frame_count ?? 0,
  };
  return {
    ...state,
    control_fingerprint: sha256Text(canonicalJson(stateProjection(state))),
  };
}

/** Return a mechanical delta between two captured control states. */
export function diffControlStates(before, after) {
  const keyed = (controls) =>
    new Map(controls.map((control) => [sha256Text(canonicalJson(control)), control]));
  const beforeControls = keyed(before.controls);
  const afterControls = keyed(after.controls);
  return {
    path_changed: before.origin !== after.origin || before.path !== after.path ||
      before.frame_origin !== after.frame_origin || before.frame_path !== after.frame_path,
    added_controls: [...afterControls]
      .filter(([key]) => !beforeControls.has(key))
      .map(([, control]) => control),
    removed_controls: [...beforeControls]
      .filter(([key]) => !afterControls.has(key))
      .map(([, control]) => control),
  };
}

/**
 * Observe a short operator demonstration window without injecting listeners or
 * recording form values. The caller asks the operator to demonstrate while
 * this bounded polling loop runs, then the model interprets the returned deltas.
 */
export async function observeGuidedWindow({
  tab,
  allowedOrigins,
  durationMs = 15_000,
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
  maxControls = DEFAULT_MAX_CONTROLS,
  maxTransitions = 20,
  includeStructuredControls = false,
  frameSelectors = [],
  signal,
}) {
  if (!Number.isInteger(durationMs) || durationMs < 1_000 || durationMs > MAX_GUIDED_WINDOW_MS) {
    throw new Error(`durationMs must be between 1000 and ${MAX_GUIDED_WINDOW_MS}`);
  }
  if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 100 || pollIntervalMs > 5_000) {
    throw new Error("pollIntervalMs must be between 100 and 5000");
  }
  if (!Number.isInteger(maxTransitions) || maxTransitions < 1 || maxTransitions > 100) {
    throw new Error("maxTransitions must be between 1 and 100");
  }
  const startedAt = Date.now();
  let previous = await captureControlState({
    tab,
    allowedOrigins,
    maxControls,
    includeStructuredControls,
    frameSelectors,
  });
  const initial = previous;
  const transitions = [];
  while (Date.now() - startedAt < durationMs && transitions.length < maxTransitions &&
         !signal?.aborted) {
    await tab.playwright.waitForTimeout(pollIntervalMs);
    if (signal?.aborted) break;
    const current = await captureControlState({
      tab,
      allowedOrigins,
      maxControls,
      includeStructuredControls,
      frameSelectors,
    });
    if (current.control_fingerprint === previous.control_fingerprint) continue;
    transitions.push({
      sequence: transitions.length + 1,
      elapsed_ms: Date.now() - startedAt,
      before: previous,
      after: current,
      delta: diffControlStates(previous, current),
    });
    previous = current;
  }
  return {
    schema_version: "browser-guided-capture/v1",
    runtime_version: DISCOVERY_RUNTIME_VERSION,
    duration_ms: Date.now() - startedAt,
    started_at: new Date(startedAt).toISOString(),
    ended_at: new Date().toISOString(),
    observing: false,
    stop_reason: signal?.aborted ? "operator_pause" :
      transitions.length >= maxTransitions ? "transition_limit" : "time_limit",
    interpretation_required: true,
    initial,
    transitions,
    final: previous,
    capture_policy: {
      query_free_paths_only: true,
      form_values_excluded: true,
      structured_rows_excluded: !includeStructuredControls,
      structured_control_values_excluded: true,
      private_identifier_tokens_redacted: true,
      screenshots_excluded: true,
    },
  };
}

export function queryFreeUrl(value) {
  const url = new URL(value);
  return `${url.origin}${queryFreePath(value)}`;
}

SHA-256: d0b57d2c373943f583e8e2c18ed3b77e8a9c784b3831efa3aca73e26c7c40c09