Files
harness-tools/extensions/fast-browser/contracts.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

633 lines
21 KiB
TypeScript

/** Shared prototype contracts. Browser handles never come from model output. */
export type BrowserAction = "click" | "fill";
export interface Candidate {
id: string;
role: string;
name: string;
actions: BrowserAction[];
}
export interface Observation {
observationId: string;
url: string;
title: string;
candidates: Candidate[];
}
export type Postcondition =
| { kind: "url"; value: string }
| { kind: "text"; value: string };
export interface ActRequest {
observationId: string;
candidateId: string;
action: BrowserAction;
text?: string;
/** Mandatory for clicks. Filling always verifies the actual field value. */
postcondition?: Postcondition;
}
export interface ActResult {
verified: boolean;
evidence: string;
}
export interface BrowserBridge {
observe(signal: AbortSignal): Promise<Observation>;
/** Must revalidate the snapshot's tab/document/target immediately before mutation,
* consume all old observations, and verify the outcome. Never retry a mutation. */
act(request: ActRequest, signal: AbortSignal): Promise<ActResult>;
/** Must terminate owned workers before releasing the caller's browser lane. */
close(): Promise<void>;
}
export interface Step {
instruction: string;
action: BrowserAction;
/** A literal is inserted exactly; compose asks the auxiliary model for new text. */
text?: string;
compose?: string;
/** Reference to a credential supplied by the initiating session; never sent to JEV. */
credential?: string;
postcondition?: Postcondition;
}
export interface Decision {
candidateId: string | null;
confidence: number;
model: string;
}
export interface DecisionInput {
instruction: string;
action: BrowserAction;
observation: Observation;
}
export type CredentialSource = string | { env: string } | { file: string; key?: string };
export type CredentialBindings = Record<string, CredentialSource>;
export interface StepTrace {
step: number;
status: "verified" | "abstained" | "unverified" | "error";
timingsMs: Record<string, number>;
/** Always emitted by the controller; unknown forbids automatic retry. */
mutationState?: "not_attempted" | "verified" | "unknown";
candidateId?: string;
confidence?: number;
model?: string;
evidence?: string;
}
export interface RunResult {
status: "completed" | "stopped";
reason: string;
steps: StepTrace[];
elapsedMs: number;
/** True when a dispatched action lacks a verified outcome. Never blindly retry. */
actionMayHaveExecuted?: boolean;
}
/**
* Development contracts for the session-scoped runtime. The prototype
* BrowserBridge above remains the compatibility boundary for observe/act/close;
* new workers should implement BrowserRuntime rather than adding ad-hoc CDP
* methods to BrowserBridge.
*/
export type MutationState = "not_attempted" | "verified" | "unknown";
export type BrowserErrorCode =
| "aborted"
| "timeout"
| "stale"
| "policy_denied"
| "invalid_request"
| "no_tab"
| "ambiguous_tab"
| "browser_closed"
| "unsupported"
| "worker_failure";
/** Safe to serialize to a report. Never include a URL query/fragment, headers,
* bodies, field values, page text, provider response, or raw exception. */
export interface BrowserErrorDetails {
code: BrowserErrorCode;
operation: BrowserRuntimeOperationName;
phase: string;
message: string;
mutationState: MutationState;
retry: "never" | "safe";
}
export class BrowserOperationError extends Error {
readonly details: BrowserErrorDetails;
override name = "BrowserOperationError";
constructor(details: BrowserErrorDetails) {
super(details.message);
this.details = details;
}
}
/** Fixed budgets: implementations may choose a lower internal deadline, but
* no caller-supplied generic timeout may exceed these ceilings. */
export const FAST_BROWSER_BUDGETS = {
browserStartupMs: { default: 10_000, hardCeiling: 10_000 },
workerReadyMs: { default: 5_000, hardCeiling: 5_000 },
bridgeRequestMs: { default: 10_000, hardCeiling: 120_000 },
actionVerificationMs: { default: 3_000, hardCeiling: 3_000 },
readAssertionWaitMs: { default: 3_000, hardCeiling: 3_000 },
navigationMs: { default: 10_000, hardCeiling: 10_000 },
reloadMs: { default: 10_000, hardCeiling: 10_000 },
popupWaitMs: { default: 5_000, hardCeiling: 5_000 },
screenshotMs: { default: 10_000, hardCeiling: 60_000 },
failureEvidenceMs: { default: 2_000, hardCeiling: 2_000 },
runMs: { default: 60_000, hardCeiling: 120_000 },
} as const;
/** The session base policy is the existing '*' default or a non-empty exact
* origin list. A runtime policy request may only narrow that base. */
export type RuntimeOriginPolicy = "*" | readonly string[];
/** Per-operation policy context. It is derived from the immutable session base
* and is not a persistent cross-call mutation. Omit it to use the base. */
export interface RuntimeOperationContext {
allowedOrigins: RuntimeOriginPolicy;
}
/** Opaque and stable for the life of a target; never model-created. */
export type BrowserTabId = string & { readonly __fastBrowserTabId: unique symbol };
export interface BrowserTab {
tabId: BrowserTabId;
kind: "page" | "popup";
openerTabId?: BrowserTabId;
selected: boolean;
owned: true;
/** Internal runtime value; public reports must reduce this to origin. */
url: string;
origin: string;
title: string;
}
export interface ReadRequest {
tabId?: BrowserTabId;
}
/** Read returns rendered visible text only. It never returns form values,
* password values, hidden text, HTML, frames, or arbitrary script output. */
export interface PageRead {
tab: BrowserTab;
documentId: string;
url: string;
title: string;
visibleText: string;
visibleTextTruncated: boolean;
}
export type PageAssertion =
| { kind: "url"; match: "exact" | "origin"; value: string }
| { kind: "title"; match: "exact" | "contains"; value: string }
| { kind: "visibleText"; match: "contains"; value: string };
export interface AssertRequest {
tabId?: BrowserTabId;
assertion: PageAssertion;
/** "bounded" uses the fixed readAssertionWaitMs budget and performs no
* mutation; "none" checks one read immediately. */
wait: "none" | "bounded";
}
export interface AssertResult {
status: "satisfied" | "not_satisfied" | "timed_out";
readOnly: true;
tab: BrowserTab;
documentId: string;
}
export interface NavigateRequest {
tabId?: BrowserTabId;
/** HTTP(S), without credentials; destination origin must be approved. */
url: string;
}
export interface NavigationResult {
status: "committed";
mutationState: "verified";
tab: BrowserTab;
documentId: string;
}
export interface ReloadRequest {
tabId?: BrowserTabId;
}
export interface ReloadResult {
status: "committed";
mutationState: "verified";
tab: BrowserTab;
documentId: string;
}
export interface SelectTabRequest {
tabId: BrowserTabId;
}
export interface SelectTabResult {
selected: true;
tab: BrowserTab;
}
export interface WaitPopupRequest {
openerTabId: BrowserTabId;
}
export interface WaitPopupResult {
status: "found" | "timed_out";
/** A found popup is returned, but is never selected automatically. */
tab?: BrowserTab;
autoSwitched: false;
}
export interface CloseTabRequest {
tabId: BrowserTabId;
}
export interface CloseTabResult {
closed: true;
tabId: BrowserTabId;
mutationState: "verified";
}
export interface NarrowOriginPolicyRequest {
allowedOrigins: RuntimeOriginPolicy;
}
export interface ConsoleFailureSummary {
level: "error" | "warning";
message: string;
count: number;
truncated: boolean;
}
/** Network evidence is deliberately origin-only: no query/fragment, headers,
* cookies, request/response bodies, or field values. */
export interface NetworkFailureSummary {
method: string;
origin: string;
status?: number;
category: "http" | "network" | "blocked";
}
export type PrivateArtifactKind = "screenshot" | "diagnostics";
export interface PrivateArtifactOwner {
sessionId: string;
runId: string;
operationId: string;
}
export interface PrivateArtifactMetadata {
artifactId: string;
kind: PrivateArtifactKind;
contentType: "image/png" | "application/json";
path: string;
bytes: number;
owner: PrivateArtifactOwner;
permissions: "0600";
localOnly: true;
modelForwarded: false;
}
/** No caller-selected path: the store owns the path and commits atomically. */
export interface PrivateArtifactWriter {
readonly owner: PrivateArtifactOwner;
readonly kind: PrivateArtifactKind;
write(chunk: Uint8Array): Promise<void>;
commit(): Promise<PrivateArtifactMetadata>;
abort(): Promise<void>;
}
export interface PrivateArtifactStore {
open(owner: PrivateArtifactOwner, kind: PrivateArtifactKind): Promise<PrivateArtifactWriter>;
close(): Promise<void>;
}
/** JSONL-safe worker-to-parent bytes. The parent decodes dataBase64 and writes
* it through PrivateArtifactStore; worker requests never contain caller paths. */
export interface BrowserTransportArtifactPayload {
kind: PrivateArtifactKind;
contentType: "image/png" | "application/json";
encoding: "base64";
dataBase64: string;
byteLength: number;
truncated: boolean;
}
export interface BrowserTransportEvidence {
status: "complete" | "partial" | "unavailable";
screenshot?: BrowserTransportArtifactPayload;
diagnostics?: BrowserTransportArtifactPayload;
console: readonly ConsoleFailureSummary[];
failedRequests: readonly NetworkFailureSummary[];
}
export const FAST_BROWSER_ARTIFACT_LIMITS = {
screenshotBytes: 8 * 1024 * 1024,
diagnosticsBytes: 256 * 1024,
} as const;
export const BROWSER_EVIDENCE_OPERATIONS = [
"observe", "act", "read", "assert", "navigate", "reload", "listTabs",
"selectTab", "waitPopup", "closeTab", "captureEvidence", "narrowOriginPolicy",
] as const;
export type BrowserEvidenceOperationName = (typeof BROWSER_EVIDENCE_OPERATIONS)[number];
export interface EvidenceRequest {
/** Caller/scenario run identity; the runtime creates the operation identity. */
runId: string;
reason: "failure" | "requested";
operation?: BrowserEvidenceOperationName;
}
export interface FailureEvidence {
status: "complete" | "partial" | "unavailable";
screenshot?: PrivateArtifactMetadata;
diagnostics?: PrivateArtifactMetadata;
console: readonly ConsoleFailureSummary[];
failedRequests: readonly NetworkFailureSummary[];
}
/** A persistent per-session bridge. Different sessions may run in parallel;
* all operations on one runtime are serialized. Tab handles/document identity
* survive calls until the target/document is closed or navigated. */
export interface BrowserRuntime extends BrowserBridge {
readonly sessionId: string;
/** Computes one effective request policy; it never permanently tightens the
* immutable session base. Pass the returned context to one or more calls in
* the same explicitly scoped operation, or omit context to restore base. */
narrowOriginPolicy(request: NarrowOriginPolicyRequest, signal: AbortSignal): Promise<RuntimeOperationContext>;
observe(signal: AbortSignal, context?: RuntimeOperationContext): Promise<Observation>;
act(request: ActRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<ActResult>;
read(request: ReadRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<PageRead>;
assert(request: AssertRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<AssertResult>;
navigate(request: NavigateRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<NavigationResult>;
reload(request: ReloadRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<ReloadResult>;
listTabs(signal: AbortSignal, context?: RuntimeOperationContext): Promise<readonly BrowserTab[]>;
selectTab(request: SelectTabRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<SelectTabResult>;
waitPopup(request: WaitPopupRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<WaitPopupResult>;
closeTab(request: CloseTabRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<CloseTabResult>;
captureEvidence(request: EvidenceRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise<FailureEvidence>;
}
export interface BrowserRuntimeOptions {
sessionId: string;
cdpUrl: string;
allowedOrigins: RuntimeOriginPolicy;
/** Explicit interpreter selection for the pinned worker environment. */
python?: string;
artifacts: PrivateArtifactStore;
/** Test/embedding seam; production may use the default transport factory. */
transportFactory?: BrowserTransportFactory;
}
export interface BrowserRuntimeFactory {
create(options: BrowserRuntimeOptions, parentSignal?: AbortSignal): BrowserRuntime;
}
/** The transport owns JSONL framing, validation, deadlines, and worker
* teardown. BrowserRuntime owns persistent tab/policy state and delegates one
* typed operation at a time; neither layer implements the other's boundary. */
export interface BrowserTransportRequest<Operation extends BrowserOperation = BrowserOperation> {
operation: Operation;
context?: RuntimeOperationContext;
}
export interface BrowserTransportCloseResult {
closed: true;
}
/** Exact result mapping for every BrowserOperation variant. */
export type BrowserTransportResultFor<Operation extends BrowserOperation> =
Operation extends { op: "observe" } ? { op: "observe"; result: Observation } :
Operation extends { op: "act" } ? { op: "act"; result: ActResult } :
Operation extends { op: "read" } ? { op: "read"; result: PageRead } :
Operation extends { op: "assert" } ? { op: "assert"; result: AssertResult } :
Operation extends { op: "navigate" } ? { op: "navigate"; result: NavigationResult } :
Operation extends { op: "reload" } ? { op: "reload"; result: ReloadResult } :
Operation extends { op: "listTabs" } ? { op: "listTabs"; result: readonly BrowserTab[] } :
Operation extends { op: "selectTab" } ? { op: "selectTab"; result: SelectTabResult } :
Operation extends { op: "waitPopup" } ? { op: "waitPopup"; result: WaitPopupResult } :
Operation extends { op: "closeTab" } ? { op: "closeTab"; result: CloseTabResult } :
Operation extends { op: "captureEvidence" } ? { op: "captureEvidence"; result: BrowserTransportEvidence } :
Operation extends { op: "narrowOriginPolicy" } ? { op: "narrowOriginPolicy"; result: RuntimeOperationContext } :
Operation extends { op: "close" } ? { op: "close"; result: BrowserTransportCloseResult } :
never;
export type BrowserTransportResult = BrowserTransportResultFor<BrowserOperation>;
export interface BrowserTransport {
dispatch<Operation extends BrowserOperation>(
request: BrowserTransportRequest<Operation>,
signal: AbortSignal,
): Promise<BrowserTransportResultFor<Operation>>;
close(): Promise<void>;
}
export interface BrowserTransportOptions {
cdpUrl: string;
allowedOrigins: RuntimeOriginPolicy;
python?: string;
}
export interface BrowserTransportFactory {
create(options: BrowserTransportOptions, parentSignal?: AbortSignal): BrowserTransport;
}
export const BROWSER_RUNTIME_OPERATIONS = [
"observe", "act", "read", "assert", "navigate", "reload", "listTabs",
"selectTab", "waitPopup", "closeTab", "captureEvidence", "narrowOriginPolicy", "close",
] as const;
export type BrowserRuntimeOperationName = (typeof BROWSER_RUNTIME_OPERATIONS)[number];
/** Explicit JSONL/CLI operation shapes. There is no arbitrary CDP or JS arm. */
export type BrowserOperation =
| { op: "observe" }
| { op: "act"; request: ActRequest }
| { op: "read"; request: ReadRequest }
| { op: "assert"; request: AssertRequest }
| { op: "navigate"; request: NavigateRequest }
| { op: "reload"; request: ReloadRequest }
| { op: "listTabs" }
| { op: "selectTab"; request: SelectTabRequest }
| { op: "waitPopup"; request: WaitPopupRequest }
| { op: "closeTab"; request: CloseTabRequest }
| { op: "captureEvidence"; request: EvidenceRequest }
| { op: "narrowOriginPolicy"; request: NarrowOriginPolicyRequest }
| { op: "close" };
/** Scenario references are data-only handles resolved by the CLI runner.
* They cannot contain selectors, expressions, templates, CDP IDs, or JS. */
/** Scenario files are bounded to 32 operations by the CLI owner. */
export type ScenarioOperationIndex =
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
| 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15
| 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23
| 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31;
export type ScenarioOperationRef =
| { index: ScenarioOperationIndex }
| { id: string };
/** Tab list indices are intentionally finite and bounded for JSON scenarios. */
export type ScenarioTabIndex = ScenarioOperationIndex;
/** Initial means the runtime's selected tab at scenario start. Operation
* references must point to an earlier operation; the runner rejects forward
* references and duplicate/unknown IDs. */
export type ScenarioTabReference =
| { source: "initial"; target: "selected" }
| {
source: "operation";
operation: ScenarioOperationRef;
result: "selectedTab" | "popup";
}
| {
source: "operation";
operation: ScenarioOperationRef;
result: "listedTab";
index: ScenarioTabIndex;
};
/** An origin selector must resolve to exactly one bounded listed tab. It never
* changes the selected runtime tab by itself. */
export type ScenarioTabSelection =
| { kind: "index"; index: ScenarioTabIndex }
| { kind: "origin"; origin: string };
export interface ScenarioReadRequest {
tab?: ScenarioTabReference;
}
export interface ScenarioAssertRequest {
tab?: ScenarioTabReference;
assertion: PageAssertion;
wait: "none" | "bounded";
}
export interface ScenarioNavigateRequest {
tab?: ScenarioTabReference;
url: string;
}
export interface ScenarioReloadRequest {
tab?: ScenarioTabReference;
}
export interface ScenarioListTabsRequest {
/** Lists deterministically and optionally identifies one unique result;
* selection still requires a later explicit selectTab operation. */
select?: ScenarioTabSelection;
}
export interface ScenarioSelectTabRequest {
tab: ScenarioTabReference;
}
export interface ScenarioWaitPopupRequest {
opener: ScenarioTabReference;
}
export interface ScenarioCloseTabRequest {
tab: ScenarioTabReference;
}
export interface ScenarioOperationMetadata {
/** Optional stable local reference for later ScenarioOperationRef{id}. */
id?: string;
}
/** Scenarios reuse the existing bounded Step instead of inventing browser code. */
export type ScenarioOperation =
| ({ op: "step"; step: Step } & ScenarioOperationMetadata)
| ({ op: "read"; request: ScenarioReadRequest } & ScenarioOperationMetadata)
| ({ op: "assert"; request: ScenarioAssertRequest } & ScenarioOperationMetadata)
| ({ op: "navigate"; request: ScenarioNavigateRequest } & ScenarioOperationMetadata)
| ({ op: "reload"; request: ScenarioReloadRequest } & ScenarioOperationMetadata)
| ({ op: "listTabs"; request?: ScenarioListTabsRequest } & ScenarioOperationMetadata)
| ({ op: "selectTab"; request: ScenarioSelectTabRequest } & ScenarioOperationMetadata)
| ({ op: "waitPopup"; request: ScenarioWaitPopupRequest } & ScenarioOperationMetadata)
| ({ op: "closeTab"; request: ScenarioCloseTabRequest } & ScenarioOperationMetadata);
export const FAST_BROWSER_SCENARIO_FILE_VERSION = 1 as const;
export const FAST_BROWSER_REPORT_VERSION = 1 as const;
export const FAST_BROWSER_SCENARIO_SERVER_BOUNDS = {
readinessTimeoutMs: { default: 10_000, hardCeiling: 30_000 },
readinessPollMs: { default: 100, hardCeiling: 1_000 },
serverShutdownMs: { default: 5_000, hardCeiling: 10_000 },
} as const;
/** Existing scenario hooks use this same trusted argv/cwd/env-reference
* shape. `start` is long-lived and is supervised until scenario finally; it is
* not a setup/reset/cleanup hook and has no shell mode. */
export interface ScenarioProcessHook {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string | { env: string }>;
timeoutMs?: number;
}
export interface ScenarioHttpReadiness {
/** GET-only HTTP(S) URL supplied by scenario/user config, never page data. */
url: string;
expectedStatus?: number;
timeoutMs?: number;
}
export type ScenarioServerConfig =
| {
mode: "managed";
start: ScenarioProcessHook;
readiness: ScenarioHttpReadiness;
shutdownTimeoutMs?: number;
}
| {
mode: "external";
readiness: ScenarioHttpReadiness;
};
export interface ScenarioFixtureConfig {
/** Explicit HTTP(S) fixture base URL; no credentials or page-derived URL. */
baseUrl: string;
server?: ScenarioServerConfig;
}
export interface ScenarioDefinition {
version: typeof FAST_BROWSER_SCENARIO_FILE_VERSION;
id: string;
operations: readonly ScenarioOperation[];
fixture?: ScenarioFixtureConfig;
}
export interface ScenarioOperationReport {
index: number;
op: ScenarioOperation["op"];
status: "passed" | "failed" | "skipped";
elapsedMs: number;
mutationState: MutationState;
error?: BrowserErrorDetails;
}
export interface ScenarioReport {
version: typeof FAST_BROWSER_REPORT_VERSION;
scenarioId: string;
status: "passed" | "failed" | "skipped";
elapsedMs: number;
operations: readonly ScenarioOperationReport[];
evidence: readonly PrivateArtifactMetadata[];
cleanup: "complete" | "failed";
}