/** Public driver contract consumed by dsh-qa and other orchestration plugins. */ export const BROWSER_DRIVER_SERVICE = 'zsevenBrowserDriver' as const export const BROWSER_DRIVER_CONTRACT_VERSION = 9 as const export type BrowserActionStatus = 'confirmed' | 'unknown' | 'rejected' | 'failed' export type BrowserActKind = 'click' | 'fill' | 'press' | 'navigate' | 'scroll' | 'select' | 'hover' /** * One cookie in the Playwright storageState JSON format. * * Cookies are HOST-SCOPED by the browser: once injected, the cookie is sent * to every origin (scheme and port) on its host — including subresource * requests to origins outside the operator allowlist. The exact-origin * allowlist can never narrow cookie delivery. The driver therefore fails the * session start closed unless the cookie's host maps onto the host of at * least one allowlisted origin; callers must only inject cookies for * operator-owned hosts. */ export interface BrowserStorageCookie { name: string value: string /** * Exact host or dot-prefixed parent (`.example.com`). Never a wildcard. * Dot-prefixed IP literals (`.127.0.0.1`) are invalid and rejected. */ domain: string path: string /** Unix time in seconds. */ expires: number httpOnly: boolean secure: boolean sameSite: 'Strict' | 'Lax' | 'None' /** * Alternative to `domain`: an absolute http(s) URL whose host the cookie * belongs to (Playwright url-form cookie). Checked by its host like * domain-form cookies. */ url?: string } /** * Playwright storageState JSON shape (cookies + per-origin localStorage). * The driver validates the entries against the operator policy and fails the * session start closed on a mismatch: every localStorage origin must be * exactly allowlisted, and every cookie host must map onto the host of an * allowlisted origin (see BrowserStorageCookie for the host-scoping rules). */ export interface BrowserStorageState { cookies: BrowserStorageCookie[] origins: Array<{ origin: string localStorage: Array<{ name: string; value: string }> }> } export interface BrowserSessionStartOptions { /** Initial http(s) URL. Omit to start at about:blank. */ url?: string /** Defaults to true. Headful mode is intended for local diagnosis only. */ headless?: boolean /** * Owner-authorized login state to pre-load into the fresh ephemeral * profile. The driver validates it at start: localStorage origins must be * exactly allowlisted and cookie hosts must map onto an allowlisted origin's * host, otherwise the start fails closed. Cookies are host-scoped by the * browser and reach every port/scheme of their host, so the allowlist can * only check the host, never narrow the delivery. The profile is destroyed * on stop as always. */ storageState?: BrowserStorageState } export interface BrowserSessionInfo { ownerId: string state: 'running' headless: boolean browser: { channel: 'chrome' | 'edge' | 'chromium' | 'custom' version: string } page: { url: string title: string } isolation: 'ephemeral-user-data' navigationPolicy: { mode: 'unrestricted' | 'allowlist' allowedOrigins: string[] } } export interface BrowserObservationOptions { /** Maximum returned semantic nodes. The driver clamps this to 1..100. */ maxNodes?: number /** * v8+: restrict the projection to the flattened subtree rooted at this * element. The value is an opaque ref from the caller's CURRENT observation * (the latest unexpired one in this Agent scope) — including a scoped * observation's own scope.rootRef (v9). The driver resolves it exactly as * actions do — same staleness/expiry rules, same rejection vocabulary — and * then collects semantic nodes from that subtree only: the root element * plus its flattened-tree descendants (light children, slotted children at * their assigned-slot render position, every open shadow root inside), in * flattened-tree order and with the same atomic handle-capture as the * whole-page path. maxNodes, the byte ceiling, and the 500-match scan * window all apply to the SUBTREE, and the iframe truncation marker only * reflects iframes inside it — so a subtree that fits reports * truncated:false, making absence provable inside a container even when * the whole page is unbounded. The result's scope field echoes the root * the driver observed and carries a fresh rootRef (v9) that binds it even * when the visibility gate excluded it from nodes. An unknown, expired, * consumed, non-element, or detached within ref refuses the call with a * distinct rejection — it never silently falls back to a whole-page view. * * v9 scoped-proof retention (additive): a dispatched action consumes the * observation that minted the ref, and after it exactly TWO bindings * survive. One is the acted element (see anchorLastAction). The other is * the scope root of the consumed observation when it was scoped: the * driver retains that root per session, so observe({ within: }) — or the literal alias within: * 'last-scope' — re-scopes the projection to the SAME root after the * action and before the next observe. The retained root is checked exactly * like a live within ref (connected element identity — TARGET_CHANGED / * WITHIN_NOT_ELEMENT); a missing or released retention (no scoped action * yet, navigation, or an observe in between) refuses with the distinct * SCOPE_UNAVAILABLE. Every OTHER ref — a plain node ref from the consumed * observation included — keeps today's refusal (OBSERVATION_REQUIRED * after an action), so ordinary ref semantics are unchanged. */ within?: string /** * v9: request an identity anchor for the element the driver last dispatched * an action on (the ORIGINAL handle used for dispatch, never a re-matched * node). The result's anchor reports, measured in-page against that handle, * whether it is still connected and whether it lies inside the within * subtree (composed containment; null without within), plus its fresh ref * in THIS observation when it was emitted. When no action target is * retained (no dispatched element action yet, or it was released by * navigation), the call REJECTS with ANCHOR_UNAVAILABLE — never a silent * null anchor. */ anchorLastAction?: true /** * v9 (Phase C): run a BOUNDED CDP coverage probe after collection, over the * observed subtree (the within root's subtree, or the whole document for a * whole-page observe), to detect CLOSED shadow roots among ALL element * descendants — non-semantic hosts included. Closed roots are invisible * in-page (Element.shadowRoot is null for them), so the content they render * is missing from the projection while the light tree can still look * complete. The probe walks the CDP DOM tree (DOM.getDocument / * DOM.describeNode with depth -1 and pierce:true, shadow roots and embedded * frame documents included) under a hard node cap (5,000 DOM nodes) and a * hard time cap (250 ms). The result's coverage reports the outcome: * closedShadowRoots > 0 pushes the truncation reason closed-shadow-root; * a probe that did not complete (over-budget, cdp-unavailable, * root-unresolved, error) pushes shadow-coverage-unverified. Only * coverage.verified === true lets a consumer read truncated:false as * "every semantic node of the subtree is in the projection". Without this * flag the observation carries coverage {verified:false, reason:'skipped'} * and NO extra truncation reason — ordinary polls are unchanged in cost * and in truncated semantics. Use it only on the terminal absence-proof * path, never on settle polls. */ verifyCoverage?: true } /** v9 (Phase C): per-observation evidence from the bounded CDP coverage probe. */ export interface BrowserCoverageEvidence { /** * True ONLY when the probe ran to completion within its node and time * budgets AND found zero closed shadow roots in the observed subtree. Only * then may a consumer treat truncated:false as "every semantic node of the * subtree is in the projection" (modulo the observable-nodes semantics). * False whenever the probe was skipped, stopped by a budget, could not run, * or found closed roots. */ verified: boolean /** Closed shadow roots found in the observed subtree. A completed probe with none reports 0; an incomplete probe reports what it counted before stopping. */ closedShadowRoots: number /** DOM nodes the probe walked before it finished or stopped. */ probedNodes: number /** * Why the probe is NOT verified evidence, absent on the two completed * outcomes (none found -> verified:true; roots found -> verified:false with * closedShadowRoots naming the count). 'skipped': verifyCoverage was not * requested. 'over-budget': the node cap (5,000) or the time cap (250 ms) * was exceeded. 'cdp-unavailable': the CDP session could not be created. * 'root-unresolved': the within handle could not be mapped to a CDP backend * node. 'error': any other probe failure. */ reason?: 'skipped' | 'over-budget' | 'cdp-unavailable' | 'root-unresolved' | 'error' } /** Where a node's accessible name comes from (v9+): an authored label, or descendant-text aggregation. */ export type SemanticNameSource = 'label' | 'content' /** v8+: the root of a scoped observation, as the driver observed it. */ export interface BrowserObservationScope { /** The ref the caller passed as within (v8 compatibility echo). */ ref: string /** * v9: a ref minted in THIS observation for the root element. When the root * was emitted, it equals that node's ref (the root is always nodes[0] of a * scoped view); when the visibility gate excluded the root, the root is * absent from nodes but rootRef still binds it, so a follow-up * observe({ within: scope.rootRef }) keeps resolving while the root stays * hidden. After a dispatched action on this observation, the driver * retains the root itself, so observe({ within: scope.rootRef }) — or the * literal alias 'last-scope' — keeps resolving until the next observation * (see BrowserObservationOptions.within). */ rootRef: string role: string name: string tag: string /** * v9+: present, and always true, when the resolved within root is a * CONTENT-named container (see BrowserSemanticNode.nameSource) whose * aggregated accessible name changed between the observation that minted * the within ref and this scoped view. Such a name-only change is * identity-exempt information — the root is the SAME element, so the * scoped observation proceeds (never a TARGET_CHANGED refusal) — and * `name` above carries the new aggregated name. Absent when the root is * label-named, when no name change happened, and on every whole-page * observe. `act` keeps the strict check: the same ref still refuses * TARGET_CHANGED there. */ nameChanged?: true } export interface BrowserSemanticNode { /** * Opaque, short-lived handle. Never a selector or backend node id. A ref is * bound to the ORIGINAL DOM node observed: if that node is removed and an * identical twin takes its place, acting on the ref rejects (TARGET_CHANGED) * instead of silently re-resolving to the twin; Set-of-Mark boxes are * measured on the same original node. When the observation retained no live * binding at all (bindable:false), acting on the ref rejects with * TARGET_UNBINDABLE. */ ref: string /** * v9: the ref of the nearest ANCESTOR — in the composed tree: light-DOM * parents, through slot assignment to the slot's flattened parent, and * crossing a shadow root to its host — that is itself an emitted node in * the SAME observation; null when none (the first whole-page node and every * scoped root have none). Because ancestors always precede their * descendants in emission order, a node's parentRef always points at an * EARLIER node of the same observation. Refs are re-minted per observation, * so consumers must compare ancestry as a RELATIONSHIP (the parent's index * within the same view), never as raw ref strings. */ parentRef: string | null role: string /** * The accessible name, derived by ONE normalization rule shared by every * derivation path — the observation-time serializer and the live * re-derivation used to re-resolve a ref — so the same element always * yields byte-identical names and a static page can never trip the * TARGET_CHANGED name check. The rule, in order: * 1. zero-width / invisible format characters (U+200B-U+200F, U+2060 WORD * JOINER, U+00AD SOFT HYPHEN, U+00FEFF ZERO WIDTH NO-BREAK SPACE) are * stripped outright; * 2. whitespace runs — including the no-break variants (U+00A0, U+2007, * U+202F, U+FEFF and the other Unicode space separators) — collapse to * a single space; * 3. the result is trimmed; * 4. then truncated at the fixed 180-character clamp, applied AFTER * normalization at a hard UTF-16 boundary; * 5. then trimmed once more, so a truncation boundary landing on * whitespace can never leave a trailing space. * Aggregation (when the source is 'content') concatenates the element's * descendant textContent in document order with the browser's own * separators, then normalizes the concatenation by the same rule. See * nameSource below for which nodes aggregate contents. */ name: string /** * Where the accessible name came from, computed in the same single * evaluation as `name`: 'label' when it is an authored label — * aria-label, aria-labelledby, an associated