From d26e39ac5b0d2cd2e32b067d6f367fe0c9349350 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Mon, 17 Aug 2026 08:25:52 -0400 Subject: [PATCH] Add isolated Direct verification tooling --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- AGENTS.md | 2 + README.md | 27 +- bun.lock | 2 + dist/tooling/browser-verification-entry.js | 1499 +++++++++++++++++ dist/tooling/bundle-boundary.js | 119 ++ docs/architecture.md | 9 +- docs/verification.md | 31 +- kb/notes/repository-seams.md | 4 +- kb/scopes/repository--cdb4ee2aea69.md | 2 +- package.json | 22 +- scripts/package-smoke.ts | 148 +- skills/direct-setup/SKILL.md | 12 +- skills/direct-verify/SKILL.md | 11 +- src/exports.test.ts | 14 + .../browser-verification-entry.test.ts | 63 + src/tooling/browser-verification-entry.ts | 32 + src/tooling/browser-verification.test.ts | 833 +++++++++ src/tooling/browser-verification.ts | 916 ++++++++++ src/tooling/bundle-boundary.test.ts | 180 ++ src/tooling/bundle-boundary.ts | 157 ++ tsconfig.json | 6 + 23 files changed, 4063 insertions(+), 30 deletions(-) create mode 100644 dist/tooling/browser-verification-entry.js create mode 100644 dist/tooling/bundle-boundary.js create mode 100644 src/tooling/browser-verification-entry.test.ts create mode 100644 src/tooling/browser-verification-entry.ts create mode 100644 src/tooling/browser-verification.test.ts create mode 100644 src/tooling/browser-verification.ts create mode 100644 src/tooling/bundle-boundary.test.ts create mode 100644 src/tooling/bundle-boundary.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75aebd..9a2632a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: exit 1 fi - run: bun pm pack --dry-run --ignore-scripts - - run: node --input-type=module -e 'await Promise.all(["./dist/index.js","./dist/core/index.js","./dist/react.js","./dist/testing/index.js","./dist/web.js"].map((path) => import(path)))' + - run: node --input-type=module -e 'await Promise.all(["./dist/index.js","./dist/core/index.js","./dist/react.js","./dist/testing/index.js","./dist/web.js","./dist/tooling/browser-verification-entry.js","./dist/tooling/bundle-boundary.js"].map((path) => import(path)))' required: name: Required diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89ef9ce..e363de0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: exit 1 fi - run: bun pm pack --dry-run --ignore-scripts - - run: node --input-type=module -e 'await Promise.all(["./dist/index.js","./dist/core/index.js","./dist/react.js","./dist/testing/index.js","./dist/web.js"].map((path) => import(path)))' + - run: node --input-type=module -e 'await Promise.all(["./dist/index.js","./dist/core/index.js","./dist/react.js","./dist/testing/index.js","./dist/web.js","./dist/tooling/browser-verification-entry.js","./dist/tooling/bundle-boundary.js"].map((path) => import(path)))' publish: name: Publish diff --git a/AGENTS.md b/AGENTS.md index bee07c5..d882bb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - `src/testing/` – deterministic session, world-free manifest, evidence, activity, probe, and exact scripted-transport utilities. - `src/react.ts` – opt-in React bindings for a Direct store. - `src/web/` – atomic exact browser-session bridge installation plus low-level bridge and fail-closed application-fetch firewall. +- `src/tooling/` – opt-in Bun/Node browser-verification and emitted-bundle scanning mechanics kept outside browser/runtime graphs. - `docs/` – architecture, adoption, verification, and wire-format reference. - `examples/todos/` – runnable React example with separate production and Direct entries. - `examples/react-native/` – runnable Expo example with platform-resolved native production and React Native Web Direct entries. @@ -22,6 +23,7 @@ - Apply unreasonably robust programming when agent work is cheap. Prefer coherent cross-file correctness and focused deterministic evidence to a knowingly weaker design. - Deliver changes to `main` through a current-head pull request. Keep the stable `Required` CI job green, resolve every review thread, and serialize merges. Human approval stays optional while one regular maintainer would otherwise self-review. Never force-push or bypass the gate. - Keep core code product-, platform-, and framework-neutral. Put React, browser globals, and Node-only tooling behind explicit subpaths. +- Build `@hraness/direct/tooling/*` separately for Bun. Keep those host-only exports out of the default, core, React, testing, and web graphs, and prove the separation through the packed-consumer boundary gate. - Keep React Native and Expo imports in the reference example; `@hraness/direct/react` remains the platform-neutral React binding. - Keep `.js` extensions on relative TypeScript import and export specifiers; the published source type surface must compile under both Bundler and NodeNext resolution. - Treat this repository as the complete project. Files and Git prose may use only its public names, paths, commands, and examples; do not refer to or infer any non-public source, system, product, package, path, or implementation detail. diff --git a/README.md b/README.md index fe1fb14..eedfa93 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ state with predictable local stand-ins. it does not click through the browser or test the systems it replaces. ```sh -bun add --dev github:hraness/direct#v0.6.2 +bun add --dev github:hraness/direct#v0.7.0 ``` [overview](https://hraness.com/direct) @@ -31,7 +31,7 @@ Copy this prompt into Codex, Claude Code, or another coding agent: ```text Install hraness/direct and its bundled Agent Skills from -https://github.com/hraness/direct at the immutable v0.6.2 tag. Follow the +https://github.com/hraness/direct at the immutable v0.7.0 tag. Follow the repository README, add `@hraness/direct` to devDependencies only, copy or link `direct-setup` and `direct-verify` into this agent runner's configured skills directory, and verify that the production dependency graph excludes @@ -47,7 +47,7 @@ Pin the public repository to an immutable version tag: ```json { "devDependencies": { - "@hraness/direct": "github:hraness/direct#v0.6.2" + "@hraness/direct": "github:hraness/direct#v0.7.0" } } ``` @@ -132,7 +132,10 @@ Retain one catalog hash across the run. Direct does not need a driver-specific plugin: agent-browser, Playwright MCP, and other tools can read the same page contract. -Direct remains driver-neutral and provides no browser launcher. The canonical +Direct's browser runtime remains driver-neutral and never launches a process. +The opt-in host tooling can invoke a consumer-installed agent-browser CLI; the +product verifier still owns its commands, process lifetime, and evidence. The +canonical [verification workflow](./docs/verification.md#run-one-bounded-local-chromium-batch) uses one task-owned local Chromium session and process for a sequential batch of at most eight scenarios. It opens a fresh BrowserContext with `window new` @@ -180,6 +183,20 @@ A quiet probe means the declared deterministic work settled. It does not prove t | `@hraness/direct/react` | Typed context, provider, and external-store hooks for React DOM or React Native | Optional React peer | | `@hraness/direct/testing` | Sessions, manifest and probe parsers, evidence classification, activity scopes, and exact scripted transports | Development and verification | | `@hraness/direct/web` | Atomic browser installation, with low-level bridge and firewall escape hatches | Browser only | +| `@hraness/direct/tooling/browser-verification` | Protocol-bound bridge reads, bounded agent-browser commands, local server leases, and artifact writes | Bun 1.3.14 with Node APIs | +| `@hraness/direct/tooling/bundle-boundary` | Deterministic emitted-file scans and exact versioned-wire evidence | Bun 1.3.14 with Node APIs | + +The tooling subpaths are development-only. They are built separately from the +browser runtime and never enter the default, core, React, testing, or web +graphs. Tooling type checks require Bun and Node type definitions. + +`readDirectBrowserContract` binds the exact package bridge schema and Direct's +manifest and probe parsers. Use `createDirectBrowserContractReader` when a +verifier supplies another compatible protocol. `createAgentBrowser` expects +agent-browser 0.32.3 at `node_modules/.bin/agent-browser` below the supplied +`repositoryRoot` and an empty task-owned config at +`scripts/direct/agent-browser.verify.json`. The product supplies its explicit +launch arguments, allowed domains, scenario commands, and final close policy. ## Activate scenarios @@ -207,7 +224,7 @@ hybrid bridge shape. ## Repository scope -This repository contains the deterministic kernel, browser bridge, production-exclusion pattern, agent skills, a small React example, and an Expo/React Native reference app. It does not contain a browser launcher or driver, process coordinator, cleanup supervisor, browser-worker pool, or browser benchmark. Use the browser tooling that fits your product and require external evidence for browser or performance claims. +This repository contains the deterministic kernel, browser bridge, production-exclusion scanner, bounded host-verification helpers, agent skills, a small React example, and an Expo/React Native reference app. It does not bundle a browser driver or provide a process coordinator, cleanup supervisor, browser-worker pool, or browser benchmark. The optional helper invokes the consumer's local agent-browser installation; the product owns commands and evidence, and external proof remains required for browser or performance claims. ## [Direct gives browser agents deterministic app states]() diff --git a/bun.lock b/bun.lock index ca4ee68..8a8db77 100644 --- a/bun.lock +++ b/bun.lock @@ -24,9 +24,11 @@ "vite": "^8.1.5", }, "peerDependencies": { + "agent-browser": "0.32.3", "react": ">=18 <20", }, "optionalPeers": [ + "agent-browser", "react", ], }, diff --git a/dist/tooling/browser-verification-entry.js b/dist/tooling/browser-verification-entry.js new file mode 100644 index 0000000..d8a4582 --- /dev/null +++ b/dist/tooling/browser-verification-entry.js @@ -0,0 +1,1499 @@ +// @bun +// src/core/result.ts +function ok(value) { + return { ok: true, value }; +} +function err(error) { + return { ok: false, error }; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// src/core/ids.ts +var IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*$/u; +var MAX_IDENTIFIER_LENGTH = 120; +function parseIdentifier(input, kind) { + if (typeof input !== "string" || input.length === 0 || input.length > MAX_IDENTIFIER_LENGTH || !IDENTIFIER_PATTERN.test(input)) { + return err({ + code: "invalid-identifier", + kind, + value: input, + message: `${kind} identifiers must be 1-${MAX_IDENTIFIER_LENGTH} lowercase ASCII characters with separated alphanumeric segments` + }); + } + return ok(input); +} +function parseScenarioId(input) { + const parsed = parseIdentifier(input, "scenario"); + return parsed.ok ? ok(parsed.value) : parsed; +} +function parseCoverageKey(input) { + const parsed = parseIdentifier(input, "coverage"); + return parsed.ok ? ok(parsed.value) : parsed; +} + +// src/core/reason.ts +function renderUnknownReason(reason, fallback = "Unknown failure") { + try { + if (typeof reason === "object" && reason !== null || typeof reason === "function") { + const message = Reflect.get(reason, "message"); + if (typeof message === "string") + return message; + } + } catch {} + try { + return String(reason); + } catch { + return fallback; + } +} + +// src/core/json.ts +var DEFAULT_JSON_LIMITS = Object.freeze({ + maxDepth: 64, + maxNodes: 1e5, + maxStringBytes: 1048576 +}); +var PARSED_JSON_OPTIONS = Object.freeze({ + freeze: false, + normalizeNegativeZero: false, + objectPrototype: "null", + sortObjectKeys: false +}); +var CLONED_JSON_OPTIONS = Object.freeze({ + freeze: false, + normalizeNegativeZero: true, + objectPrototype: "ordinary", + sortObjectKeys: true +}); +var FROZEN_CLONED_JSON_OPTIONS = Object.freeze({ + freeze: true, + normalizeNegativeZero: true, + objectPrototype: "ordinary", + sortObjectKeys: true +}); +function jsonError(code, path, message) { + return { code, path, message }; +} +function utf8ByteLength(value) { + let bytes = 0; + for (let index = 0;index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 127) { + bytes += 1; + } else if (code <= 2047) { + bytes += 2; + } else if (code >= 55296 && code <= 56319 && index + 1 < value.length) { + const next = value.charCodeAt(index + 1); + if (next >= 56320 && next <= 57343) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} +function parseJsonAt(input, path, depth, limits, budget, ancestors, options) { + budget.nodes += 1; + if (budget.nodes > limits.maxNodes) { + return err(jsonError("node-limit-exceeded", path, `JSON value exceeds ${limits.maxNodes} nodes`)); + } + if (depth > limits.maxDepth) { + return err(jsonError("depth-exceeded", path, `JSON value exceeds depth ${limits.maxDepth}`)); + } + if (input === null || typeof input === "boolean") { + return ok(input); + } + if (typeof input === "string") { + budget.stringBytes += utf8ByteLength(input); + if (budget.stringBytes > limits.maxStringBytes) { + return err(jsonError("string-limit-exceeded", path, `JSON strings exceed ${limits.maxStringBytes} UTF-8 bytes`)); + } + return ok(input); + } + if (typeof input === "number") { + return Number.isFinite(input) ? ok(options.normalizeNegativeZero && Object.is(input, -0) ? 0 : input) : err(jsonError("invalid-number", path, "JSON numbers must be finite")); + } + if (typeof input !== "object") { + return err(jsonError("invalid-type", path, `${typeof input} is not a JSON value`)); + } + if (ancestors.has(input)) { + return err(jsonError("cycle", path, "JSON values cannot contain cycles")); + } + const nextAncestors = new Set(ancestors); + nextAncestors.add(input); + if (Array.isArray(input)) { + if (Object.getPrototypeOf(input) !== Array.prototype) { + return err(jsonError("invalid-object", path, "JSON arrays must have the standard Array prototype")); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, "length"); + if (lengthDescriptor === undefined || lengthDescriptor.get !== undefined || lengthDescriptor.set !== undefined || !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0) { + return err(jsonError("invalid-object", path, "JSON arrays must have a valid data length")); + } + const length = lengthDescriptor.value; + for (const key of Reflect.ownKeys(input)) { + if (typeof key === "symbol") { + return err(jsonError("symbol-key", path, "JSON arrays cannot have symbol keys")); + } + if (key === "length") + continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + return err(jsonError("invalid-object", `${path}.${key}`, "JSON arrays cannot have extra properties")); + } + } + const output2 = []; + for (let index = 0;index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, index); + if (descriptor === undefined) { + return err(jsonError("invalid-object", `${path}[${index}]`, "Sparse arrays are not exact JSON values")); + } + if (descriptor.get !== undefined || descriptor.set !== undefined) { + return err(jsonError("accessor-property", `${path}[${index}]`, "JSON arrays must use data elements")); + } + if (!descriptor.enumerable) { + return err(jsonError("invalid-object", `${path}[${index}]`, "JSON array elements must be enumerable")); + } + const item = parseJsonAt(descriptor.value, `${path}[${index}]`, depth + 1, limits, budget, nextAncestors, options); + if (!item.ok) { + return item; + } + output2.push(item.value); + } + return ok(options.freeze ? Object.freeze(output2) : output2); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + return err(jsonError("invalid-object", path, "JSON objects must have Object or null prototypes")); + } + const output = options.objectPrototype === "ordinary" ? {} : Object.create(null); + const entries = options.sortObjectKeys ? [] : null; + for (const key of Reflect.ownKeys(input)) { + if (typeof key === "symbol") { + return err(jsonError("symbol-key", path, "JSON objects cannot have symbol keys")); + } + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (descriptor === undefined || descriptor.get !== undefined || descriptor.set !== undefined) { + return err(jsonError("accessor-property", `${path}.${key}`, "JSON objects must use data properties")); + } + if (!descriptor.enumerable) { + return err(jsonError("invalid-object", `${path}.${key}`, "JSON object properties must be enumerable")); + } + budget.stringBytes += utf8ByteLength(key); + if (budget.stringBytes > limits.maxStringBytes) { + return err(jsonError("string-limit-exceeded", `${path}.${key}`, `JSON strings exceed ${limits.maxStringBytes} UTF-8 bytes`)); + } + const child = parseJsonAt(descriptor.value, `${path}.${key}`, depth + 1, limits, budget, nextAncestors, options); + if (!child.ok) { + return child; + } + if (entries === null) { + output[key] = child.value; + } else { + entries.push([key, child.value]); + } + } + if (entries !== null) { + entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + for (const [key, value] of entries) { + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value, + writable: true + }); + } + } + return ok(options.freeze ? Object.freeze(output) : output); +} +function validateAndCloneJson(input, limits, options) { + if (!Number.isSafeInteger(limits.maxDepth) || limits.maxDepth < 0 || !Number.isSafeInteger(limits.maxNodes) || limits.maxNodes < 1 || !Number.isSafeInteger(limits.maxStringBytes) || limits.maxStringBytes < 0) { + throw new Error("JSON limits must be non-negative safe integers and allow at least one node"); + } + try { + return parseJsonAt(input, "$", 0, limits, { nodes: 0, stringBytes: 0 }, new Set, options); + } catch (reason) { + return err(jsonError("invalid-object", "$", renderUnknownReason(reason, "JSON object inspection failed"))); + } +} +function parseJsonValue(input, limits = DEFAULT_JSON_LIMITS) { + return validateAndCloneJson(input, limits, PARSED_JSON_OPTIONS); +} +function canonicalize(value) { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(",")}]`; + } + const entries = Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, child]) => `${JSON.stringify(key)}:${canonicalize(child)}`); + return `{${entries.join(",")}}`; +} +function canonicalJson(input, limits = DEFAULT_JSON_LIMITS) { + const parsed = parseJsonValue(input, limits); + return parsed.ok ? ok(canonicalize(parsed.value)) : parsed; +} +function freezeJson(value) { + if (value !== null && typeof value === "object") { + for (const child of Array.isArray(value) ? value : Object.values(value)) { + freezeJson(child); + } + Object.freeze(value); + } + return value; +} +var STABLE_HASH_ALGORITHM = "fnv1a-64"; +var TAGGED_STABLE_HASH_PATTERN = /^fnv1a-64:[0-9a-f]{16}$/u; +function tagStableHash(hash) { + return `${hash.algorithm}:${hash.value}`; +} +function parseTaggedStableHash(input) { + return typeof input === "string" && TAGGED_STABLE_HASH_PATTERN.test(input) ? ok(input) : err({ + code: "invalid-stable-hash", + message: `Stable hashes must use ${STABLE_HASH_ALGORITHM} with 16 lowercase hexadecimal digits` + }); +} +function updateFnvByte(hash, byte) { + return BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n); +} +function stableHash(input, limits = DEFAULT_JSON_LIMITS) { + const serialized = canonicalJson(input, limits); + if (!serialized.ok) { + return serialized; + } + let hash = 0xcbf29ce484222325n; + for (let index = 0;index < serialized.value.length; index += 1) { + const code = serialized.value.charCodeAt(index); + if (code <= 127) { + hash = updateFnvByte(hash, code); + } else if (code <= 2047) { + hash = updateFnvByte(hash, 192 | code >> 6); + hash = updateFnvByte(hash, 128 | code & 63); + } else if (code >= 55296 && code <= 56319 && index + 1 < serialized.value.length) { + const next = serialized.value.charCodeAt(index + 1); + if (next >= 56320 && next <= 57343) { + const point = 65536 + (code - 55296 << 10) + (next - 56320); + hash = updateFnvByte(hash, 240 | point >> 18); + hash = updateFnvByte(hash, 128 | point >> 12 & 63); + hash = updateFnvByte(hash, 128 | point >> 6 & 63); + hash = updateFnvByte(hash, 128 | point & 63); + index += 1; + } else { + hash = updateFnvByte(hash, 239); + hash = updateFnvByte(hash, 191); + hash = updateFnvByte(hash, 189); + } + } else { + hash = updateFnvByte(hash, 224 | code >> 12); + hash = updateFnvByte(hash, 128 | code >> 6 & 63); + hash = updateFnvByte(hash, 128 | code & 63); + } + } + return ok({ + algorithm: STABLE_HASH_ALGORITHM, + value: hash.toString(16).padStart(16, "0") + }); +} + +// src/core/coverage.ts +var DIRECT_COVERAGE_SCHEMA = "direct.coverage/v2"; +var MAX_DIRECT_COVERAGE_ENTRIES = 256; +var DIRECT_COVERAGE_JSON_LIMITS = Object.freeze({ + ...DEFAULT_JSON_LIMITS, + maxStringBytes: 16777216 +}); +var EMPTY_COVERAGE_CATALOG_SNAPSHOT = Object.freeze({ + schema: DIRECT_COVERAGE_SCHEMA, + entries: Object.freeze([]) +}); +function coverageError(code, message, keys = []) { + return { code, message, keys }; +} +function hasControlCharacters(value) { + for (const character of value) { + const code = character.charCodeAt(0); + if (code < 32 && code !== 9 && code !== 10 && code !== 13 || code === 127) { + return true; + } + } + return false; +} +var COVERAGE_ENTRY_KEYS = new Set(["key", "mode", "claim", "scenarios"]); +var COVERAGE_SNAPSHOT_KEYS = new Set(["schema", "entries"]); +function isStringArray(value) { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} +function createCoverageCatalogSnapshot(catalog) { + return Object.freeze({ + schema: DIRECT_COVERAGE_SCHEMA, + entries: catalog.list() + }); +} +function parseCoverageCatalogSnapshot(input, limits = DIRECT_COVERAGE_JSON_LIMITS) { + const parsed = parseJsonValue(input, limits); + if (!parsed.ok || !isRecord(parsed.value)) { + return err(coverageError("invalid-coverage", parsed.ok ? "Coverage snapshot must be an object" : parsed.error.message)); + } + for (const key of Object.keys(parsed.value)) { + if (!COVERAGE_SNAPSHOT_KEYS.has(key)) { + return err(coverageError("invalid-coverage", `Unknown coverage snapshot key: ${key}`)); + } + } + if (parsed.value.schema !== DIRECT_COVERAGE_SCHEMA) { + return err(coverageError("invalid-coverage", `Coverage snapshot schema must be ${DIRECT_COVERAGE_SCHEMA}`)); + } + if (!Array.isArray(parsed.value.entries)) { + return err(coverageError("invalid-coverage", "Coverage snapshot entries must be an array")); + } + const entries = []; + for (const [index, candidate] of parsed.value.entries.entries()) { + if (!isRecord(candidate)) { + return err(coverageError("invalid-coverage", `Coverage entry ${String(index)} must be an object`)); + } + for (const key of Object.keys(candidate)) { + if (!COVERAGE_ENTRY_KEYS.has(key)) { + return err(coverageError("invalid-coverage", `Unknown coverage entry key at ${String(index)}: ${key}`)); + } + } + if (typeof candidate.key !== "string" || typeof candidate.claim !== "string" || candidate.mode !== "fixture" && candidate.mode !== "mixed" && candidate.mode !== "direct" || !isStringArray(candidate.scenarios)) { + return err(coverageError("invalid-coverage", `Coverage entry ${String(index)} has an invalid wire shape`)); + } + if (candidate.mode === "direct") { + if (candidate.scenarios.length > 0) { + return err(coverageError("invalid-mode", `Direct coverage ${candidate.key} cannot cite fixture scenarios`, [candidate.key])); + } + entries.push({ + key: candidate.key, + mode: candidate.mode, + claim: candidate.claim, + scenarios: [] + }); + } else { + const firstScenario = candidate.scenarios[0]; + if (typeof firstScenario !== "string") { + return err(coverageError("invalid-mode", `${candidate.mode} coverage ${candidate.key} must cite at least one scenario`, [candidate.key])); + } + entries.push({ + key: candidate.key, + mode: candidate.mode, + claim: candidate.claim, + scenarios: [firstScenario, ...candidate.scenarios.slice(1)] + }); + } + } + const catalog = createCoverageCatalog(entries); + return catalog.ok ? ok(createCoverageCatalogSnapshot(catalog.value)) : catalog; +} +function createCoverageCatalog(inputs, scenarios) { + if (inputs.length > MAX_DIRECT_COVERAGE_ENTRIES) { + return err(coverageError("too-many-coverage-entries", `Direct definitions support at most ${String(MAX_DIRECT_COVERAGE_ENTRIES)} coverage entries`)); + } + const entries = []; + const byKey = new Map; + for (const input of inputs) { + const key = parseCoverageKey(input.key); + if (!key.ok) { + return err(coverageError("invalid-coverage", key.error.message, [String(input.key)])); + } + if (byKey.has(key.value)) { + return err(coverageError("duplicate-coverage", `Duplicate coverage key: ${key.value}`, [key.value])); + } + if (input.claim.trim().length === 0 || input.claim.length > 1000 || hasControlCharacters(input.claim)) { + return err(coverageError("invalid-claim", `Coverage ${key.value} needs a 1-1000 character claim`, [key.value])); + } + if (input.mode !== "fixture" && input.mode !== "mixed" && input.mode !== "direct") { + return err(coverageError("invalid-mode", `Coverage ${key.value} has an unknown proof mode`, [key.value])); + } + if (input.mode === "direct" && input.scenarios.length > 0) { + return err(coverageError("invalid-mode", `Direct coverage ${key.value} cannot cite fixture scenarios`, [key.value])); + } + if (input.mode !== "direct" && input.scenarios.length === 0) { + return err(coverageError("invalid-mode", `${input.mode} coverage ${key.value} must cite at least one scenario`, [key.value])); + } + const scenarioIds = []; + const seenScenarios = new Set; + for (const candidate of input.scenarios) { + const id = parseScenarioId(candidate); + if (!id.ok) { + return err(coverageError("invalid-scenario", id.error.message, [String(candidate)])); + } + if (seenScenarios.has(id.value)) { + return err(coverageError("invalid-scenario", `Coverage ${key.value} repeats scenario ${id.value}`, [id.value])); + } + if (scenarios !== undefined && scenarios.get(id.value) === undefined) { + return err(coverageError("unknown-scenario", `Coverage ${key.value} cites unknown scenario ${id.value}`, [id.value])); + } + seenScenarios.add(id.value); + scenarioIds.push(id.value); + } + let entry; + if (input.mode === "direct") { + const scenarios2 = Object.freeze([]); + entry = Object.freeze({ + key: key.value, + mode: input.mode, + claim: input.claim, + scenarios: scenarios2 + }); + } else { + const firstScenarioId = scenarioIds[0]; + if (firstScenarioId === undefined) { + return err(coverageError("invalid-mode", `${input.mode} coverage ${key.value} must cite at least one scenario`, [key.value])); + } + const scenarios2 = Object.freeze([ + firstScenarioId, + ...scenarioIds.slice(1) + ]); + entry = Object.freeze({ + key: key.value, + mode: input.mode, + claim: input.claim, + scenarios: scenarios2 + }); + } + entries.push(entry); + byKey.set(key.value, entry); + } + const frozenEntries = Object.freeze(entries); + const keys = Object.freeze(frozenEntries.map((entry) => entry.key)); + const catalog = { + size: frozenEntries.length, + keys: () => keys, + list: () => frozenEntries, + get: (key) => byKey.get(key), + resolve: (input) => { + const key = parseCoverageKey(input); + if (!key.ok) { + return err(coverageError("invalid-coverage", key.error.message, [String(input)])); + } + const entry = byKey.get(key.value); + return entry === undefined ? err(coverageError("unknown-coverage", `Unknown coverage key: ${key.value}`, [key.value])) : ok(entry); + }, + requireExactKeys: (expected) => { + const expectedKeys = []; + const seen = new Set; + for (const candidate of expected) { + const parsed = parseCoverageKey(candidate); + if (!parsed.ok) { + return err(coverageError("invalid-coverage", parsed.error.message, [String(candidate)])); + } + if (seen.has(parsed.value)) { + return err(coverageError("duplicate-expected-key", `Expected coverage repeats ${parsed.value}`, [parsed.value])); + } + seen.add(parsed.value); + expectedKeys.push(parsed.value); + } + const missing = expectedKeys.filter((key) => !byKey.has(key)); + if (missing.length > 0) { + return err(coverageError("missing-coverage", `Missing coverage keys: ${missing.join(", ")}`, missing)); + } + const unexpected = keys.filter((key) => !seen.has(key)); + if (unexpected.length > 0) { + return err(coverageError("unexpected-coverage", `Unexpected coverage keys: ${unexpected.join(", ")}`, unexpected)); + } + return ok(true); + } + }; + return ok(Object.freeze(catalog)); +} +// src/core/fixture.ts +var DEFAULT_MAX_FIXTURE_BYTES = 65536; +var FIXTURE_KEYS = new Set(["schema", "scenario", "route", "world", "runtime"]); + +// src/core/query.ts +var SCENARIO_QUERY_KEY = "__direct_scenario"; +var FIXTURE_QUERY_KEY = "__direct_fixture"; +var FIXTURE_QUERY_PREFIX_BYTES = utf8ByteLength(`?${FIXTURE_QUERY_KEY}=`); +function maximumFixtureQueryBytes(maxFixtureBytes) { + return maxFixtureBytes * 3 + FIXTURE_QUERY_PREFIX_BYTES; +} +var DEFAULT_MAX_QUERY_BYTES = maximumFixtureQueryBytes(DEFAULT_MAX_FIXTURE_BYTES); + +// src/core/scenario.ts +var MAX_DIRECT_SCENARIOS = 256; + +// src/testing/manifest.ts +var DIRECT_SESSION_MANIFEST_SCHEMA = "direct.session-manifest/v1"; +var DIRECT_CATALOG_HASH_ALGORITHM = STABLE_HASH_ALGORITHM; +var DIRECT_SESSION_MANIFEST_JSON_LIMITS = Object.freeze({ + maxDepth: 64, + maxNodes: 1e5, + maxStringBytes: 16777216 +}); +var MANIFEST_KEYS = new Set([ + "active", + "catalogHash", + "coverage", + "defaultScenario", + "queries", + "scenarios", + "schema" +]); +var QUERY_KEYS = new Set(["fixture", "scenario"]); +var ACTIVE_KEYS = new Set([ + "activationHash", + "route", + "scenario", + "selectionHash", + "source" +]); +var SCENARIO_KEYS = new Set([ + "description", + "id", + "route", + "title" +]); +function manifestError(code, message) { + return Object.freeze({ code, message }); +} +function exactKeys(input, expected, label) { + for (const key of Object.keys(input)) { + if (!expected.has(key)) + throw new Error(`Unknown ${label} key: ${key}`); + } + for (const key of expected) { + if (!Object.hasOwn(input, key)) + throw new Error(`Missing ${label} key: ${key}`); + } +} +function hasControlCharacters2(value) { + for (const character of value) { + const code = character.charCodeAt(0); + if (code < 32 && code !== 9 && code !== 10 && code !== 13 || code === 127) { + return true; + } + } + return false; +} +function validText(value, maximum) { + return value.trim().length > 0 && value.length <= maximum && !hasControlCharacters2(value); +} +function validRoute(value) { + if (value.trim().length === 0 || value.length > 256) + return false; + for (const character of value) { + const code = character.charCodeAt(0); + if (code < 32 || code === 127) + return false; + } + return true; +} +function parseTaggedHash(value, label) { + const parsed = parseTaggedStableHash(value); + if (!parsed.ok) + throw new Error(`${label}: ${parsed.error.message}`); + return parsed.value; +} +function selectionHash(payload) { + const hashed = stableHash(payload, DIRECT_SESSION_MANIFEST_JSON_LIMITS); + if (!hashed.ok) { + return err(manifestError("invalid-manifest", hashed.error.message)); + } + return ok(tagStableHash(hashed.value)); +} +function catalogHash(payload) { + const hashed = stableHash(payload, DIRECT_SESSION_MANIFEST_JSON_LIMITS); + if (!hashed.ok) { + return err(manifestError("invalid-manifest", hashed.error.message)); + } + return ok(tagStableHash(hashed.value)); +} +function parseManifestUnchecked(input) { + const parsedJson = parseJsonValue(input, DIRECT_SESSION_MANIFEST_JSON_LIMITS); + if (!parsedJson.ok || !isRecord(parsedJson.value)) { + return err(manifestError("invalid-manifest", parsedJson.ok ? "Direct session manifest must be an object" : parsedJson.error.message)); + } + const candidate = parsedJson.value; + exactKeys(candidate, MANIFEST_KEYS, "Direct session manifest"); + if (candidate.schema !== DIRECT_SESSION_MANIFEST_SCHEMA) { + throw new Error(`Direct session manifest schema must be ${DIRECT_SESSION_MANIFEST_SCHEMA}`); + } + if (!isRecord(candidate.queries)) { + throw new Error("Direct session manifest queries must be an object"); + } + exactKeys(candidate.queries, QUERY_KEYS, "Direct session manifest queries"); + if (candidate.queries.scenario !== SCENARIO_QUERY_KEY || candidate.queries.fixture !== FIXTURE_QUERY_KEY) { + throw new Error("Direct session manifest query keys do not match Direct"); + } + const queries = Object.freeze({ + scenario: SCENARIO_QUERY_KEY, + fixture: FIXTURE_QUERY_KEY + }); + const defaultScenario = parseScenarioId(candidate.defaultScenario); + if (!defaultScenario.ok) { + throw new Error(`Invalid default scenario: ${defaultScenario.error.message}`); + } + if (!Array.isArray(candidate.scenarios)) { + throw new Error("Direct session manifest scenarios must be an array"); + } + if (candidate.scenarios.length > MAX_DIRECT_SCENARIOS) { + throw new Error(`Direct session manifests support at most ${String(MAX_DIRECT_SCENARIOS)} scenarios`); + } + const scenarios = []; + const byId = new Map; + for (const [index, rawScenario] of candidate.scenarios.entries()) { + if (!isRecord(rawScenario)) { + throw new Error(`Direct session manifest scenario ${String(index)} must be an object`); + } + exactKeys(rawScenario, SCENARIO_KEYS, `Direct session manifest scenario ${String(index)}`); + const id = parseScenarioId(rawScenario.id); + if (!id.ok) { + throw new Error(`Invalid Direct session manifest scenario ${String(index)}: ${id.error.message}`); + } + if (byId.has(id.value)) { + return err(manifestError("duplicate-scenario", `Duplicate Direct session manifest scenario: ${id.value}`)); + } + if (typeof rawScenario.title !== "string" || !validText(rawScenario.title, 160)) { + throw new Error(`Direct session manifest scenario ${id.value} title must contain 1-160 visible characters`); + } + if (rawScenario.description !== null && (typeof rawScenario.description !== "string" || !validText(rawScenario.description, 2000))) { + throw new Error(`Direct session manifest scenario ${id.value} description must be null or contain 1-2000 visible characters`); + } + if (typeof rawScenario.route !== "string" || !validRoute(rawScenario.route)) { + throw new Error(`Direct session manifest scenario ${id.value} route must contain 1-256 visible characters`); + } + const scenario = Object.freeze({ + id: id.value, + title: rawScenario.title, + description: rawScenario.description, + route: rawScenario.route + }); + scenarios.push(scenario); + byId.set(id.value, scenario); + } + const frozenScenarios = Object.freeze(scenarios); + if (!byId.has(defaultScenario.value)) { + return err(manifestError("unknown-scenario", `Direct session manifest default scenario is missing: ${defaultScenario.value}`)); + } + if (!isRecord(candidate.active)) { + throw new Error("Direct session manifest active selection must be an object"); + } + exactKeys(candidate.active, ACTIVE_KEYS, "Direct session manifest active selection"); + if (candidate.active.source !== "scenario" && candidate.active.source !== "fixture") { + throw new Error("Direct session manifest active source must be scenario or fixture"); + } + const activeScenario = parseScenarioId(candidate.active.scenario); + if (!activeScenario.ok) { + throw new Error(`Invalid active scenario: ${activeScenario.error.message}`); + } + const activeDefinition = byId.get(activeScenario.value); + if (activeDefinition === undefined) { + return err(manifestError("unknown-scenario", `Direct session manifest active scenario is missing: ${activeScenario.value}`)); + } + if (typeof candidate.active.route !== "string" || !validRoute(candidate.active.route)) { + throw new Error("Direct session manifest active route is invalid"); + } + if (candidate.active.route !== activeDefinition.route) { + return err(manifestError("route-mismatch", `Direct session manifest active route does not match scenario ${activeScenario.value}`)); + } + const activationHash = parseTaggedHash(candidate.active.activationHash, "Direct session manifest activationHash"); + let suppliedSelectionHash; + try { + suppliedSelectionHash = parseTaggedHash(candidate.active.selectionHash, "Direct session manifest selectionHash"); + } catch (reason) { + return err(manifestError("invalid-selection-hash", renderUnknownReason(reason, "Direct session manifest selectionHash is invalid"))); + } + const expectedSelectionHash = selectionHash({ + source: candidate.active.source, + scenario: activeScenario.value, + route: activeDefinition.route, + activationHash + }); + if (!expectedSelectionHash.ok) + return expectedSelectionHash; + if (suppliedSelectionHash !== expectedSelectionHash.value) { + return err(manifestError("selection-hash-mismatch", "Direct session manifest selectionHash does not match its active selection")); + } + const active = Object.freeze({ + source: candidate.active.source, + scenario: activeScenario.value, + route: activeDefinition.route, + activationHash, + selectionHash: expectedSelectionHash.value + }); + const coverage = parseCoverageCatalogSnapshot(candidate.coverage, DIRECT_SESSION_MANIFEST_JSON_LIMITS); + if (!coverage.ok) { + throw new Error(coverage.error.message); + } + for (const entry of coverage.value.entries) { + for (const scenario of entry.scenarios) { + if (!byId.has(scenario)) { + return err(manifestError("unknown-coverage-scenario", `Coverage ${entry.key} cites unknown Direct session manifest scenario ${scenario}`)); + } + } + } + let suppliedCatalogHash; + try { + const parsedHash = parseTaggedHash(candidate.catalogHash, "Direct session manifest catalogHash"); + const separator = parsedHash.indexOf(":"); + suppliedCatalogHash = `${DIRECT_CATALOG_HASH_ALGORITHM}:${parsedHash.slice(separator + 1)}`; + } catch (reason) { + return err(manifestError("invalid-catalog-hash", renderUnknownReason(reason, "Direct session manifest catalogHash is invalid"))); + } + const expectedCatalogHash = catalogHash({ + queries, + defaultScenario: defaultScenario.value, + scenarios: frozenScenarios, + coverage: coverage.value + }); + if (!expectedCatalogHash.ok) + return expectedCatalogHash; + if (suppliedCatalogHash !== expectedCatalogHash.value) { + return err(manifestError("catalog-hash-mismatch", "Direct session manifest catalogHash does not match its public catalog")); + } + return ok(Object.freeze({ + schema: DIRECT_SESSION_MANIFEST_SCHEMA, + catalogHash: expectedCatalogHash.value, + queries, + defaultScenario: defaultScenario.value, + active, + scenarios: frozenScenarios, + coverage: coverage.value + })); +} +function parseDirectSessionManifest(input) { + try { + return parseManifestUnchecked(input); + } catch (reason) { + return err(manifestError("invalid-manifest", renderUnknownReason(reason, "Direct session manifest is invalid"))); + } +} +// src/testing/probe.ts +var DIRECT_PROBE_SCHEMA = "direct.probe/v1"; +var MAX_DIRECT_PROBE_COUNTERS = 128; +var COUNTER_NAME_PATTERN = /^[a-z][A-Za-z0-9]*(?:[.-][A-Za-z0-9]+)*$/u; +var SNAPSHOT_KEYS = new Set([ + "schema", + "activationHash", + "generation", + "revision", + "activity", + "pending", + "violations", + "remainingWork", + "isQuiescent" +]); +var ACTIVITY_KEYS = new Set(["active", "started", "settled"]); +function probeError(code, message, counter = null) { + return Object.freeze({ code, message, counter }); +} +function readNonNegativeInteger(input) { + return typeof input === "number" && Number.isSafeInteger(input) && input >= 0 ? input : null; +} +function parseSnapshotCounters(input, category) { + if (!isRecord(input)) { + return err(probeError("invalid-snapshot", `Probe ${category} counters must be an object`)); + } + const output = Object.create(null); + for (const [name, candidate] of Object.entries(input)) { + if (name.length > 80 || !COUNTER_NAME_PATTERN.test(name)) { + return err(probeError("invalid-counter-name", "Counter names must be 1-80 ASCII alphanumeric characters with optional dots or hyphens", name)); + } + const value = readNonNegativeInteger(candidate); + if (value === null) { + return err(probeError("invalid-counter", `Counter ${name} must be a non-negative safe integer`, name)); + } + output[name] = value; + } + return ok(Object.freeze(output)); +} +function parseDirectProbeSnapshot(input) { + const parsed = parseJsonValue(input); + if (!parsed.ok || !isRecord(parsed.value)) { + return err(probeError("invalid-snapshot", parsed.ok ? "Direct probe snapshot must be an object" : parsed.error.message)); + } + const record = parsed.value; + for (const key of Object.keys(record)) { + if (!SNAPSHOT_KEYS.has(key)) { + return err(probeError("invalid-snapshot", `Unknown Direct probe snapshot key: ${key}`)); + } + } + if (record.schema !== DIRECT_PROBE_SCHEMA) { + return err(probeError("invalid-snapshot", `Direct probe schema must be ${DIRECT_PROBE_SCHEMA}`)); + } + const activationHash = parseTaggedStableHash(record.activationHash); + if (!activationHash.ok) { + return err(probeError("invalid-activation-hash", "Direct probe activation hash is invalid")); + } + const generation = readNonNegativeInteger(record.generation); + const revision = readNonNegativeInteger(record.revision); + if (generation === null || generation < 1 || revision === null) { + return err(probeError("invalid-snapshot", "Direct probe generation must be positive and revision must be non-negative")); + } + if (generation - 1 > revision) { + return err(probeError("invalid-snapshot", "Direct probe generation cannot exceed revision plus one")); + } + if (!isRecord(record.activity)) { + return err(probeError("invalid-snapshot", "Direct probe activity must be an object")); + } + for (const key of Object.keys(record.activity)) { + if (!ACTIVITY_KEYS.has(key)) { + return err(probeError("invalid-snapshot", `Unknown Direct activity key: ${key}`)); + } + } + const active = readNonNegativeInteger(record.activity.active); + const started = readNonNegativeInteger(record.activity.started); + const settled = readNonNegativeInteger(record.activity.settled); + if (active === null || started === null || settled === null || settled > started || active !== started - settled) { + return err(probeError("invalid-snapshot", "Direct activity counters must be non-negative and conserve started work")); + } + if (started > revision || settled > revision - started) { + return err(probeError("invalid-snapshot", "Direct activity transitions cannot exceed the store revision")); + } + const pending = parseSnapshotCounters(record.pending, "pending"); + if (!pending.ok) + return pending; + const violations = parseSnapshotCounters(record.violations, "violation"); + if (!violations.ok) + return violations; + if (Object.keys(pending.value).length + Object.keys(violations.value).length > MAX_DIRECT_PROBE_COUNTERS) { + return err(probeError("too-many-counters", `A probe supports at most ${String(MAX_DIRECT_PROBE_COUNTERS)} counters`)); + } + if (record.remainingWork === undefined) { + return err(probeError("invalid-snapshot", "Direct probe snapshot requires remainingWork")); + } + if (typeof record.isQuiescent !== "boolean") { + return err(probeError("invalid-snapshot", "Direct probe isQuiescent must be boolean")); + } + const expectedQuiescence = active === 0 && Object.values(pending.value).every((value) => value === 0); + if (record.isQuiescent !== expectedQuiescence) { + return err(probeError("invalid-snapshot", "Direct probe isQuiescent does not match its activity and pending counters")); + } + return ok(Object.freeze({ + schema: DIRECT_PROBE_SCHEMA, + activationHash: activationHash.value, + generation, + revision, + activity: Object.freeze({ active, started, settled }), + pending: pending.value, + violations: violations.value, + remainingWork: freezeJson(record.remainingWork), + isQuiescent: record.isQuiescent + })); +} +// src/web/browser-bridge.ts +var DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2"; + +// src/web.ts +var DIRECT_BROWSER_BRIDGE_SCHEMA2 = DIRECT_BROWSER_BRIDGE_SCHEMA; + +// src/tooling/browser-verification.ts +import { randomUUID } from "crypto"; +import { mkdir, rename, rm, writeFile } from "fs/promises"; +import { dirname, join } from "path"; +var DEFAULT_LOG_LIMIT = 12000; +var DEFAULT_PROBE_TIMEOUT_MS = 1500; +var DEFAULT_REUSE_PROBE_INTERVAL_MS = 250; +var DEFAULT_STOP_TIMEOUT_MS = 3000; +var MAX_RENDERED_ERROR_LENGTH = 4096; +var MAX_ERROR_CAUSE_DEPTH = 8; +function parseDirectBrowserContractEnvelope(input) { + try { + if (typeof input !== "object" || input === null || Array.isArray(input) || Object.keys(input).length !== 3 || !Object.hasOwn(input, "bridgeSchema") || !Object.hasOwn(input, "manifest") || !Object.hasOwn(input, "probe")) { + throw new Error("invalid"); + } + return { + bridgeSchema: Reflect.get(input, "bridgeSchema"), + manifest: Reflect.get(input, "manifest"), + probe: Reflect.get(input, "probe") + }; + } catch { + throw new Error("Direct browser contract has an invalid envelope"); + } +} +function directCatalogIdentity(manifest2) { + return JSON.stringify({ + queries: manifest2.queries, + defaultScenario: manifest2.defaultScenario, + scenarios: manifest2.scenarios + }); +} +function bindDirectScenarioCatalog(manifests) { + const baseline = manifests[0]; + if (baseline === undefined) { + throw new Error("Direct scenario verification requires at least one session manifest"); + } + const baselineCoverage = JSON.stringify(baseline.coverage); + const baselineCatalog = directCatalogIdentity(baseline); + for (const [index, manifest2] of manifests.entries()) { + if (manifest2.catalogHash !== baseline.catalogHash) { + throw new Error(`Direct scenario ${String(index)} exposed catalog ${manifest2.catalogHash} instead of ${baseline.catalogHash}`); + } + if (JSON.stringify(manifest2.coverage) !== baselineCoverage) { + throw new Error(`Direct scenario ${String(index)} exposed different coverage for catalog ${baseline.catalogHash}`); + } + if (directCatalogIdentity(manifest2) !== baselineCatalog) { + throw new Error(`Direct scenario ${String(index)} exposed different public metadata for catalog ${baseline.catalogHash}`); + } + } + return baseline.coverage; +} +function bindDirectBrowserContractEvidence(initial, final, retainedProbe = final.probe) { + if (directCatalogIdentity(final.manifest) !== directCatalogIdentity(initial.manifest)) { + throw new Error("Direct public catalog metadata changed during verification"); + } + if (JSON.stringify(final.manifest.coverage) !== JSON.stringify(initial.manifest.coverage)) { + throw new Error("Direct coverage changed during verification"); + } + if (final.manifest.catalogHash !== initial.manifest.catalogHash) { + throw new Error("Direct catalog hash changed during verification"); + } + if (JSON.stringify(final.manifest.active) !== JSON.stringify(initial.manifest.active)) { + throw new Error("Direct activation identity changed during verification"); + } + if (initial.probe.activationHash !== initial.manifest.active.activationHash || final.probe.activationHash !== final.manifest.active.activationHash || retainedProbe.activationHash !== final.manifest.active.activationHash) { + throw new Error("Direct probe identity changed during verification"); + } + return final; +} +function createDirectBrowserContractReader(protocol) { + return async (browser2, expectation) => { + const envelope = parseDirectBrowserContractEnvelope(await browser2.evaluate(`(() => { + const bridge = window.__direct; + return { + bridgeSchema: bridge?.schema, + manifest: bridge?.manifest, + probe: typeof bridge?.snapshot === "function" ? bridge.snapshot() : undefined, + }; + })()`)); + if (envelope.bridgeSchema !== protocol.bridgeSchema) { + throw new Error(`Direct browser bridge schema must be ${protocol.bridgeSchema}`); + } + const manifest2 = protocol.parseManifest(envelope.manifest); + if (!manifest2.ok) { + throw new Error(`Direct session manifest is invalid: ${manifest2.error.message}`); + } + const probe2 = protocol.parseProbe(envelope.probe); + if (!probe2.ok) { + throw new Error(`Direct probe is invalid: ${probe2.error.message}`); + } + if (manifest2.value.active.source !== expectation.source) { + throw new Error(`Direct activated from ${manifest2.value.active.source} instead of ${expectation.source}`); + } + if (String(manifest2.value.active.scenario) !== expectation.scenario) { + throw new Error(`Direct activated ${String(manifest2.value.active.scenario)} instead of ${expectation.scenario}`); + } + if (manifest2.value.active.route !== expectation.route) { + throw new Error(`Direct scenario ${expectation.scenario} activated route ${manifest2.value.active.route} instead of ${expectation.route}`); + } + if (manifest2.value.active.activationHash !== probe2.value.activationHash) { + throw new Error("Direct session manifest and probe identify different activations"); + } + return Object.freeze({ + manifest: manifest2.value, + probe: probe2.value + }); + }; +} +function serializeAgentBrowserLaunchArguments(launchArguments) { + for (const argument of launchArguments) { + if (!argument.startsWith("--") || argument.includes(` +`) || argument.includes(",")) { + throw new Error(`agent-browser launch arguments must be comma-free Chrome flags, received ${JSON.stringify(argument)}`); + } + } + return launchArguments.join(","); +} +function isolatedAgentBrowserEnvironment(options) { + const environment = { ...options.inheritedEnvironment }; + for (const variable of Object.keys(environment)) { + if (variable.startsWith("AGENT_BROWSER_")) + Reflect.deleteProperty(environment, variable); + } + return { + ...environment, + AGENT_BROWSER_CONFIG: options.configPath, + AGENT_BROWSER_DEFAULT_TIMEOUT: String(options.defaultTimeoutMs), + AGENT_BROWSER_IDLE_TIMEOUT_MS: String(options.idleTimeoutMs ?? options.defaultTimeoutMs + 60000), + ...options.launchArguments === undefined ? {} : { AGENT_BROWSER_ARGS: serializeAgentBrowserLaunchArguments(options.launchArguments) }, + AGENT_BROWSER_NAMESPACE: options.session, + AGENT_BROWSER_RESTORE_SAVE: "never", + AGENT_BROWSER_SESSION: options.session + }; +} +function boundedAgentBrowserSessionName(prefix, processId, nonce) { + const boundedPrefix = prefix.replaceAll(/[^a-zA-Z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 6) || "verify"; + const boundedProcessId = Math.max(0, Math.trunc(processId)).toString(36).slice(-6); + const boundedNonce = nonce.replaceAll(/[^a-zA-Z0-9]+/g, "").slice(0, 6) || "run"; + return `${boundedPrefix}-${boundedProcessId}-${boundedNonce}`; +} +function renderAgentBrowserCommand(arguments_) { + const [command, payload] = arguments_; + if (command === "eval" && payload !== undefined) { + return `${command} (${payload.length} character payload)`; + } + if (command === "batch") { + return `${command} (${arguments_.slice(1).join(` +`).length} character payload)`; + } + return arguments_.join(" "); +} +var agentBrowserCloseProcessTimeoutMs = 1e4; +function agentBrowserProcessTimeoutMs(arguments_, defaultTimeoutMs) { + const defaultProcessTimeoutMs = defaultTimeoutMs + 5000; + return arguments_[0] === "close" ? Math.min(defaultProcessTimeoutMs, agentBrowserCloseProcessTimeoutMs) : defaultProcessTimeoutMs; +} +function truncateRenderedError(value) { + if (value.length <= MAX_RENDERED_ERROR_LENGTH) + return value; + return `${value.slice(0, MAX_RENDERED_ERROR_LENGTH - 1)}\u2026`; +} +function readForeignProperty(value, key) { + try { + return { ok: true, value: Reflect.get(value, key) }; + } catch { + return { ok: false }; + } +} +function isUnknownArray(value) { + return Array.isArray(value); +} +function isNonArrayObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isNonEmptyStringArray(value) { + return isUnknownArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string"); +} +function renderUnknownAtDepth(value, seen, depth) { + if (typeof value === "string") + return truncateRenderedError(value); + if (typeof value === "object" && value !== null || typeof value === "function") { + if (seen.has(value)) + return "[Circular]"; + if (depth >= MAX_ERROR_CAUSE_DEPTH) + return "[Cause depth exceeded]"; + seen.add(value); + const message = readForeignProperty(value, "message"); + if (message.ok && typeof message.value === "string") { + const name = readForeignProperty(value, "name"); + const label = name.ok && typeof name.value === "string" && name.value.length > 0 ? name.value : "Error"; + const cause = readForeignProperty(value, "cause"); + const renderedCause = cause.ok && cause.value !== undefined ? `; caused by ${renderUnknownAtDepth(cause.value, seen, depth + 1)}` : ""; + return truncateRenderedError(`${label}: ${message.value}${renderedCause}`); + } + } + try { + const encoded = JSON.stringify(value); + if (encoded !== undefined) + return truncateRenderedError(encoded); + } catch {} + try { + return truncateRenderedError(String(value)); + } catch { + return "Unknown failure"; + } +} +function renderUnknown(value) { + return renderUnknownAtDepth(value, new WeakSet, 0); +} +function tail(value, maximumLength = DEFAULT_LOG_LIMIT) { + return value.length <= maximumLength ? value : value.slice(-maximumLength); +} +function normalizeRootHttpOrigin(input) { + let url; + try { + url = new URL(input); + } catch { + throw new Error("--base-url must be an absolute HTTP URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("--base-url must use http: or https:"); + } + if (url.username !== "" || url.password !== "") { + throw new Error("--base-url cannot contain credentials"); + } + if (url.pathname !== "/" || url.search !== "" || url.hash !== "") { + throw new Error("--base-url must point to the server root without a query string or fragment"); + } + return url.origin; +} +function parseBaseUrlArguments(arguments_, defaultBaseUrl) { + let baseUrl = defaultBaseUrl; + let receivedBaseUrl = false; + for (let index = 0;index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === undefined) + continue; + if (argument === "--help" || argument === "-h") + return { kind: "help" }; + if (argument.startsWith("--base-url=")) { + if (receivedBaseUrl) + throw new Error("--base-url may be provided only once"); + receivedBaseUrl = true; + baseUrl = argument.slice("--base-url=".length); + continue; + } + if (argument === "--base-url") { + if (receivedBaseUrl) + throw new Error("--base-url may be provided only once"); + const value = arguments_[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error("--base-url requires a value"); + } + receivedBaseUrl = true; + baseUrl = value; + index += 1; + continue; + } + throw new Error(`Unknown argument at position ${String(index + 1)}`); + } + return { kind: "run", baseUrl: normalizeRootHttpOrigin(baseUrl) }; +} +function canAutomaticallyStartLocalServer(baseUrl, localHosts = new Set(["127.0.0.1", "localhost"])) { + const url = new URL(normalizeRootHttpOrigin(baseUrl)); + return url.protocol === "http:" && localHosts.has(url.hostname); +} +function parseAgentBrowserEnvelope(source) { + let input; + try { + input = JSON.parse(source); + } catch { + throw new Error("agent-browser did not return one JSON document"); + } + if (typeof input !== "object" || input === null || Array.isArray(input) || typeof Reflect.get(input, "success") !== "boolean" || !Object.hasOwn(input, "data") || !Object.hasOwn(input, "error")) { + throw new Error("agent-browser returned an invalid envelope"); + } + if (!Reflect.get(input, "success")) { + throw new Error(`agent-browser reported failure: ${renderUnknown(Reflect.get(input, "error"))}`); + } + return Reflect.get(input, "data"); +} +function parseAgentBrowserBatchEnvelope(source) { + let input; + try { + input = JSON.parse(source); + } catch { + throw new Error("agent-browser batch did not return one JSON document"); + } + if (!isUnknownArray(input) || input.length === 0) { + throw new Error("agent-browser batch returned an invalid envelope"); + } + return input.map((entry, index) => { + if (!isNonArrayObject(entry) || !Object.hasOwn(entry, "command") || !Object.hasOwn(entry, "success") || !Object.hasOwn(entry, "result") || !Object.hasOwn(entry, "error")) { + throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`); + } + const command = readForeignProperty(entry, "command"); + const success = readForeignProperty(entry, "success"); + const result = readForeignProperty(entry, "result"); + const error = readForeignProperty(entry, "error"); + if (!command.ok || !isNonEmptyStringArray(command.value) || !success.ok || typeof success.value !== "boolean" || !result.ok || !error.ok) { + throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`); + } + if (!success.value) { + throw new Error(`agent-browser batch command ${String(index + 1)} (${renderAgentBrowserCommand(command.value)}) reported failure: ${renderUnknown(error.value)}`); + } + return result.value; + }); +} +function createAgentBrowser(options) { + const binary = join(options.repositoryRoot, "node_modules/.bin/agent-browser"); + const createEnvironment = () => { + const session2 = boundedAgentBrowserSessionName(options.sessionPrefix, process.pid, randomUUID()); + return isolatedAgentBrowserEnvironment({ + configPath: join(options.repositoryRoot, "scripts/direct/agent-browser.verify.json"), + defaultTimeoutMs: options.defaultTimeoutMs ?? 35000, + ...options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }, + inheritedEnvironment: process.env, + ...options.launchArguments === undefined ? {} : { launchArguments: options.launchArguments }, + session: session2 + }); + }; + let environment = createEnvironment(); + let used = false; + async function run(arguments_) { + used = true; + const defaultTimeoutMs = options.defaultTimeoutMs ?? 35000; + const commandArguments = arguments_[0] === "wait" && !arguments_.includes("--timeout") ? [...arguments_, "--timeout", String(defaultTimeoutMs)] : arguments_; + const command = Bun.spawn([process.execPath, binary, "--json", ...commandArguments], { + cwd: options.repositoryRoot, + env: environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe" + }); + let timedOut = false; + let forceKillTimer; + const commandTimeoutMs = agentBrowserProcessTimeoutMs(commandArguments, defaultTimeoutMs); + const timeoutTimer = setTimeout(() => { + timedOut = true; + command.kill(); + forceKillTimer = setTimeout(() => command.kill(9), 1000); + }, commandTimeoutMs); + let stdout; + let stderr; + let exitCode; + try { + [stdout, stderr, exitCode] = await Promise.all([ + new Response(command.stdout).text(), + new Response(command.stderr).text(), + command.exited + ]); + } finally { + clearTimeout(timeoutTimer); + if (forceKillTimer !== undefined) + clearTimeout(forceKillTimer); + } + if (timedOut) { + throw new Error(`agent-browser ${renderAgentBrowserCommand(commandArguments)} exceeded its ${commandTimeoutMs}ms process deadline`); + } + if (exitCode !== 0) { + throw new Error(`agent-browser ${renderAgentBrowserCommand(commandArguments)} exited with ${exitCode}: ${tail(stderr.trim() || stdout.trim())}`); + } + return commandArguments[0] === "batch" ? parseAgentBrowserBatchEnvelope(stdout) : parseAgentBrowserEnvelope(stdout); + } + async function evaluate(expression) { + const evaluation = await run(["eval", expression]); + if (typeof evaluation !== "object" || evaluation === null || Array.isArray(evaluation) || !Object.hasOwn(evaluation, "result")) { + throw new Error("browser evaluation returned invalid data"); + } + return Reflect.get(evaluation, "result"); + } + async function readBodyText() { + const result = await evaluate("document.body?.innerText ?? ''"); + if (typeof result !== "string") + throw new Error("body text evaluation did not return a string"); + return result; + } + async function close() { + if (!used) + return; + try { + await run(["close"]); + } catch (error) { + if (!renderUnknown(error).includes("Failed to connect: No such file or directory")) { + throw error; + } + } finally { + used = false; + } + } + async function restart() { + try { + await close(); + } catch { + used = false; + } + environment = createEnvironment(); + } + return { close, evaluate, readBodyText, restart, run }; +} +async function collectStream(stream, logLimit) { + const reader = stream.getReader(); + const decoder = new TextDecoder; + let output = ""; + for (;; ) { + const chunk = await reader.read(); + if (chunk.done) + return tail(`${output}${decoder.decode()}`, logLimit); + output = tail(`${output}${decoder.decode(chunk.value, { stream: true })}`, logLimit); + } +} +function spawnVerificationServer(options) { + const process_ = Bun.spawn([...options.command], { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe" + }); + const logLimit = options.logLimit ?? DEFAULT_LOG_LIMIT; + const output = Promise.all([ + collectStream(process_.stdout, logLimit), + collectStream(process_.stderr, logLimit) + ]).then(([stdout, stderr]) => tail(`${stdout} +${stderr}`.trim(), logLimit)); + return { + exited: process_.exited, + exitCode: () => process_.exitCode, + output, + terminate: () => process_.kill("SIGTERM"), + kill: () => process_.kill("SIGKILL") + }; +} +async function runVerificationCommand(options) { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error("verification command timeout must be a finite positive duration"); + } + const command = spawnVerificationServer({ + command: options.command, + cwd: options.cwd, + ...options.env === undefined ? {} : { env: options.env } + }); + let timeout; + const completed = await Promise.race([ + command.exited.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), options.timeoutMs); + }) + ]); + if (timeout !== undefined) + clearTimeout(timeout); + if (!completed) { + const output2 = tail(await stopVerificationServerWithOutput(command)); + const message = `${options.label} exceeded its ${options.timeoutMs}ms deadline`; + throw new Error(output2 === "" ? message : `${message}: +${output2}`); + } + const exitCode = command.exitCode(); + const output = tail(await stopVerificationServerWithOutput(command)); + if (exitCode !== 0) { + throw new Error(`${options.label} exited with ${String(exitCode)}: +${output}`); + } + return output; +} +async function settleWithin(promise, timeoutMs) { + return await Promise.race([ + promise.then((value) => ({ settled: true, value })), + Bun.sleep(timeoutMs).then(() => ({ settled: false })) + ]); +} +async function serverIsReachable(baseUrl, probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, readinessPath = "/") { + if (!readinessPath.startsWith("/") || readinessPath.startsWith("//")) { + throw new Error(`readinessPath must be an origin-relative path, received ${JSON.stringify(readinessPath)}`); + } + const probeUrl = new URL(readinessPath, `${normalizeRootHttpOrigin(baseUrl)}/`); + if (probeUrl.hash !== "") + throw new Error("readinessPath cannot contain a fragment"); + try { + const response = await fetch(probeUrl, { + signal: AbortSignal.timeout(probeTimeoutMs) + }); + await response.body?.cancel(); + return response.ok; + } catch { + return false; + } +} +async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS) { + if (!Number.isFinite(stopTimeoutMs) || stopTimeoutMs < 0) { + throw new Error("verification server stop timeout must be a finite nonnegative duration"); + } + if (server.exitCode() === null) + server.terminate(); + const stopped = await settleWithin(server.exited, stopTimeoutMs); + if (!stopped.settled) { + server.kill(); + const killed = await settleWithin(server.exited, stopTimeoutMs); + if (!killed.settled) { + throw new Error(`verification server did not exit within ${stopTimeoutMs}ms after SIGKILL`); + } + } + const output = await settleWithin(server.output, stopTimeoutMs); + if (!output.settled) { + throw new Error(`verification server output did not settle within ${stopTimeoutMs}ms after exit`); + } + return output.value; +} +async function stopVerificationServer(server, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS) { + await stopVerificationServerWithOutput(server, stopTimeoutMs); +} +async function acquireVerificationServer(options) { + const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const readinessPath = options.readinessPath ?? "/"; + const isReachable = options.isReachable ?? serverIsReachable; + const canStartLocally = canAutomaticallyStartLocalServer(options.baseUrl, options.localHosts); + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + if (canStartLocally && options.reuseExistingLocalServer === false) { + throw new Error(`A local server is already reachable at ${options.baseUrl}; ` + "verification will not reuse a server whose worktree ownership is unknown"); + } + await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS); + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + return { source: "reused" }; + } + } + if (!canStartLocally) { + throw new Error(`No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`); + } + const server = options.startServer(); + let exitedWithCode = null; + try { + const deadline = Date.now() + options.startupTimeoutMs; + while (Date.now() < deadline) { + const exitCode = server.exitCode(); + if (exitCode !== null) { + exitedWithCode = exitCode; + break; + } + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + return { source: "started", server }; + } + await Bun.sleep(options.pollIntervalMs ?? 200); + } + } catch (error) { + await stopVerificationServer(server); + throw error; + } + if (exitedWithCode !== null) { + const output2 = tail(await stopVerificationServerWithOutput(server)); + throw new Error(`${options.label} exited with ${exitedWithCode}: +${output2}`); + } + const timeoutMessage = `${options.label} did not become reachable at ${new URL(readinessPath, `${options.baseUrl}/`).href} within ${options.startupTimeoutMs}ms`; + const output = tail(await stopVerificationServerWithOutput(server)); + throw new Error(output === "" ? timeoutMessage : `${timeoutMessage}: +${output}`); +} +async function createArtifactRun(options) { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const processId = options.processId ?? process.pid; + const runId = `${generatedAt.replaceAll(/[^0-9A-Za-z]/gu, "-")}-${processId}`; + const runDirectory = join(options.artifactRoot, runId); + await mkdir(runDirectory, { recursive: true }); + return { + artifactRoot: options.artifactRoot, + generatedAt, + manifestPath: join(options.artifactRoot, "manifest.json"), + runDirectory + }; +} +async function writeJsonAtomically(path, value) { + const temporaryPath = join(dirname(path), `.${process.pid}-${randomUUID()}.tmp`); + try { + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)} +`, "utf8"); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } +} + +// src/tooling/browser-verification-entry.ts +var readDirectBrowserContract = createDirectBrowserContractReader({ + bridgeSchema: DIRECT_BROWSER_BRIDGE_SCHEMA2, + parseManifest: parseDirectSessionManifest, + parseProbe: parseDirectProbeSnapshot +}); +export { + writeJsonAtomically, + tail, + stopVerificationServer, + spawnVerificationServer, + serverIsReachable, + serializeAgentBrowserLaunchArguments, + runVerificationCommand, + renderUnknown, + renderAgentBrowserCommand, + readDirectBrowserContract, + parseBaseUrlArguments, + parseAgentBrowserEnvelope, + parseAgentBrowserBatchEnvelope, + normalizeRootHttpOrigin, + isolatedAgentBrowserEnvironment, + createDirectBrowserContractReader, + createArtifactRun, + createAgentBrowser, + canAutomaticallyStartLocalServer, + boundedAgentBrowserSessionName, + bindDirectScenarioCatalog, + bindDirectBrowserContractEvidence, + agentBrowserProcessTimeoutMs, + agentBrowserCloseProcessTimeoutMs, + acquireVerificationServer +}; diff --git a/dist/tooling/bundle-boundary.js b/dist/tooling/bundle-boundary.js new file mode 100644 index 0000000..5c0501d --- /dev/null +++ b/dist/tooling/bundle-boundary.js @@ -0,0 +1,119 @@ +// @bun +// src/tooling/bundle-boundary.ts +import path from "path"; +var DIRECT_WIRE_MARKERS = Object.freeze([ + "direct.browser-bridge/", + "direct.coverage/", + "direct.fixture/", + "direct.probe/", + "direct.runtime/", + "direct.session-manifest/" +]); +function validatedMarkers(markers) { + const seen = new Set; + const output = []; + for (const marker of markers) { + if (marker.length === 0) + throw new Error("Bundle-boundary markers cannot be empty."); + if (seen.has(marker)) + throw new Error(`Bundle-boundary marker is duplicated: ${marker}`); + seen.add(marker); + output.push(marker); + } + if (output.length === 0) + throw new Error("A bundle boundary needs at least one forbidden marker."); + return Object.freeze(output); +} +function validatedPatterns(patterns) { + if (patterns.length === 0) + throw new Error("A bundle boundary needs at least one file pattern."); + return Object.freeze(patterns.map((pattern) => { + if (pattern.length === 0) + throw new Error("Bundle-boundary file patterns cannot be empty."); + return pattern; + })); +} +function validatedExcludePatterns(patterns) { + return Object.freeze((patterns ?? []).map((pattern) => { + if (pattern.length === 0) + throw new Error("Bundle-boundary exclusion patterns cannot be empty."); + return pattern; + })); +} +function versionedMarkerFamilies(expectedMarkers) { + if (expectedMarkers.length === 0) { + throw new Error("An exact versioned-marker policy needs at least one expected marker."); + } + const seen = new Set; + return Object.freeze(expectedMarkers.map((expected) => { + const match = /^(?[A-Za-z0-9][A-Za-z0-9._/-]*\/v)(?0|[1-9][0-9]*)$/u.exec(expected); + const family = match?.groups?.["family"]; + if (family === undefined) { + throw new Error(`Exact versioned marker must end in a canonical numeric version: ${expected}`); + } + if (seen.has(family)) { + throw new Error(`Exact versioned-marker family is duplicated: ${family}`); + } + seen.add(family); + return Object.freeze({ expected, family }); + })); +} +function escapedRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} +function inspectExactVersionedMarkers(byteSequences, expectedMarkers) { + const families = versionedMarkerFamilies(expectedMarkers); + const observed = new Set; + const contents = [...byteSequences].map((bytes) => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1")); + for (const { family } of families) { + const pattern = new RegExp(`(? observed.has(marker)); + const unexpected = [...observed].filter((marker) => !expected.has(marker)).sort((left, right) => left.localeCompare(right)); + return Object.freeze({ + missing: Object.freeze(expectedMarkers.filter((marker) => !observed.has(marker))), + observed: Object.freeze([...matching, ...unexpected]), + unexpected: Object.freeze(unexpected) + }); +} +function findForbiddenMarkers(bytes, markers) { + const contents = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return validatedMarkers(markers).filter((marker) => contents.includes(Buffer.from(marker))); +} +async function checkBundleBoundary(options) { + const root = path.resolve(options.directory); + const markers = validatedMarkers(options.markers); + const patterns = validatedPatterns(options.patterns); + const excludePatterns = validatedExcludePatterns(options.excludePatterns).map((pattern) => new Bun.Glob(pattern)); + const scanned = new Set; + const violations = []; + for (const pattern of patterns) { + const glob = new Bun.Glob(pattern); + for await (const relative of glob.scan({ cwd: root, dot: true, onlyFiles: true })) { + if (excludePatterns.some((excludePattern) => excludePattern.match(relative))) + continue; + const file = path.join(root, relative); + if (scanned.has(file)) + continue; + scanned.add(file); + const found = findForbiddenMarkers(new Uint8Array(await Bun.file(file).arrayBuffer()), markers); + if (found.length > 0) + violations.push({ file, markers: found }); + } + } + return { + scanned: Object.freeze([...scanned].sort()), + violations: Object.freeze(violations.toSorted((left, right) => left.file.localeCompare(right.file))) + }; +} +export { + inspectExactVersionedMarkers, + findForbiddenMarkers, + checkBundleBoundary, + DIRECT_WIRE_MARKERS +}; diff --git a/docs/architecture.md b/docs/architecture.md index 517b838..4e71a82 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,9 +39,10 @@ coverage snapshot. agent-browser, Playwright MCP, and other browser tools can read the same exact value through page evaluation. Direct does not own their browser sessions, selectors, navigation, screenshots, or action histories. -Direct remains driver-neutral and has no browser launcher. The product -verifier owns the driver, process lifetime, context policy, navigation, and -evidence capture. +Direct's browser runtime remains driver-neutral and never launches a process. +The opt-in Bun/Node verification tooling can invoke the consumer's local +agent-browser installation, while the product verifier owns the commands, +process lifetime, context policy, navigation, and evidence capture. The canonical local policy uses one task-owned agent-browser session and one Chromium process for a sequential batch of at most eight scenarios. The @@ -115,7 +116,7 @@ A clean marker scan is narrow evidence: the scanned files did not contain the co ## Keep optional surfaces isolated -The default `@hraness/direct` export is the curated definition and activation path. Advanced JSON, fixture, catalog, store, runtime, effect, and resource mechanics live under `@hraness/direct/core`. React bindings live under `@hraness/direct/react`, sessions and scripted test utilities under `@hraness/direct/testing`, and browser installation under `@hraness/direct/web`. None of the default, core, or testing surfaces imports React or browser globals. The package runtime does not import React Native or Expo; the React Native example composes these surfaces from a platform-resolved web entry. +The default `@hraness/direct` export is the curated definition and activation path. Advanced JSON, fixture, catalog, store, runtime, effect, and resource mechanics live under `@hraness/direct/core`. React bindings live under `@hraness/direct/react`, sessions and scripted test utilities under `@hraness/direct/testing`, and browser installation under `@hraness/direct/web`. Host-only verification and emitted-bundle scanning live under explicit `@hraness/direct/tooling/*` subpaths built for Bun with Node APIs. None of the default, core, testing, or web surfaces imports those host tools. The package runtime does not import React Native or Expo; the React Native example composes these surfaces from a platform-resolved web entry. `installDirectBrowser` enables the fetch firewall by default. It intercepts application calls to `fetch` in its JavaScript realm and denies a request unless the product's allow predicate accepts its parsed URL. It does not intercept WebSockets, EventSource, navigation, asset loading, native calls, or traffic in another realm. Use it only in a Direct browser entry. Pass `firewall: false` only when another checked boundary owns network containment. diff --git a/docs/verification.md b/docs/verification.md index eb0021f..e917137 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -17,9 +17,10 @@ proof of a substituted live system. ## Run one bounded local Chromium batch -Direct is driver-neutral. It provides deterministic state and a browser bridge, -not a browser launcher, driver, process coordinator, or cleanup supervisor. The -product verifier owns browser commands and process policy. +Direct's browser runtime is driver-neutral and never launches a process. The +optional Bun/Node host tooling can invoke a consumer-installed agent-browser +CLI, but it is not a bundled driver, process coordinator, or cleanup +supervisor. The product verifier owns browser commands and process policy. The canonical local policy uses one task-owned agent-browser session and one Chromium process for a sequential batch of at most eight scenarios. Before each @@ -48,6 +49,24 @@ both the agent-browser daemon and Chromium roots, or one containing job. agent-browser 0.32.3 may place those roots in different process groups, so daemon exit alone is not cleanup proof. +### Reuse the host mechanics + +Import `createAgentBrowser`, `acquireVerificationServer`, +`createArtifactRun`, and `readDirectBrowserContract` from +`@hraness/direct/tooling/browser-verification`. The ready-bound reader uses +Direct's exact bridge schema, session-manifest parser, and probe parser. Use +`createDirectBrowserContractReader` to inject a different compatible protocol. + +Run this subpath with Bun 1.3.14 and Node type definitions. It uses Node +filesystem, path, and crypto APIs plus Bun process, sleep, and file APIs. +`createAgentBrowser` resolves the consumer's agent-browser 0.32.3 executable at +`node_modules/.bin/agent-browser` and its task-owned configuration at +`scripts/direct/agent-browser.verify.json` below `repositoryRoot`. The helper +sanitizes inherited `AGENT_BROWSER_*` variables, bounds command and close +deadlines, and rotates a namespace after an unresponsive process. The product +still supplies allowed-domain launch flags, commands, semantic assertions, +context inventory, and the final close decision. + ### Isolate and run the session This command path uses an empty task-owned config, a fresh socket directory, a @@ -227,6 +246,12 @@ Never report a fixture scenario as proof of the adapter, service, host, browser Build the production entry independently, then scan emitted JavaScript, source maps, HTML, CSS, native bundles, executables, or packaged assets as appropriate. Each product owns marker policy. Include the package name, wire schemas, reserved query keys, fixture identifiers, bridge globals, and product workbench markers. When a bundler removes import specifiers, inspect source-map module paths as structural evidence too. +Use `checkBundleBoundary`, `DIRECT_WIRE_MARKERS`, and +`inspectExactVersionedMarkers` from +`@hraness/direct/tooling/bundle-boundary` for the shared deterministic scan. +The product still owns the directory, included file patterns, exclusions, +product markers, and required positive identity evidence. + Fail when no expected executable and source-map files were scanned, and positively require stable markers for the intended production entry. An empty, metadata-only, or unrelated clean bundle is not evidence. ## Preserve bounded evidence diff --git a/kb/notes/repository-seams.md b/kb/notes/repository-seams.md index 0a70ce2..abe475d 100644 --- a/kb/notes/repository-seams.md +++ b/kb/notes/repository-seams.md @@ -19,7 +19,9 @@ repository_scopes: # Repository seams -Direct publishes a product-neutral deterministic-development harness. Its stable seam is the versioned scenario, fixture, store, effect, resource, evidence, manifest, probe, session, and browser-bridge contract. Each product still owns its semantic ports, strict JSON world, deterministic adapters, scenarios, coverage claims, and workbench. +Direct publishes a product-neutral deterministic-development harness. Its stable runtime seam is the versioned scenario, fixture, store, effect, resource, evidence, manifest, probe, session, and browser-bridge contract. Each product still owns its semantic ports, strict JSON world, deterministic adapters, scenarios, coverage claims, and workbench. + +The package also exposes two independently imported host-tooling seams. Browser verification supplies protocol-bound atomic bridge reads, bounded agent-browser process mechanics, local server leases, and artifact persistence. Bundle-boundary verification supplies deterministic emitted-file scans and exact versioned-marker evidence. These Bun/Node exports are built separately and must remain absent from the default, core, React, testing, and web graphs. Products own driver commands, semantic and visual assertions, included output patterns, product markers, and positive production identity evidence. Consumers pin a reviewed immutable release or full commit and validate upgrades on their own schedule. Do not replace that boundary with sibling paths, Git submodules, or coordinated `main` workflows. Direct remains development-only: production entries and emitted assets must not import the package, fixture worlds, scenario catalogs, workbenches, or browser bridge. diff --git a/kb/scopes/repository--cdb4ee2aea69.md b/kb/scopes/repository--cdb4ee2aea69.md index b9f85fa..7eebeb1 100644 --- a/kb/scopes/repository--cdb4ee2aea69.md +++ b/kb/scopes/repository--cdb4ee2aea69.md @@ -10,7 +10,7 @@ tags: # Repository agent context -The root `AGENTS.md` is the repository's normative control plane. Its rules apply before deeper lookup. Direct is the product-neutral `@hraness/direct` package for deterministic application states, evidence, and development workbenches. +The root `AGENTS.md` is the repository's normative control plane. Its rules apply before deeper lookup. Direct is the product-neutral `@hraness/direct` package for deterministic application states, evidence, development workbenches, and opt-in host verification mechanics. ## Authority and repository seams diff --git a/package.json b/package.json index 983aa69..6ecd3ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/direct", - "version": "0.6.2", + "version": "0.7.0", "description": "A general harness for repeatable app states.", "license": "MIT", "type": "module", @@ -48,6 +48,14 @@ "./web": { "types": "./src/web.ts", "import": "./dist/web.js" + }, + "./tooling/browser-verification": { + "types": "./src/tooling/browser-verification-entry.ts", + "import": "./dist/tooling/browser-verification-entry.js" + }, + "./tooling/bundle-boundary": { + "types": "./src/tooling/bundle-boundary.ts", + "import": "./dist/tooling/bundle-boundary.js" } }, "main": "./dist/index.js", @@ -79,6 +87,9 @@ "src/testing/probe.ts", "src/testing/scripted-transport.ts", "src/testing/session.ts", + "src/tooling/browser-verification-entry.ts", + "src/tooling/browser-verification.ts", + "src/tooling/bundle-boundary.ts", "src/web.ts", "src/web/browser.ts", "src/web/browser-bridge.ts", @@ -88,7 +99,10 @@ "LICENSE" ], "scripts": { - "build": "bun -e 'await (await import(\"node:fs/promises\")).rm(\"./dist\", { recursive: true, force: true })' && bun build ./src/index.ts ./src/core/index.ts ./src/react.ts ./src/testing/index.ts ./src/web.ts --outdir ./dist --root ./src --target browser --format esm --splitting --packages external", + "build": "bun run build:clean && bun run build:runtime && bun run build:tooling", + "build:clean": "bun -e 'await (await import(\"node:fs/promises\")).rm(\"./dist\", { recursive: true, force: true })'", + "build:runtime": "bun build ./src/index.ts ./src/core/index.ts ./src/react.ts ./src/testing/index.ts ./src/web.ts --outdir ./dist --root ./src --target browser --format esm --splitting --packages external", + "build:tooling": "bun build ./src/tooling/browser-verification-entry.ts ./src/tooling/bundle-boundary.ts --outdir ./dist/tooling --root ./src/tooling --target bun --format esm --splitting --packages external", "typecheck": "tsc --noEmit", "test": "bun test ./src", "example:build": "vite build --config examples/todos/vite.config.ts", @@ -113,9 +127,13 @@ "prepack": "bun run check" }, "peerDependencies": { + "agent-browser": "0.32.3", "react": ">=18 <20" }, "peerDependenciesMeta": { + "agent-browser": { + "optional": true + }, "react": { "optional": true } diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index 2b126dd..91b8ee5 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -3,7 +3,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const packageName = "@hraness/direct"; -const importSpecifiers = ["@hraness/direct","@hraness/direct/core","@hraness/direct/react","@hraness/direct/testing","@hraness/direct/web"]; +const runtimeImportSpecifiers = [ + "@hraness/direct", + "@hraness/direct/core", + "@hraness/direct/react", + "@hraness/direct/testing", + "@hraness/direct/web", +]; +const toolingImportSpecifiers = [ + "@hraness/direct/tooling/browser-verification", + "@hraness/direct/tooling/bundle-boundary", +]; +const importSpecifiers = [...runtimeImportSpecifiers, ...toolingImportSpecifiers]; const binNames = []; const verificationPackages = ["@eslint/js@^9.39.2","@expo/metro-runtime@~57.0.6","@types/bun@^1.3.14","@types/node@^24.10.0","@types/react@^19.2.14","@types/react-dom@^19.2.3","@vitejs/plugin-react@^6.0.3","eslint@^9.39.2","expo@~57.0.9","fast-check@^4.8.0","react@19.2.3","react-dom@19.2.3","react-native@0.86.2","react-native-web@~0.21.2","typescript@^6.0.3","typescript-eslint@^8.53.0","vite@^8.1.5"]; @@ -13,6 +24,34 @@ async function run(command: string[], cwd: string): Promise { if (exitCode !== 0) throw new Error(`Command failed (${String(exitCode)}): ${command.join(" ")}`); } +function typeImportSource(specifiers: readonly string[]): string { + return `${specifiers + .map((specifier, index) => `import * as surface${String(index)} from ${JSON.stringify(specifier)};`) + .join("\n")}\nvoid [${specifiers.map((_, index) => `surface${String(index)}`).join(", ")}];\n`; +} + +function typeScriptConfig(options: { + readonly include: string; + readonly module: "NodeNext" | "Preserve"; + readonly moduleResolution: "Bundler" | "NodeNext"; + readonly tooling: boolean; +}): string { + return `${JSON.stringify({ + compilerOptions: { + target: "ES2023", + lib: ["ES2023", "DOM", "DOM.Iterable"], + jsx: "react-jsx", + strict: true, + noEmit: true, + skipLibCheck: options.tooling, + ...(options.tooling ? { types: ["bun", "node"] } : {}), + module: options.module, + moduleResolution: options.moduleResolution, + }, + include: [options.include], + }, null, 2)}\n`; +} + const repository = process.cwd(); const work = await mkdtemp(join(tmpdir(), "hraness-package-smoke-")); try { @@ -43,11 +82,112 @@ try { "-e", `await Promise.all(${JSON.stringify(importSpecifiers)}.map((specifier) => import(specifier)))`, ], consumer); - await writeFile(join(consumer, "index.ts"), "import * as surface0 from \"@hraness/direct\";\nimport * as surface1 from \"@hraness/direct/core\";\nimport * as surface2 from \"@hraness/direct/react\";\nimport * as surface3 from \"@hraness/direct/testing\";\nimport * as surface4 from \"@hraness/direct/web\";\nvoid [surface0, surface1, surface2, surface3, surface4];\n"); - await writeFile(join(consumer, "tsconfig.bundler.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2023\",\n \"lib\": [\n \"ES2023\",\n \"DOM\",\n \"DOM.Iterable\"\n ],\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": false,\n \"module\": \"Preserve\",\n \"moduleResolution\": \"Bundler\"\n },\n \"include\": [\n \"index.ts\"\n ]\n}"); + await writeFile(join(consumer, "runtime-index.ts"), typeImportSource(runtimeImportSpecifiers)); + await writeFile(join(consumer, "tooling-index.ts"), typeImportSource(toolingImportSpecifiers)); + await writeFile(join(consumer, "tsconfig.bundler.json"), typeScriptConfig({ + include: "runtime-index.ts", + module: "Preserve", + moduleResolution: "Bundler", + tooling: false, + })); await run([process.execPath, "x", "tsc", "-p", "./tsconfig.bundler.json"], consumer); - await writeFile(join(consumer, "tsconfig.nodenext.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2023\",\n \"lib\": [\n \"ES2023\",\n \"DOM\",\n \"DOM.Iterable\"\n ],\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": false,\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\"\n },\n \"include\": [\n \"index.ts\"\n ]\n}"); + await writeFile(join(consumer, "tsconfig.nodenext.json"), typeScriptConfig({ + include: "runtime-index.ts", + module: "NodeNext", + moduleResolution: "NodeNext", + tooling: false, + })); await run([process.execPath, "x", "tsc", "-p", "./tsconfig.nodenext.json"], consumer); + await writeFile(join(consumer, "tsconfig.tooling-bundler.json"), typeScriptConfig({ + include: "tooling-index.ts", + module: "Preserve", + moduleResolution: "Bundler", + tooling: true, + })); + await run([process.execPath, "x", "tsc", "-p", "./tsconfig.tooling-bundler.json"], consumer); + await writeFile(join(consumer, "tsconfig.tooling-nodenext.json"), typeScriptConfig({ + include: "tooling-index.ts", + module: "NodeNext", + moduleResolution: "NodeNext", + tooling: true, + })); + await run([process.execPath, "x", "tsc", "-p", "./tsconfig.tooling-nodenext.json"], consumer); + await writeFile(join(consumer, "installed-tooling-smoke.ts"), ` + import { + normalizeRootHttpOrigin, + readDirectBrowserContract, + } from "@hraness/direct/tooling/browser-verification"; + import { findForbiddenMarkers } from "@hraness/direct/tooling/bundle-boundary"; + + if (normalizeRootHttpOrigin("https://example.test/") !== "https://example.test") { + throw new Error("browser verification tooling did not normalize the origin"); + } + const found = findForbiddenMarkers( + Buffer.from("prefix\\0direct.fixture/v1\\0suffix"), + ["direct.fixture/v1"], + ); + if (found.length !== 1 || found[0] !== "direct.fixture/v1") { + throw new Error("bundle-boundary tooling did not find the marker"); + } + if (typeof readDirectBrowserContract !== "function") { + throw new Error("the package-bound Direct browser reader is missing"); + } + `); + await run([process.execPath, "run", "./installed-tooling-smoke.ts"], consumer); + + await writeFile(join(consumer, "browser-runtime.ts"), ` + import { defineDirect } from "@hraness/direct"; + import { installDirectBrowser } from "@hraness/direct/web"; + Object.defineProperty(globalThis, "__directPackageRuntimeSmoke", { + value: Object.freeze({ defineDirect, installDirectBrowser }), + }); + `); + await run([ + process.execPath, + "build", + "./browser-runtime.ts", + "--outdir", + "./browser-dist", + "--target", + "browser", + "--format", + "esm", + ], consumer); + await writeFile(join(consumer, "verify-runtime-boundary.ts"), ` + import { + checkBundleBoundary, + inspectExactVersionedMarkers, + } from "@hraness/direct/tooling/bundle-boundary"; + + const result = await checkBundleBoundary({ + directory: "./browser-dist", + markers: [ + "@hraness/direct/tooling/", + "browser-verification", + "bundle-boundary", + "node:crypto", + "node:fs", + "node:path", + "Bun.Glob", + "Bun.spawn", + ], + patterns: ["**/*.js"], + }); + if (result.scanned.length === 0) throw new Error("no browser output was scanned"); + if (result.violations.length > 0) { + throw new Error(JSON.stringify(result.violations)); + } + const markerEvidence = inspectExactVersionedMarkers( + await Promise.all(result.scanned.map(async (path) => ( + new Uint8Array(await Bun.file(path).arrayBuffer()) + ))), + ["direct.browser-bridge/v2", "direct.fixture/v1"], + ); + if (markerEvidence.missing.length > 0 || markerEvidence.unexpected.length > 0) { + throw new Error(JSON.stringify(markerEvidence)); + } + `); + await run([process.execPath, "run", "./verify-runtime-boundary.ts"], consumer); } finally { await rm(work, { recursive: true, force: true }); } diff --git a/skills/direct-setup/SKILL.md b/skills/direct-setup/SKILL.md index 9fc0cf1..8077b06 100644 --- a/skills/direct-setup/SKILL.md +++ b/skills/direct-setup/SKILL.md @@ -62,9 +62,13 @@ entry lives at that route or inside one wrapper URL. Display activation failures. Never fall back from malformed explicit activation to a nearby valid scenario. -Keep browser process and session policy in the product verifier, outside -Direct and the product composition. Direct remains driver-neutral and provides -no browser launcher, driver, coordinator, or cleanup supervisor. +Keep browser process and session policy in the product verifier, outside the +product composition. Direct's browser runtime remains driver-neutral. Prefer +the optional `@hraness/direct/tooling/browser-verification` Bun/Node helpers +for atomic bridge reads, bounded agent-browser commands, server leases, and +artifacts when they fit the repository. The helpers invoke the consumer's +local agent-browser installation; they do not bundle a driver, coordinate +parallel work, supervise cleanup, or own product commands and evidence. Use one task-owned local Chromium session and process for a sequential batch of at most eight scenarios. Call `window new` before every scenario to create a @@ -112,7 +116,7 @@ Add focused tests for: - exact-script consumption and remaining work when scripts are used; and - emitted production output containing a forbidden marker. -Build production and Direct separately. Scan emitted production assets for package names, wire schemas, reserved query keys, fixture and workbench markers, and browser globals. Fail a scan that inspects no executable files. +Build production and Direct separately. Scan emitted production assets for package names, wire schemas, reserved query keys, fixture and workbench markers, and browser globals. Prefer `@hraness/direct/tooling/bundle-boundary` for the shared scan mechanics while keeping included paths, product markers, and positive production identity evidence product-owned. Fail a scan that inspects no executable files. For native bundles, emit a paired source map for each production platform. Positively require stable path suffixes for the shared screen and state, native composition, and production adapters in every map; reject the Direct package, `.web` composition, fixtures, and workbench sources. Apply the inverse positive selection to a web fixture map. An absence-only scan of an unrelated clean bundle is not proof. diff --git a/skills/direct-verify/SKILL.md b/skills/direct-verify/SKILL.md index 941ca45..683779c 100644 --- a/skills/direct-verify/SKILL.md +++ b/skills/direct-verify/SKILL.md @@ -30,9 +30,12 @@ complete manifest and every probe; bind `manifest.coverage` to the authored definition with `parseDefinitionCoverageSnapshot`. Do not accept compatibility or product-specific globals as equivalent evidence. -The manifest is driver-neutral. Direct provides deterministic state and a -browser bridge, not a browser launcher, driver, process coordinator, or cleanup -supervisor. Keep those responsibilities in the product verifier. +The manifest and browser runtime remain driver-neutral. Prefer the optional +`@hraness/direct/tooling/browser-verification` Bun/Node helpers for exact +package-bound bridge reads, bounded agent-browser commands, server leases, and +artifacts when they fit the repository. They invoke the consumer's local +agent-browser installation; they do not bundle a driver, coordinate parallel +work, supervise cleanup, or own product commands and evidence. Use one task-owned local Chromium session and process for a sequential batch of at most eight scenarios. Before each scenario, call `window new` for a fresh @@ -196,7 +199,7 @@ not crash-safe cleanup proof. ## Verify production exclusion -Build the real production graph independently. Run its emitted-boundary scanner across every declared production surface. Require at least one executable bundle and reject package names, wire schemas, reserved query keys, fixtures, workbench strings, and browser bridge globals. +Build the real production graph independently. Run its emitted-boundary scanner across every declared production surface. Prefer `@hraness/direct/tooling/bundle-boundary` for shared scan mechanics while keeping included paths, product markers, and positive production identity evidence product-owned. Require at least one executable bundle and reject package names, wire schemas, reserved query keys, fixtures, workbench strings, and browser bridge globals. When a bundler selects platform variants, require a paired source map for every executable and every production platform. Positively match the declared shared behavior, native composition, and production-adapter modules in each map; reject Direct and web-fixture paths. Verify the inverse selection for the fixture graph. A clean marker scan proves only absence of those markers in those files, and a clean unrelated bundle proves nothing. Source selection still does not prove native linkage, service behavior, runtime loading, or device behavior. diff --git a/src/exports.test.ts b/src/exports.test.ts index 4005c95..f5da052 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -4,6 +4,8 @@ import * as root from "@hraness/direct"; import * as core from "@hraness/direct/core"; import { createDirectReactBindings } from "@hraness/direct/react"; import * as testing from "@hraness/direct/testing"; +import * as browserVerification from "@hraness/direct/tooling/browser-verification"; +import * as bundleBoundary from "@hraness/direct/tooling/bundle-boundary"; import * as web from "@hraness/direct/web"; describe("public package exports", () => { @@ -52,4 +54,16 @@ describe("public package exports", () => { expect(typeof bindings.useSnapshot).toBe("function"); expect(bindings.Context).toBeDefined(); }); + + test("host tooling stays behind explicit subpaths", () => { + expect(typeof browserVerification.createAgentBrowser).toBe("function"); + expect(typeof browserVerification.createDirectBrowserContractReader).toBe("function"); + expect(typeof browserVerification.readDirectBrowserContract).toBe("function"); + expect(typeof bundleBoundary.checkBundleBoundary).toBe("function"); + expect(typeof bundleBoundary.findForbiddenMarkers).toBe("function"); + expect("createAgentBrowser" in root).toBeFalse(); + expect("checkBundleBoundary" in root).toBeFalse(); + expect("createAgentBrowser" in web).toBeFalse(); + expect("checkBundleBoundary" in testing).toBeFalse(); + }); }); diff --git a/src/tooling/browser-verification-entry.test.ts b/src/tooling/browser-verification-entry.test.ts new file mode 100644 index 0000000..45b2cf5 --- /dev/null +++ b/src/tooling/browser-verification-entry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import { defineDirect } from "@hraness/direct"; +import { createDirectSession } from "@hraness/direct/testing"; +import { DIRECT_BROWSER_BRIDGE_SCHEMA } from "@hraness/direct/web"; + +import { + readDirectBrowserContract, + type DirectSessionBrowserContract, +} from "./browser-verification-entry.js"; + +describe("Direct package browser verification binding", () => { + test("reads the exact package bridge through the ready-bound parser", async () => { + const definition = defineDirect({ + parseWorld: (input) => { + if (typeof input !== "object" || input === null || !("ready" in input)) { + throw new Error("World readiness is required"); + } + if (input.ready !== true) throw new Error("World must be ready"); + return { ready: true } as const; + }, + defaultScenario: "surface.ready", + scenarios: [{ + id: "surface.ready", + title: "Ready surface", + route: "/surface", + world: { ready: true }, + }], + coverage: [{ + key: "surface.render", + claim: "The ready surface renders.", + mode: "fixture", + scenarios: ["surface.ready"], + }], + }); + const opened = createDirectSession({ + definition, + activation: { kind: "scenario", scenario: "surface.ready" }, + create: () => ({}), + }); + if (!opened.ok) throw new Error(opened.error.message); + const probe = opened.value.probe.snapshot(); + if (!probe.ok) throw new Error(probe.error.message); + + const contract: DirectSessionBrowserContract = await readDirectBrowserContract({ + evaluate: () => Promise.resolve({ + bridgeSchema: DIRECT_BROWSER_BRIDGE_SCHEMA, + manifest: opened.value.manifest, + probe: probe.value, + }), + }, { + route: "/surface", + scenario: "surface.ready", + source: "scenario", + }); + + expect(contract.manifest.coverage.entries).toHaveLength(1); + expect(contract.probe.activationHash).toBe( + contract.manifest.active.activationHash, + ); + opened.value.dispose(); + }); +}); diff --git a/src/tooling/browser-verification-entry.ts b/src/tooling/browser-verification-entry.ts new file mode 100644 index 0000000..ad5420e --- /dev/null +++ b/src/tooling/browser-verification-entry.ts @@ -0,0 +1,32 @@ +import { + parseDirectProbeSnapshot, + parseDirectSessionManifest, + type DirectProbeSnapshot, + type DirectSessionManifest, +} from "@hraness/direct/testing"; +import { DIRECT_BROWSER_BRIDGE_SCHEMA } from "@hraness/direct/web"; + +import { + createDirectBrowserContractReader, + type DirectBrowserContract, +} from "./browser-verification.js"; + +export * from "./browser-verification.js"; + +export type DirectSessionBrowserContract = DirectBrowserContract< + DirectSessionManifest, + DirectProbeSnapshot +>; + +/** + * Reads the package's exact browser bridge without requiring consumers to + * repeat the schema and parser binding. + */ +export const readDirectBrowserContract = createDirectBrowserContractReader< + DirectSessionManifest, + DirectProbeSnapshot +>({ + bridgeSchema: DIRECT_BROWSER_BRIDGE_SCHEMA, + parseManifest: parseDirectSessionManifest, + parseProbe: parseDirectProbeSnapshot, +}); diff --git a/src/tooling/browser-verification.test.ts b/src/tooling/browser-verification.test.ts new file mode 100644 index 0000000..2e84b3d --- /dev/null +++ b/src/tooling/browser-verification.test.ts @@ -0,0 +1,833 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { afterEach, describe, expect, test } from "bun:test"; +import { defineDirect } from "@hraness/direct"; +import { + createDirectSession, + parseDirectProbeSnapshot, + parseDirectSessionManifest, +} from "@hraness/direct/testing"; +import { DIRECT_BROWSER_BRIDGE_SCHEMA } from "@hraness/direct/web"; +import { assertProperty, fc } from "../core/test-support.js"; + +import { + acquireVerificationServer, + agentBrowserCloseProcessTimeoutMs, + agentBrowserProcessTimeoutMs, + bindDirectBrowserContractEvidence, + bindDirectScenarioCatalog, + boundedAgentBrowserSessionName, + canAutomaticallyStartLocalServer, + createDirectBrowserContractReader, + createAgentBrowser, + createArtifactRun, + isolatedAgentBrowserEnvironment, + normalizeRootHttpOrigin, + parseAgentBrowserBatchEnvelope, + parseAgentBrowserEnvelope, + parseBaseUrlArguments, + renderAgentBrowserCommand, + renderUnknown, + runVerificationCommand, + serializeAgentBrowserLaunchArguments, + serverIsReachable, + stopVerificationServer, + tail, + writeJsonAtomically, + type ManagedVerificationServer, +} from "./browser-verification.js"; + +const temporaryDirectories: string[] = []; +const readDirectBrowserContract = createDirectBrowserContractReader({ + bridgeSchema: DIRECT_BROWSER_BRIDGE_SCHEMA, + parseManifest: parseDirectSessionManifest, + parseProbe: parseDirectProbeSnapshot, +}); + +function directContractFixture(options: Readonly<{ + claim?: string; + count?: number; + source?: "fixture" | "scenario"; + title?: string; +}> = {}) { + const definition = defineDirect({ + parseWorld: (input) => { + if ( + typeof input !== "object" + || input === null + || Array.isArray(input) + || !("count" in input) + || typeof input.count !== "number" + ) { + throw new Error("World count is required"); + } + return { count: input.count }; + }, + defaultScenario: "surface.ready", + scenarios: [{ + id: "surface.ready", + title: options.title ?? "Ready surface", + route: "/surface", + world: { count: options.count ?? 1 }, + }], + coverage: [{ + key: "surface.render", + claim: options.claim ?? "The ready surface renders.", + mode: "fixture", + scenarios: ["surface.ready"], + }], + }); + const fixture = definition.serializeFixture({ + scenario: "surface.ready", + world: { count: options.count ?? 1 }, + }); + if (!fixture.ok) throw new Error(fixture.error.message); + const session = createDirectSession({ + definition, + activation: options.source === "fixture" + ? { + kind: "query", + source: `?__direct_fixture=${encodeURIComponent(fixture.value)}`, + } + : { kind: "scenario", scenario: "surface.ready" }, + create: () => ({}), + }); + if (!session.ok) throw new Error(session.error.message); + const probe = session.value.probe.snapshot(); + if (!probe.ok) throw new Error(probe.error.message); + return { + bridgeSchema: DIRECT_BROWSER_BRIDGE_SCHEMA, + manifest: session.value.manifest, + probe: probe.value, + }; +} + +async function temporaryDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), "direct-verification-")); + temporaryDirectories.push(path); + return path; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true }))); +}); + +function fakeServer(options: { readonly exitCode?: number | null } = {}): { + readonly calls: string[]; + readonly server: ManagedVerificationServer; +} { + const calls: string[] = []; + let resolveExit!: () => void; + let exitCode = options.exitCode ?? null; + const exited = new Promise((resolve) => { + resolveExit = resolve; + if (exitCode !== null) resolve(); + }); + return { + calls, + server: { + exited, + exitCode: () => exitCode, + output: Promise.resolve("server log"), + terminate: () => { + calls.push("terminate"); + exitCode = 0; + resolveExit(); + }, + kill: () => { + calls.push("kill"); + exitCode = 137; + resolveExit(); + }, + }, + }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (reason: unknown) { + return reason instanceof Error ? reason : new Error(String(reason)); + } + throw new Error("Expected the operation to reject."); +} + +describe("browser verification targets", () => { + test("normalizes only credential-free HTTP server roots", () => { + expect(normalizeRootHttpOrigin("https://example.test/")).toBe("https://example.test"); + expect(() => normalizeRootHttpOrigin("file:///tmp/site")).toThrow("http:"); + expect(() => normalizeRootHttpOrigin("https://user:secret@example.test")).toThrow("credentials"); + expect(() => normalizeRootHttpOrigin("https://example.test/nested")).toThrow("server root"); + expect(() => normalizeRootHttpOrigin("https://example.test/?run=one")).toThrow("server root"); + }); + + test("parses the shared base URL CLI without accepting ambiguous values", () => { + expect(parseBaseUrlArguments([], "http://127.0.0.1:8080")).toEqual({ + kind: "run", + baseUrl: "http://127.0.0.1:8080", + }); + expect(parseBaseUrlArguments(["--base-url=https://example.test"], "http://unused.test")).toEqual({ + kind: "run", + baseUrl: "https://example.test", + }); + expect(parseBaseUrlArguments(["-h"], "http://unused.test")).toEqual({ kind: "help" }); + expect(() => parseBaseUrlArguments(["--base-url"], "http://unused.test")).toThrow("requires a value"); + expect(() => parseBaseUrlArguments(["--unknown"], "http://unused.test")).toThrow("Unknown argument"); + expect(() => parseBaseUrlArguments([ + "--base-url=https://first.example", + "--base-url", + "https://second.example", + ], "http://unused.test")).toThrow("only once"); + }); + + test("property: rejected CLI values are never reflected into diagnostics", () => { + const secret = fc.array( + fc.constantFrom(..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), + { minLength: 1, maxLength: 80 }, + ).map((characters) => `SECRET_${characters.join("")}_TOKEN`); + + assertProperty(fc.property(secret, (generatedSecret) => { + for (const arguments_ of [ + [`--unknown=${generatedSecret}`], + [`--base-url=${generatedSecret}`], + ["--base-url", `https://${generatedSecret}@example.test`], + ["--base-url=https://first.example", `--base-url=${generatedSecret}`], + ]) { + let rejection: unknown; + try { + parseBaseUrlArguments(arguments_, "http://unused.test"); + } catch (reason) { + rejection = reason; + } + expect(rejection).toBeInstanceOf(Error); + expect(renderUnknown(rejection)).not.toContain(generatedSecret); + } + })); + }); + + test("limits automatic startup to explicitly allowed local HTTP hosts", () => { + expect(canAutomaticallyStartLocalServer("http://127.0.0.1:8080")).toBeTrue(); + expect(canAutomaticallyStartLocalServer("http://localhost:8080")).toBeTrue(); + expect(canAutomaticallyStartLocalServer("http://[::1]:8080")).toBeFalse(); + expect(canAutomaticallyStartLocalServer("http://[::1]:8080", new Set(["[::1]"]))).toBeTrue(); + expect(canAutomaticallyStartLocalServer("https://127.0.0.1:8080")).toBeFalse(); + }); +}); + +describe("agent-browser envelopes", () => { + test("bounds session names for macOS namespace socket paths", () => { + const session = boundedAgentBrowserSessionName( + "ds-forced-colors-with-an-unnecessarily-long-label", + 40_558, + "246832c3-05b0-4f37-a227-b027af03dff3", + ); + expect(session.length).toBeLessThanOrEqual(20); + expect(session).toMatch(/^ds-for-[a-z0-9]+-246832$/); + }); + + test("returns successful data and rejects malformed or failed commands", () => { + expect(parseAgentBrowserEnvelope(JSON.stringify({ success: true, data: { value: 3 }, error: null }))).toEqual({ value: 3 }); + expect(() => parseAgentBrowserEnvelope("not json")).toThrow("one JSON document"); + expect(() => parseAgentBrowserEnvelope(JSON.stringify({ success: true }))).toThrow("invalid envelope"); + expect(() => parseAgentBrowserEnvelope(JSON.stringify({ success: false, data: null, error: "closed" }))).toThrow("closed"); + }); + + test("validates every batch result and preserves command order", () => { + expect(parseAgentBrowserBatchEnvelope(JSON.stringify([ + { command: ["mouse", "move", "1", "2"], error: null, result: { moved: true }, success: true }, + { command: ["wait", "200"], error: null, result: { waited: "timeout" }, success: true }, + ]))).toEqual([{ moved: true }, { waited: "timeout" }]); + expect(() => parseAgentBrowserBatchEnvelope("not json")).toThrow("one JSON document"); + expect(() => parseAgentBrowserBatchEnvelope("[]")).toThrow("invalid envelope"); + expect(() => parseAgentBrowserBatchEnvelope(JSON.stringify([ + { command: [], error: null, result: null, success: true }, + ]))).toThrow("position 1"); + expect(() => parseAgentBrowserBatchEnvelope(JSON.stringify([ + { command: ["mouse", "move"], error: "closed", result: null, success: false }, + ]))).toThrow("mouse move"); + const largeEvaluation = "secret-program".repeat(1_000); + let batchFailure: unknown; + try { + parseAgentBrowserBatchEnvelope(JSON.stringify([ + { + command: ["eval", largeEvaluation], + error: "timed out", + result: null, + success: false, + }, + ])); + } catch (error) { + batchFailure = error; + } + expect(renderUnknown(batchFailure)).toContain("eval (14000 character payload)"); + expect(renderUnknown(batchFailure)).not.toContain(largeEvaluation); + }); + + test("keeps only the bounded log tail", () => { + expect(tail("abcdef", 4)).toBe("cdef"); + }); + + test("describes large browser programs without repeating their contents", () => { + expect(renderAgentBrowserCommand(["eval", "secret-program".repeat(1_000)])) + .toBe("eval (14000 character payload)"); + expect(renderAgentBrowserCommand(["batch", "[large batch]"])) + .toBe("batch (13 character payload)"); + expect(renderAgentBrowserCommand([ + "batch", + "--bail", + "mouse move 1 2", + "wait 200", + ])).toBe("batch (30 character payload)"); + expect(renderAgentBrowserCommand(["open", "https://example.com"])) + .toBe("open https://example.com"); + }); + + test("bounds close separately and rotates after an unresponsive namespace", async () => { + expect(agentBrowserCloseProcessTimeoutMs).toBe(10_000); + expect(agentBrowserProcessTimeoutMs(["eval", "1"], 60_000)).toBe(65_000); + expect(agentBrowserProcessTimeoutMs(["close"], 60_000)).toBe(10_000); + + const repositoryRoot = await temporaryDirectory(); + const binaryDirectory = join(repositoryRoot, "node_modules/.bin"); + await mkdir(binaryDirectory, { recursive: true }); + await writeFile(join(binaryDirectory, "agent-browser"), ` + const command = process.argv[3]; + if (command === "close") { + console.error("simulated unresponsive namespace"); + process.exit(1); + } + console.log(JSON.stringify({ + data: { result: process.env.AGENT_BROWSER_NAMESPACE }, + error: null, + success: true, + })); + `); + const browser = createAgentBrowser({ + defaultTimeoutMs: 1_000, + repositoryRoot, + sessionPrefix: "test", + }); + const firstNamespace = await browser.evaluate("1"); + await browser.restart(); + const secondNamespace = await browser.evaluate("1"); + + expect(typeof firstNamespace).toBe("string"); + expect(typeof secondNamespace).toBe("string"); + expect(secondNamespace).not.toBe(firstNamespace); + }); + + test("renders hostile, cyclic, and oversized foreign failures without throwing or growing logs", () => { + const hostile = new Proxy({}, { + get: () => { throw new Error("getter rejected"); }, + ownKeys: () => { throw new Error("keys rejected"); }, + }); + expect(renderUnknown(hostile)).toBe("Unknown failure"); + + const cyclic = new Error("cycle"); + cyclic.cause = cyclic; + expect(renderUnknown(cyclic)).toBe("Error: cycle; caused by [Circular]"); + + const rendered = renderUnknown(new Error("x".repeat(20_000))); + expect(rendered.length).toBe(4_096); + expect(rendered.endsWith("…")).toBeTrue(); + }); + + test("serializes explicit Chrome launch flags without a shell boundary", () => { + expect(serializeAgentBrowserLaunchArguments([ + "--force-high-contrast", + "--disable-extensions", + ])).toBe("--force-high-contrast,--disable-extensions"); + expect(() => serializeAgentBrowserLaunchArguments(["force-high-contrast"])) + .toThrow("Chrome flags"); + expect(() => serializeAgentBrowserLaunchArguments(["--flag,also-flag"])) + .toThrow("comma-free"); + }); + + test("removes inherited browser attachment and persistence state", () => { + const environment = isolatedAgentBrowserEnvironment({ + configPath: "/repo/scripts/direct/agent-browser.verify.json", + defaultTimeoutMs: 12_345, + inheritedEnvironment: { + AGENT_BROWSER_ARGS: "--force-device-scale-factor=0.9", + AGENT_BROWSER_ALLOWED_DOMAINS: "example.com", + AGENT_BROWSER_AUTO_CONNECT: "1", + AGENT_BROWSER_CDP: "9222", + AGENT_BROWSER_CONFIG: "/tmp/ambient-agent-browser.json", + AGENT_BROWSER_ENABLE: "react-devtools", + AGENT_BROWSER_EXECUTABLE_PATH: "/tmp/browser", + AGENT_BROWSER_EXTENSIONS: "/tmp/extension", + AGENT_BROWSER_INIT_SCRIPTS: "/tmp/init.js", + AGENT_BROWSER_IDLE_TIMEOUT_MS: "86400000", + AGENT_BROWSER_IOS_DEVICE: "iPhone 15 Pro", + AGENT_BROWSER_PLUGINS: "[]", + AGENT_BROWSER_PROFILE: "/tmp/persistent-profile", + AGENT_BROWSER_PROVIDER: "browserless", + AGENT_BROWSER_UNRECOGNIZED_FUTURE_OPTION: "must-not-leak", + AGENT_BROWSER_RESTORE: "persisted-session", + AGENT_BROWSER_RESTORE_SAVE: "always", + AGENT_BROWSER_SESSION_NAME: "legacy-session", + AGENT_BROWSER_STATE: "/tmp/browser-state.json", + PRESERVED_ENVIRONMENT_VALUE: "present", + }, + session: "fresh-session", + }); + + expect(environment).toEqual({ + AGENT_BROWSER_CONFIG: "/repo/scripts/direct/agent-browser.verify.json", + AGENT_BROWSER_DEFAULT_TIMEOUT: "12345", + AGENT_BROWSER_IDLE_TIMEOUT_MS: "72345", + AGENT_BROWSER_NAMESPACE: "fresh-session", + AGENT_BROWSER_RESTORE_SAVE: "never", + AGENT_BROWSER_SESSION: "fresh-session", + PRESERVED_ENVIRONMENT_VALUE: "present", + }); + + const extendedIdleEnvironment = isolatedAgentBrowserEnvironment({ + configPath: "/repo/scripts/direct/agent-browser.verify.json", + defaultTimeoutMs: 12_345, + idleTimeoutMs: 240_000, + inheritedEnvironment: {}, + session: "cold-direct-server", + }); + expect(extendedIdleEnvironment.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe("240000"); + }); + + test("uses only explicitly supplied Chrome launch flags", () => { + const environment = isolatedAgentBrowserEnvironment({ + configPath: "/repo/scripts/direct/agent-browser.verify.json", + defaultTimeoutMs: 35_000, + inheritedEnvironment: { + AGENT_BROWSER_ARGS: "--inherited-flag", + }, + launchArguments: ["--force-high-contrast", "--disable-extensions"], + session: "forced-colors", + }); + + expect(environment.AGENT_BROWSER_ARGS).toBe( + "--force-high-contrast,--disable-extensions", + ); + }); +}); + +describe("Direct browser contract binding", () => { + test("parses one atomic sample and requires the requested active identity", async () => { + const fixture = directContractFixture(); + const browser = { + evaluate: () => Promise.resolve(fixture), + }; + + const contract = await readDirectBrowserContract(browser, { + source: "scenario", + scenario: "surface.ready", + route: "/surface", + }); + expect(String(contract.manifest.active.scenario)).toBe("surface.ready"); + expect(contract.probe.activationHash).toBe( + contract.manifest.active.activationHash, + ); + }); + + test("rejects non-exact and hostile outer envelopes before nested parsing", async () => { + const fixture = directContractFixture(); + const expectation = { + source: "scenario" as const, + scenario: "surface.ready", + route: "/surface", + }; + + expect((await rejection(readDirectBrowserContract({ + evaluate: () => Promise.resolve({ ...fixture, extra: true }), + }, expectation))).message).toContain("invalid envelope"); + + const hostile = Object.defineProperty({ ...fixture }, "bridgeSchema", { + enumerable: true, + get: () => { + throw new Error("foreign getter"); + }, + }); + expect((await rejection(readDirectBrowserContract({ + evaluate: () => Promise.resolve(hostile), + }, expectation))).message).toContain("invalid envelope"); + }); + + test("rejects bridge, scenario, route, and probe identity drift", async () => { + const fixture = directContractFixture(); + const fixtureActivation = directContractFixture({ source: "fixture" }); + const browser = { + evaluate: () => Promise.resolve(fixture), + }; + + expect((await rejection(readDirectBrowserContract(browser, { + source: "scenario", + scenario: "surface.missing", + route: "/surface", + }))).message).toContain("instead of surface.missing"); + expect((await rejection(readDirectBrowserContract(browser, { + source: "scenario", + scenario: "surface.ready", + route: "/wrong", + }))).message).toContain("instead of /wrong"); + expect((await rejection(readDirectBrowserContract({ + evaluate: () => Promise.resolve(fixtureActivation), + }, { + source: "scenario", + scenario: "surface.ready", + route: "/surface", + }))).message).toContain("from fixture instead of scenario"); + expect((await rejection(readDirectBrowserContract({ + evaluate: () => Promise.resolve({ + ...fixture, + bridgeSchema: "direct.browser-bridge/v1", + }), + }, { + source: "scenario", + scenario: "surface.ready", + route: "/surface", + }))).message).toContain(DIRECT_BROWSER_BRIDGE_SCHEMA); + expect((await rejection(readDirectBrowserContract({ + evaluate: () => Promise.resolve({ + ...fixture, + probe: { + ...fixture.probe, + activationHash: "fnv1a-64:0000000000000000", + }, + }), + }, { + source: "scenario", + scenario: "surface.ready", + route: "/surface", + }))).message).toContain("different activations"); + }); + + test("binds independently loaded scenarios to one exact catalog", () => { + const fixture = directContractFixture(); + const secondManifest = { + ...fixture.manifest, + active: { + ...fixture.manifest.active, + scenario: fixture.manifest.defaultScenario, + }, + }; + expect(bindDirectScenarioCatalog([ + fixture.manifest, + secondManifest, + ])).toBe(fixture.manifest.coverage); + + expect(() => bindDirectScenarioCatalog([])).toThrow("at least one"); + expect(() => bindDirectScenarioCatalog([ + fixture.manifest, + { + ...secondManifest, + catalogHash: "fnv1a-64:0000000000000000", + }, + ])).toThrow("exposed catalog"); + expect(() => bindDirectScenarioCatalog([ + fixture.manifest, + { + ...secondManifest, + coverage: { + ...fixture.manifest.coverage, + entries: [], + }, + }, + ])).toThrow("different coverage"); + expect(() => bindDirectScenarioCatalog([ + fixture.manifest, + { + ...secondManifest, + scenarios: secondManifest.scenarios.map((scenario) => ({ + ...scenario, + title: `${scenario.title} drifted`, + })), + }, + ])).toThrow("different public metadata"); + }); + + test("binds final evidence to the exact initial catalog and activation", () => { + const initial = directContractFixture(); + const final = { + manifest: initial.manifest, + probe: { + ...initial.probe, + revision: initial.probe.revision + 1, + }, + }; + expect(bindDirectBrowserContractEvidence(initial, final)).toBe(final); + + expect(() => bindDirectBrowserContractEvidence( + initial, + directContractFixture({ count: 2 }), + )).toThrow("activation identity changed"); + expect(() => bindDirectBrowserContractEvidence( + initial, + directContractFixture({ title: "Changed title" }), + )).toThrow("public catalog metadata changed"); + expect(() => bindDirectBrowserContractEvidence( + initial, + directContractFixture({ claim: "Changed coverage claim." }), + )).toThrow("coverage changed"); + expect(() => bindDirectBrowserContractEvidence(initial, { + manifest: initial.manifest, + probe: { + ...initial.probe, + activationHash: "fnv1a-64:0000000000000000", + }, + })).toThrow("probe identity changed"); + expect(() => bindDirectBrowserContractEvidence(initial, final, { + ...initial.probe, + activationHash: "fnv1a-64:0000000000000000", + })).toThrow("probe identity changed"); + }); +}); + +describe("server leases", () => { + test("bounds one-shot verification commands and reports their exact outcome", async () => { + expect(await runVerificationCommand({ + command: [process.execPath, "-e", "console.log('built')"], + cwd: process.cwd(), + label: "Fixture build", + timeoutMs: 1_000, + })).toBe("built"); + + expect((await rejection(runVerificationCommand({ + command: [process.execPath, "-e", "process.exit(23)"], + cwd: process.cwd(), + label: "Fixture build", + timeoutMs: 1_000, + }))).message).toContain("Fixture build exited with 23"); + + expect((await rejection(runVerificationCommand({ + command: [process.execPath, "-e", "await Bun.sleep(10_000)"], + cwd: process.cwd(), + label: "Fixture build", + timeoutMs: 1, + }))).message).toContain("Fixture build exceeded its 1ms deadline"); + }); + + test("probes an explicit lightweight route without changing the server root", async () => { + const requests: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request) => { + requests.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + return Promise.resolve(new Response("ready")); + }) as typeof fetch; + try { + expect(await serverIsReachable("http://localhost:8080", 100, "/design?ready=1")).toBeTrue(); + expect(requests).toEqual(["http://localhost:8080/design?ready=1"]); + expect((await rejection( + serverIsReachable("http://localhost:8080", 100, "//elsewhere.test"), + )).message).toContain("origin-relative path"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reuses a reachable server without starting another process", async () => { + let starts = 0; + const lease = await acquireVerificationServer({ + baseUrl: "http://127.0.0.1:8080", + label: "Fixture server", + reuseProbeIntervalMs: 0, + startupTimeoutMs: 100, + startServer: () => { + starts += 1; + return fakeServer().server; + }, + isReachable: () => true, + }); + expect(lease).toEqual({ source: "reused" }); + expect(starts).toBe(0); + }); + + test("refuses a reachable local server when its worktree ownership is unknown", async () => { + expect((await rejection(acquireVerificationServer({ + baseUrl: "http://127.0.0.1:8080", + label: "Fixture server", + reuseExistingLocalServer: false, + startupTimeoutMs: 100, + startServer: () => fakeServer().server, + isReachable: () => true, + }))).message).toContain("worktree ownership is unknown"); + }); + + test("starts a fresh local server when a reachable listener is shutting down", async () => { + const fixture = fakeServer(); + const reachability = [true, false, true]; + let starts = 0; + const lease = await acquireVerificationServer({ + baseUrl: "http://127.0.0.1:8080", + label: "Fixture server", + reuseProbeIntervalMs: 0, + startupTimeoutMs: 100, + startServer: () => { + starts += 1; + return fixture.server; + }, + isReachable: () => reachability.shift() ?? false, + }); + expect(lease.source).toBe("started"); + expect(starts).toBe(1); + expect(reachability).toEqual([]); + if (lease.source === "started") await stopVerificationServer(lease.server); + expect(fixture.calls).toEqual(["terminate"]); + }); + + test("starts a local server and returns ownership after readiness", async () => { + const fixture = fakeServer(); + let probes = 0; + const readinessPaths: string[] = []; + const lease = await acquireVerificationServer({ + baseUrl: "http://localhost:8080", + label: "Fixture server", + pollIntervalMs: 0, + readinessPath: "/design", + startupTimeoutMs: 100, + startServer: () => fixture.server, + isReachable: (_baseUrl, _probeTimeoutMs, readinessPath) => { + readinessPaths.push(readinessPath); + probes += 1; + return probes >= 2; + }, + }); + expect(lease.source).toBe("started"); + expect(fixture.calls).toEqual([]); + if (lease.source === "started") await stopVerificationServer(lease.server); + expect(fixture.calls).toEqual(["terminate"]); + expect(readinessPaths).toEqual(["/design", "/design"]); + }); + + test("refuses remote startup and reports an exited local process", async () => { + expect((await rejection(acquireVerificationServer({ + baseUrl: "https://fixtures.example", + label: "Fixture server", + startupTimeoutMs: 100, + startServer: () => fakeServer().server, + isReachable: () => false, + }))).message).toContain("local HTTP"); + + const fixture = fakeServer({ exitCode: 23 }); + expect((await rejection(acquireVerificationServer({ + baseUrl: "http://localhost:8080", + label: "Fixture server", + startupTimeoutMs: 100, + startServer: () => fixture.server, + isReachable: () => false, + }))).message).toContain("exited with 23"); + expect(fixture.calls).toEqual([]); + }); + + test("terminates an owned server when readiness times out", async () => { + const fixture = fakeServer(); + expect((await rejection(acquireVerificationServer({ + baseUrl: "http://localhost:8080", + label: "Fixture server", + startupTimeoutMs: 0, + startServer: () => fixture.server, + isReachable: () => false, + }))).message).toContain("did not become reachable"); + expect(fixture.calls).toEqual(["terminate"]); + }); + + test("bounds cleanup when a server never exits after SIGKILL", async () => { + const calls: string[] = []; + const never = new Promise(() => undefined); + const server: ManagedVerificationServer = { + exited: never, + exitCode: () => null, + output: Promise.resolve("unreachable output"), + terminate: () => calls.push("terminate"), + kill: () => calls.push("kill"), + }; + + const failure = await rejection(stopVerificationServer(server, 1)); + + expect(calls).toEqual(["terminate", "kill"]); + expect(failure.message).toBe("verification server did not exit within 1ms after SIGKILL"); + }, 1_000); + + test("bounds output draining after a server exits", async () => { + const never = new Promise(() => undefined); + const server: ManagedVerificationServer = { + exited: Promise.resolve(), + exitCode: () => 0, + output: never, + terminate: () => { + throw new Error("an exited server must not receive SIGTERM"); + }, + kill: () => { + throw new Error("an exited server must not receive SIGKILL"); + }, + }; + + const failure = await rejection(stopVerificationServer(server, 1)); + + expect(failure.message).toBe("verification server output did not settle within 1ms after exit"); + }, 1_000); + + test("reports the bounded server tail once after timeout cleanup completes", async () => { + const calls: string[] = []; + let exitCode: number | null = null; + let resolveExit!: () => void; + let resolveOutput!: (output: string) => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const output = new Promise((resolve) => { + resolveOutput = resolve; + }); + const rawOutput = `discarded-prefix${"x".repeat(13_000)}\nstdout-tail\nstderr-tail`; + const boundedOutput = tail(rawOutput); + const server: ManagedVerificationServer = { + exited, + exitCode: () => exitCode, + output, + terminate: () => { + calls.push("terminate"); + exitCode = 0; + resolveExit(); + queueMicrotask(() => { + calls.push("output"); + resolveOutput(rawOutput); + }); + }, + kill: () => { + throw new Error("graceful timeout cleanup should not require SIGKILL"); + }, + }; + + const failure = await rejection(acquireVerificationServer({ + baseUrl: "http://localhost:8080", + label: "Fixture server", + startupTimeoutMs: 0, + startServer: () => server, + isReachable: () => false, + })); + + expect(calls).toEqual(["terminate", "output"]); + expect(boundedOutput).toHaveLength(12_000); + expect(failure.message).toEndWith(`within 0ms:\n${boundedOutput}`); + expect(failure.message.match(/stderr-tail/gu)).toHaveLength(1); + expect(failure.message).not.toContain("discarded-prefix"); + }); +}); + +test("artifact runs use deterministic names and atomically replace the manifest", async () => { + const root = await mkdtemp(join(tmpdir(), "direct-verification-")); + temporaryDirectories.push(root); + const artifactRoot = join(root, "artifacts", "direct", "fixture"); + const run = await createArtifactRun({ + artifactRoot, + generatedAt: "2026-07-20T12:34:56.789Z", + processId: 42, + }); + expect(run.runDirectory).toBe(join(artifactRoot, "2026-07-20T12-34-56-789Z-42")); + await writeJsonAtomically(run.manifestPath, { version: 1 }); + await writeJsonAtomically(run.manifestPath, { version: 2 }); + expect(await readFile(run.manifestPath, "utf8")).toBe('{\n "version": 2\n}\n'); + expect((await readdir(artifactRoot)).sort()).toEqual([ + "2026-07-20T12-34-56-789Z-42", + "manifest.json", + ]); +}); diff --git a/src/tooling/browser-verification.ts b/src/tooling/browser-verification.ts new file mode 100644 index 0000000..95da4e9 --- /dev/null +++ b/src/tooling/browser-verification.ts @@ -0,0 +1,916 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const DEFAULT_LOG_LIMIT = 12_000; +const DEFAULT_PROBE_TIMEOUT_MS = 1_500; +const DEFAULT_REUSE_PROBE_INTERVAL_MS = 250; +const DEFAULT_STOP_TIMEOUT_MS = 3_000; +const MAX_RENDERED_ERROR_LENGTH = 4_096; +const MAX_ERROR_CAUSE_DEPTH = 8; + +export type BrowserVerificationArguments = + | { readonly kind: "help" } + | { readonly kind: "run"; readonly baseUrl: string }; + +export interface AgentBrowser { + readonly close: () => Promise; + readonly evaluate: (expression: string) => Promise; + readonly readBodyText: () => Promise; + readonly restart: () => Promise; + readonly run: (arguments_: readonly string[]) => Promise; +} + +export interface DirectBrowserManifest { + readonly active: Readonly<{ + readonly activationHash: string; + readonly route: string; + readonly scenario: string; + readonly source: "scenario" | "fixture"; + }>; + readonly catalogHash: string; + readonly coverage: unknown; + readonly defaultScenario: unknown; + readonly queries: unknown; + readonly scenarios: unknown; +} + +export interface DirectBrowserProbe { + readonly activationHash: string; +} + +export interface DirectBrowserContract< + Manifest extends DirectBrowserManifest = DirectBrowserManifest, + Probe extends DirectBrowserProbe = DirectBrowserProbe, +> { + readonly manifest: Manifest; + readonly probe: Probe; +} + +export interface DirectBrowserContractExpectation { + readonly source: "scenario" | "fixture"; + readonly scenario: string; + readonly route: string; +} + +interface DirectBrowserContractEnvelope { + readonly bridgeSchema: unknown; + readonly manifest: unknown; + readonly probe: unknown; +} + +type DirectBrowserParserResult = + | Readonly<{ readonly ok: true; readonly value: Value }> + | Readonly<{ + readonly error: Readonly<{ readonly message: string }>; + readonly ok: false; + }>; + +export interface DirectBrowserProtocol< + Manifest extends DirectBrowserManifest, + Probe extends DirectBrowserProbe, +> { + readonly bridgeSchema: string; + readonly parseManifest: (input: unknown) => DirectBrowserParserResult; + readonly parseProbe: (input: unknown) => DirectBrowserParserResult; +} + +function parseDirectBrowserContractEnvelope( + input: unknown, +): DirectBrowserContractEnvelope { + try { + if ( + typeof input !== "object" + || input === null + || Array.isArray(input) + || Object.keys(input).length !== 3 + || !Object.hasOwn(input, "bridgeSchema") + || !Object.hasOwn(input, "manifest") + || !Object.hasOwn(input, "probe") + ) { + throw new Error("invalid"); + } + return { + bridgeSchema: Reflect.get(input, "bridgeSchema"), + manifest: Reflect.get(input, "manifest"), + probe: Reflect.get(input, "probe"), + }; + } catch { + throw new Error("Direct browser contract has an invalid envelope"); + } +} + +function directCatalogIdentity(manifest: DirectBrowserManifest): string { + return JSON.stringify({ + queries: manifest.queries, + defaultScenario: manifest.defaultScenario, + scenarios: manifest.scenarios, + }); +} + +/** + * Prove that independently loaded scenario pages expose one identical Direct + * catalog, then return the single coverage snapshot bound to that catalog. + */ +export function bindDirectScenarioCatalog( + manifests: readonly Manifest[], +): Manifest["coverage"] { + const baseline = manifests[0]; + if (baseline === undefined) { + throw new Error("Direct scenario verification requires at least one session manifest"); + } + const baselineCoverage = JSON.stringify(baseline.coverage); + const baselineCatalog = directCatalogIdentity(baseline); + for (const [index, manifest] of manifests.entries()) { + if (manifest.catalogHash !== baseline.catalogHash) { + throw new Error( + `Direct scenario ${String(index)} exposed catalog ${manifest.catalogHash} instead of ${baseline.catalogHash}`, + ); + } + if (JSON.stringify(manifest.coverage) !== baselineCoverage) { + throw new Error( + `Direct scenario ${String(index)} exposed different coverage for catalog ${baseline.catalogHash}`, + ); + } + if (directCatalogIdentity(manifest) !== baselineCatalog) { + throw new Error( + `Direct scenario ${String(index)} exposed different public metadata for catalog ${baseline.catalogHash}`, + ); + } + } + return baseline.coverage; +} + +/** + * Bind post-interaction evidence to the exact catalog and activation sampled + * before the interaction. Probe counters may advance; their session identity + * may not. + */ +export function bindDirectBrowserContractEvidence< + Manifest extends DirectBrowserManifest, + Probe extends DirectBrowserProbe, +>( + initial: DirectBrowserContract, + final: DirectBrowserContract, + retainedProbe: DirectBrowserProbe = final.probe, +): DirectBrowserContract { + if (directCatalogIdentity(final.manifest) !== directCatalogIdentity(initial.manifest)) { + throw new Error("Direct public catalog metadata changed during verification"); + } + if (JSON.stringify(final.manifest.coverage) !== JSON.stringify(initial.manifest.coverage)) { + throw new Error("Direct coverage changed during verification"); + } + if (final.manifest.catalogHash !== initial.manifest.catalogHash) { + throw new Error("Direct catalog hash changed during verification"); + } + if (JSON.stringify(final.manifest.active) !== JSON.stringify(initial.manifest.active)) { + throw new Error("Direct activation identity changed during verification"); + } + if ( + initial.probe.activationHash !== initial.manifest.active.activationHash + || final.probe.activationHash !== final.manifest.active.activationHash + || retainedProbe.activationHash !== final.manifest.active.activationHash + ) { + throw new Error("Direct probe identity changed during verification"); + } + return final; +} + +/** + * Read one atomic Direct bridge sample and bind its exact manifest, active + * scenario, product route, and probe identity before product assertions run. + */ +export function createDirectBrowserContractReader< + Manifest extends DirectBrowserManifest, + Probe extends DirectBrowserProbe, +>( + protocol: DirectBrowserProtocol, +): ( + browser: Pick, + expectation: DirectBrowserContractExpectation, +) => Promise> { + return async (browser, expectation) => { + const envelope = parseDirectBrowserContractEnvelope( + await browser.evaluate(`(() => { + const bridge = window.__direct; + return { + bridgeSchema: bridge?.schema, + manifest: bridge?.manifest, + probe: typeof bridge?.snapshot === "function" ? bridge.snapshot() : undefined, + }; + })()`), + ); + if (envelope.bridgeSchema !== protocol.bridgeSchema) { + throw new Error( + `Direct browser bridge schema must be ${protocol.bridgeSchema}`, + ); + } + const manifest = protocol.parseManifest(envelope.manifest); + if (!manifest.ok) { + throw new Error(`Direct session manifest is invalid: ${manifest.error.message}`); + } + const probe = protocol.parseProbe(envelope.probe); + if (!probe.ok) { + throw new Error(`Direct probe is invalid: ${probe.error.message}`); + } + if (manifest.value.active.source !== expectation.source) { + throw new Error( + `Direct activated from ${manifest.value.active.source} instead of ${expectation.source}`, + ); + } + if (String(manifest.value.active.scenario) !== expectation.scenario) { + throw new Error( + `Direct activated ${String(manifest.value.active.scenario)} instead of ${expectation.scenario}`, + ); + } + if (manifest.value.active.route !== expectation.route) { + throw new Error( + `Direct scenario ${expectation.scenario} activated route ${manifest.value.active.route} instead of ${expectation.route}`, + ); + } + if (manifest.value.active.activationHash !== probe.value.activationHash) { + throw new Error( + "Direct session manifest and probe identify different activations", + ); + } + return Object.freeze({ + manifest: manifest.value, + probe: probe.value, + }); + }; +} + +export interface ManagedVerificationServer { + readonly exited: Promise; + readonly exitCode: () => number | null; + readonly output: Promise; + readonly terminate: () => void; + readonly kill: () => void; +} + +export type ServerLease = + | { readonly source: "reused" } + | { readonly source: "started"; readonly server: ManagedVerificationServer }; + +export interface ArtifactRun { + readonly artifactRoot: string; + readonly generatedAt: string; + readonly manifestPath: string; + readonly runDirectory: string; +} + +/** Serializes explicit Chrome flags for agent-browser without a shell boundary. */ +export function serializeAgentBrowserLaunchArguments( + launchArguments: readonly string[], +): string { + for (const argument of launchArguments) { + if (!argument.startsWith("--") || argument.includes("\n") || argument.includes(",")) { + throw new Error(`agent-browser launch arguments must be comma-free Chrome flags, received ${JSON.stringify(argument)}`); + } + } + return launchArguments.join(","); +} + +/** + * Builds a fresh agent-browser process environment without inheriting an + * attached browser, persistent profile, restored state, or ambient flags. + */ +export function isolatedAgentBrowserEnvironment(options: { + readonly configPath: string; + readonly defaultTimeoutMs: number; + readonly idleTimeoutMs?: number; + readonly inheritedEnvironment: Readonly>; + readonly launchArguments?: readonly string[]; + readonly session: string; +}): Record { + const environment = { ...options.inheritedEnvironment }; + for (const variable of Object.keys(environment)) { + if (variable.startsWith("AGENT_BROWSER_")) Reflect.deleteProperty(environment, variable); + } + return { + ...environment, + AGENT_BROWSER_CONFIG: options.configPath, + AGENT_BROWSER_DEFAULT_TIMEOUT: String(options.defaultTimeoutMs), + AGENT_BROWSER_IDLE_TIMEOUT_MS: String( + options.idleTimeoutMs ?? options.defaultTimeoutMs + 60_000, + ), + ...(options.launchArguments === undefined + ? {} + : { AGENT_BROWSER_ARGS: serializeAgentBrowserLaunchArguments(options.launchArguments) }), + AGENT_BROWSER_NAMESPACE: options.session, + AGENT_BROWSER_RESTORE_SAVE: "never", + AGENT_BROWSER_SESSION: options.session, + }; +} + +/** Keeps the namespace-backed Unix socket path below macOS's 103-byte limit. */ +export function boundedAgentBrowserSessionName( + prefix: string, + processId: number, + nonce: string, +): string { + const boundedPrefix = prefix + .replaceAll(/[^a-zA-Z0-9_-]+/g, "-") + .replaceAll(/^-+|-+$/g, "") + .slice(0, 6) || "verify"; + const boundedProcessId = Math.max(0, Math.trunc(processId)).toString(36).slice(-6); + const boundedNonce = nonce.replaceAll(/[^a-zA-Z0-9]+/g, "").slice(0, 6) || "run"; + return `${boundedPrefix}-${boundedProcessId}-${boundedNonce}`; +} + +/** Keeps diagnostics useful without repeating large browser programs or batches. */ +export function renderAgentBrowserCommand(arguments_: readonly string[]): string { + const [command, payload] = arguments_; + if (command === "eval" && payload !== undefined) { + return `${command} (${payload.length} character payload)`; + } + if (command === "batch") { + return `${command} (${arguments_.slice(1).join("\n").length} character payload)`; + } + return arguments_.join(" "); +} + +export const agentBrowserCloseProcessTimeoutMs = 10_000; + +export function agentBrowserProcessTimeoutMs( + arguments_: readonly string[], + defaultTimeoutMs: number, +): number { + const defaultProcessTimeoutMs = defaultTimeoutMs + 5_000; + return arguments_[0] === "close" + ? Math.min(defaultProcessTimeoutMs, agentBrowserCloseProcessTimeoutMs) + : defaultProcessTimeoutMs; +} + +function truncateRenderedError(value: string): string { + if (value.length <= MAX_RENDERED_ERROR_LENGTH) return value; + return `${value.slice(0, MAX_RENDERED_ERROR_LENGTH - 1)}…`; +} + +function readForeignProperty( + value: object | ((...arguments_: never[]) => unknown), + key: PropertyKey, +): { readonly ok: true; readonly value: unknown } | { readonly ok: false } { + try { + return { ok: true, value: Reflect.get(value, key) }; + } catch { + return { ok: false }; + } +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function isNonArrayObject(value: unknown): value is object { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyStringArray(value: unknown): value is readonly [string, ...string[]] { + return isUnknownArray(value) + && value.length > 0 + && value.every((entry) => typeof entry === "string"); +} + +function renderUnknownAtDepth(value: unknown, seen: WeakSet, depth: number): string { + if (typeof value === "string") return truncateRenderedError(value); + if ((typeof value === "object" && value !== null) || typeof value === "function") { + if (seen.has(value)) return "[Circular]"; + if (depth >= MAX_ERROR_CAUSE_DEPTH) return "[Cause depth exceeded]"; + seen.add(value); + + const message = readForeignProperty(value, "message"); + if (message.ok && typeof message.value === "string") { + const name = readForeignProperty(value, "name"); + const label = name.ok && typeof name.value === "string" && name.value.length > 0 + ? name.value + : "Error"; + const cause = readForeignProperty(value, "cause"); + const renderedCause = cause.ok && cause.value !== undefined + ? `; caused by ${renderUnknownAtDepth(cause.value, seen, depth + 1)}` + : ""; + return truncateRenderedError(`${label}: ${message.value}${renderedCause}`); + } + } + + try { + const encoded = JSON.stringify(value); + if (encoded !== undefined) return truncateRenderedError(encoded); + } catch { + // Fall through to guarded coercion for cycles, proxies, and foreign toJSON hooks. + } + try { + return truncateRenderedError(String(value)); + } catch { + return "Unknown failure"; + } +} + +/** Render foreign failures without trusting prototypes, getters, causes, or unbounded output. */ +export function renderUnknown(value: unknown): string { + return renderUnknownAtDepth(value, new WeakSet(), 0); +} + +export function tail(value: string, maximumLength = DEFAULT_LOG_LIMIT): string { + return value.length <= maximumLength ? value : value.slice(-maximumLength); +} + +export function normalizeRootHttpOrigin(input: string): string { + let url: URL; + try { + url = new URL(input); + } catch { + throw new Error("--base-url must be an absolute HTTP URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("--base-url must use http: or https:"); + } + if (url.username !== "" || url.password !== "") { + throw new Error("--base-url cannot contain credentials"); + } + if (url.pathname !== "/" || url.search !== "" || url.hash !== "") { + throw new Error("--base-url must point to the server root without a query string or fragment"); + } + return url.origin; +} + +export function parseBaseUrlArguments( + arguments_: readonly string[], + defaultBaseUrl: string, +): BrowserVerificationArguments { + let baseUrl = defaultBaseUrl; + let receivedBaseUrl = false; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === undefined) continue; + if (argument === "--help" || argument === "-h") return { kind: "help" }; + if (argument.startsWith("--base-url=")) { + if (receivedBaseUrl) throw new Error("--base-url may be provided only once"); + receivedBaseUrl = true; + baseUrl = argument.slice("--base-url=".length); + continue; + } + if (argument === "--base-url") { + if (receivedBaseUrl) throw new Error("--base-url may be provided only once"); + const value = arguments_[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error("--base-url requires a value"); + } + receivedBaseUrl = true; + baseUrl = value; + index += 1; + continue; + } + throw new Error(`Unknown argument at position ${String(index + 1)}`); + } + return { kind: "run", baseUrl: normalizeRootHttpOrigin(baseUrl) }; +} + +export function canAutomaticallyStartLocalServer( + baseUrl: string, + localHosts: ReadonlySet = new Set(["127.0.0.1", "localhost"]), +): boolean { + const url = new URL(normalizeRootHttpOrigin(baseUrl)); + return url.protocol === "http:" && localHosts.has(url.hostname); +} + +export function parseAgentBrowserEnvelope(source: string): unknown { + let input: unknown; + try { + input = JSON.parse(source) as unknown; + } catch { + throw new Error("agent-browser did not return one JSON document"); + } + if ( + typeof input !== "object" + || input === null + || Array.isArray(input) + || typeof Reflect.get(input, "success") !== "boolean" + || !Object.hasOwn(input, "data") + || !Object.hasOwn(input, "error") + ) { + throw new Error("agent-browser returned an invalid envelope"); + } + if (!Reflect.get(input, "success")) { + throw new Error(`agent-browser reported failure: ${renderUnknown(Reflect.get(input, "error"))}`); + } + return Reflect.get(input, "data"); +} + +export function parseAgentBrowserBatchEnvelope(source: string): readonly unknown[] { + let input: unknown; + try { + input = JSON.parse(source) as unknown; + } catch { + throw new Error("agent-browser batch did not return one JSON document"); + } + if (!isUnknownArray(input) || input.length === 0) { + throw new Error("agent-browser batch returned an invalid envelope"); + } + return input.map((entry, index) => { + if ( + !isNonArrayObject(entry) + || !Object.hasOwn(entry, "command") + || !Object.hasOwn(entry, "success") + || !Object.hasOwn(entry, "result") + || !Object.hasOwn(entry, "error") + ) { + throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`); + } + const command = readForeignProperty(entry, "command"); + const success = readForeignProperty(entry, "success"); + const result = readForeignProperty(entry, "result"); + const error = readForeignProperty(entry, "error"); + if ( + !command.ok + || !isNonEmptyStringArray(command.value) + || !success.ok + || typeof success.value !== "boolean" + || !result.ok + || !error.ok + ) { + throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`); + } + if (!success.value) { + throw new Error( + `agent-browser batch command ${String(index + 1)} (${renderAgentBrowserCommand(command.value)}) reported failure: ${renderUnknown(error.value)}`, + ); + } + return result.value; + }); +} + +export function createAgentBrowser(options: { + readonly repositoryRoot: string; + readonly sessionPrefix: string; + readonly defaultTimeoutMs?: number; + readonly idleTimeoutMs?: number; + readonly launchArguments?: readonly string[]; +}): AgentBrowser { + const binary = join(options.repositoryRoot, "node_modules/.bin/agent-browser"); + const createEnvironment = () => { + const session = boundedAgentBrowserSessionName( + options.sessionPrefix, + process.pid, + randomUUID(), + ); + return isolatedAgentBrowserEnvironment({ + configPath: join(options.repositoryRoot, "scripts/direct/agent-browser.verify.json"), + defaultTimeoutMs: options.defaultTimeoutMs ?? 35_000, + ...(options.idleTimeoutMs === undefined + ? {} + : { idleTimeoutMs: options.idleTimeoutMs }), + inheritedEnvironment: process.env, + ...(options.launchArguments === undefined + ? {} + : { launchArguments: options.launchArguments }), + session, + }); + }; + let environment = createEnvironment(); + let used = false; + + async function run(arguments_: readonly string[]): Promise { + used = true; + const defaultTimeoutMs = options.defaultTimeoutMs ?? 35_000; + const commandArguments = arguments_[0] === "wait" && !arguments_.includes("--timeout") + ? [...arguments_, "--timeout", String(defaultTimeoutMs)] + : arguments_; + const command = Bun.spawn([process.execPath, binary, "--json", ...commandArguments], { + cwd: options.repositoryRoot, + env: environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + let forceKillTimer: ReturnType | undefined; + const commandTimeoutMs = agentBrowserProcessTimeoutMs( + commandArguments, + defaultTimeoutMs, + ); + const timeoutTimer = setTimeout(() => { + timedOut = true; + command.kill(); + forceKillTimer = setTimeout(() => command.kill(9), 1_000); + }, commandTimeoutMs); + let stdout: string; + let stderr: string; + let exitCode: number; + try { + [stdout, stderr, exitCode] = await Promise.all([ + new Response(command.stdout).text(), + new Response(command.stderr).text(), + command.exited, + ]); + } finally { + clearTimeout(timeoutTimer); + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + } + if (timedOut) { + throw new Error( + `agent-browser ${renderAgentBrowserCommand(commandArguments)} exceeded its ${commandTimeoutMs}ms process deadline`, + ); + } + if (exitCode !== 0) { + throw new Error( + `agent-browser ${renderAgentBrowserCommand(commandArguments)} exited with ${exitCode}: ${tail(stderr.trim() || stdout.trim())}`, + ); + } + return commandArguments[0] === "batch" + ? parseAgentBrowserBatchEnvelope(stdout) + : parseAgentBrowserEnvelope(stdout); + } + + async function evaluate(expression: string): Promise { + const evaluation = await run(["eval", expression]); + if ( + typeof evaluation !== "object" + || evaluation === null + || Array.isArray(evaluation) + || !Object.hasOwn(evaluation, "result") + ) { + throw new Error("browser evaluation returned invalid data"); + } + return Reflect.get(evaluation, "result"); + } + + async function readBodyText(): Promise { + const result = await evaluate("document.body?.innerText ?? ''"); + if (typeof result !== "string") throw new Error("body text evaluation did not return a string"); + return result; + } + + async function close(): Promise { + if (!used) return; + try { + await run(["close"]); + } catch (error) { + if (!renderUnknown(error).includes("Failed to connect: No such file or directory")) { + throw error; + } + } finally { + used = false; + } + } + + async function restart(): Promise { + // A process deadline can leave the old daemon unable to answer `close`. + // Its verifier-owned idle timeout still bounds that exact namespace, so + // recovery must rotate even when synchronous cleanup cannot complete. + try { + await close(); + } catch { + used = false; + } + environment = createEnvironment(); + } + + return { close, evaluate, readBodyText, restart, run }; +} + +async function collectStream(stream: ReadableStream, logLimit: number): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + for (;;) { + const chunk = await reader.read(); + if (chunk.done) return tail(`${output}${decoder.decode()}`, logLimit); + output = tail(`${output}${decoder.decode(chunk.value, { stream: true })}`, logLimit); + } +} + +export function spawnVerificationServer(options: { + readonly command: readonly string[]; + readonly cwd: string; + readonly env?: Readonly>; + readonly logLimit?: number; +}): ManagedVerificationServer { + const process_ = Bun.spawn([...options.command], { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const logLimit = options.logLimit ?? DEFAULT_LOG_LIMIT; + const output = Promise.all([ + collectStream(process_.stdout, logLimit), + collectStream(process_.stderr, logLimit), + ]).then(([stdout, stderr]) => tail(`${stdout}\n${stderr}`.trim(), logLimit)); + + return { + exited: process_.exited, + exitCode: () => process_.exitCode, + output, + terminate: () => process_.kill("SIGTERM"), + kill: () => process_.kill("SIGKILL"), + }; +} + +export async function runVerificationCommand(options: { + readonly command: readonly string[]; + readonly cwd: string; + readonly env?: Readonly>; + readonly label: string; + readonly timeoutMs: number; +}): Promise { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error("verification command timeout must be a finite positive duration"); + } + const command = spawnVerificationServer({ + command: options.command, + cwd: options.cwd, + ...(options.env === undefined ? {} : { env: options.env }), + }); + let timeout: ReturnType | undefined; + const completed = await Promise.race([ + command.exited.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), options.timeoutMs); + }), + ]); + if (timeout !== undefined) clearTimeout(timeout); + if (!completed) { + const output = tail(await stopVerificationServerWithOutput(command)); + const message = `${options.label} exceeded its ${options.timeoutMs}ms deadline`; + throw new Error(output === "" ? message : `${message}:\n${output}`); + } + const exitCode = command.exitCode(); + const output = tail(await stopVerificationServerWithOutput(command)); + if (exitCode !== 0) { + throw new Error(`${options.label} exited with ${String(exitCode)}:\n${output}`); + } + return output; +} + +type BoundedSettlement = + | { readonly settled: false } + | { readonly settled: true; readonly value: Value }; + +async function settleWithin( + promise: Promise, + timeoutMs: number, +): Promise> { + return await Promise.race([ + promise.then((value) => ({ settled: true, value }) as const), + Bun.sleep(timeoutMs).then(() => ({ settled: false }) as const), + ]); +} + +export async function serverIsReachable( + baseUrl: string, + probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, + readinessPath = "/", +): Promise { + if (!readinessPath.startsWith("/") || readinessPath.startsWith("//")) { + throw new Error(`readinessPath must be an origin-relative path, received ${JSON.stringify(readinessPath)}`); + } + const probeUrl = new URL(readinessPath, `${normalizeRootHttpOrigin(baseUrl)}/`); + if (probeUrl.hash !== "") throw new Error("readinessPath cannot contain a fragment"); + try { + const response = await fetch(probeUrl, { + signal: AbortSignal.timeout(probeTimeoutMs), + }); + await response.body?.cancel(); + return response.ok; + } catch { + return false; + } +} + +async function stopVerificationServerWithOutput( + server: ManagedVerificationServer, + stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, +): Promise { + if (!Number.isFinite(stopTimeoutMs) || stopTimeoutMs < 0) { + throw new Error("verification server stop timeout must be a finite nonnegative duration"); + } + if (server.exitCode() === null) server.terminate(); + const stopped = await settleWithin(server.exited, stopTimeoutMs); + if (!stopped.settled) { + server.kill(); + const killed = await settleWithin(server.exited, stopTimeoutMs); + if (!killed.settled) { + throw new Error( + `verification server did not exit within ${stopTimeoutMs}ms after SIGKILL`, + ); + } + } + const output = await settleWithin(server.output, stopTimeoutMs); + if (!output.settled) { + throw new Error( + `verification server output did not settle within ${stopTimeoutMs}ms after exit`, + ); + } + return output.value; +} + +export async function stopVerificationServer( + server: ManagedVerificationServer, + stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, +): Promise { + await stopVerificationServerWithOutput(server, stopTimeoutMs); +} + +export async function acquireVerificationServer(options: { + readonly baseUrl: string; + readonly label: string; + readonly localHosts?: ReadonlySet; + readonly pollIntervalMs?: number; + readonly probeTimeoutMs?: number; + readonly reuseProbeIntervalMs?: number; + readonly reuseExistingLocalServer?: boolean; + readonly readinessPath?: `/${string}`; + readonly startServer: () => ManagedVerificationServer; + readonly startupTimeoutMs: number; + readonly isReachable?: ( + baseUrl: string, + probeTimeoutMs: number, + readinessPath: string, + ) => boolean | Promise; +}): Promise { + const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const readinessPath = options.readinessPath ?? "/"; + const isReachable = options.isReachable ?? serverIsReachable; + const canStartLocally = canAutomaticallyStartLocalServer( + options.baseUrl, + options.localHosts, + ); + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + if (canStartLocally && options.reuseExistingLocalServer === false) { + throw new Error( + `A local server is already reachable at ${options.baseUrl}; ` + + "verification will not reuse a server whose worktree ownership is unknown", + ); + } + // A verifier-owned command can exit before its child listener has finished + // shutting down. Require the listener to survive a bounded interval before + // another verifier trusts it as independently managed infrastructure. + await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS); + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + return { source: "reused" }; + } + } + if (!canStartLocally) { + throw new Error( + `No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`, + ); + } + + const server = options.startServer(); + let exitedWithCode: number | null = null; + try { + const deadline = Date.now() + options.startupTimeoutMs; + while (Date.now() < deadline) { + const exitCode = server.exitCode(); + if (exitCode !== null) { + exitedWithCode = exitCode; + break; + } + if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) { + return { source: "started", server }; + } + await Bun.sleep(options.pollIntervalMs ?? 200); + } + } catch (error) { + await stopVerificationServer(server); + throw error; + } + if (exitedWithCode !== null) { + const output = tail(await stopVerificationServerWithOutput(server)); + throw new Error(`${options.label} exited with ${exitedWithCode}:\n${output}`); + } + const timeoutMessage = `${options.label} did not become reachable at ${new URL(readinessPath, `${options.baseUrl}/`).href} within ${options.startupTimeoutMs}ms`; + const output = tail(await stopVerificationServerWithOutput(server)); + throw new Error(output === "" ? timeoutMessage : `${timeoutMessage}:\n${output}`); +} + +export async function createArtifactRun(options: { + readonly artifactRoot: string; + readonly generatedAt?: string; + readonly processId?: number; +}): Promise { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const processId = options.processId ?? process.pid; + const runId = `${generatedAt.replaceAll(/[^0-9A-Za-z]/gu, "-")}-${processId}`; + const runDirectory = join(options.artifactRoot, runId); + await mkdir(runDirectory, { recursive: true }); + return { + artifactRoot: options.artifactRoot, + generatedAt, + manifestPath: join(options.artifactRoot, "manifest.json"), + runDirectory, + }; +} + +export async function writeJsonAtomically(path: string, value: unknown): Promise { + const temporaryPath = join(dirname(path), `.${process.pid}-${randomUUID()}.tmp`); + try { + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } +} diff --git a/src/tooling/bundle-boundary.test.ts b/src/tooling/bundle-boundary.test.ts new file mode 100644 index 0000000..859426a --- /dev/null +++ b/src/tooling/bundle-boundary.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + checkBundleBoundary, + DIRECT_WIRE_MARKERS, + findForbiddenMarkers, + inspectExactVersionedMarkers, +} from "./bundle-boundary.js"; + +describe("Direct bundle boundary", () => { + test("reports markers in declaration order across binary data", () => { + const markers = ["direct.fixture/v1", "__direct_scenario", "Direct workbench"] as const; + const bytes = Buffer.from(`prefix\0${markers[2]}\0${markers[0]}\0suffix`); + + expect(findForbiddenMarkers(bytes, markers)).toEqual([markers[0], markers[2]]); + }); + + test("rejects empty and duplicate policies", () => { + expect(() => findForbiddenMarkers(Buffer.from("bundle"), [])).toThrow("at least one"); + expect(() => findForbiddenMarkers(Buffer.from("bundle"), ["fixture", "fixture"])) + .toThrow("duplicated"); + expect(() => findForbiddenMarkers(Buffer.from("bundle"), [""])).toThrow("cannot be empty"); + expect(checkBundleBoundary({ + directory: import.meta.dir, + excludePatterns: [""], + markers: ["fixture"], + patterns: ["*.ts"], + })).rejects.toThrow("exclusion patterns cannot be empty"); + }); + + test("uses exact wire families instead of the ambiguous product adjective", () => { + expect(DIRECT_WIRE_MARKERS).toEqual([ + "direct.browser-bridge/", + "direct.coverage/", + "direct.fixture/", + "direct.probe/", + "direct.runtime/", + "direct.session-manifest/", + ]); + expect(findForbiddenMarkers( + Buffer.from("ordinary direct.file metadata"), + DIRECT_WIRE_MARKERS, + )).toEqual([]); + expect(findForbiddenMarkers( + Buffer.from("direct.runtime/v1 direct.session-manifest/v1"), + DIRECT_WIRE_MARKERS, + )).toEqual(["direct.runtime/", "direct.session-manifest/"]); + }); + + test("matches complete numeric versions instead of version prefixes", () => { + expect(inspectExactVersionedMarkers([ + Buffer.from([ + "direct.browser-bridge/v20", + "direct.session-manifest/v10", + "direct.probe/v10", + ].join(" ")), + ], [ + "direct.browser-bridge/v2", + "direct.session-manifest/v1", + "direct.probe/v1", + ])).toEqual({ + missing: [ + "direct.browser-bridge/v2", + "direct.session-manifest/v1", + "direct.probe/v1", + ], + observed: [ + "direct.browser-bridge/v20", + "direct.probe/v10", + "direct.session-manifest/v10", + ], + unexpected: [ + "direct.browser-bridge/v20", + "direct.probe/v10", + "direct.session-manifest/v10", + ], + }); + }); + + test("reports additional versions beside an exact current contract", () => { + expect(inspectExactVersionedMarkers([ + Buffer.from([ + "direct.browser-bridge/v2", + "direct.browser-bridge/v1", + "direct.session-manifest/v1", + "direct.session-manifest/v99", + ].join(" ")), + Buffer.from("direct.probe/v1 direct.probe/v2"), + ], [ + "direct.browser-bridge/v2", + "direct.session-manifest/v1", + "direct.probe/v1", + ])).toEqual({ + missing: [], + observed: [ + "direct.browser-bridge/v2", + "direct.session-manifest/v1", + "direct.probe/v1", + "direct.browser-bridge/v1", + "direct.probe/v2", + "direct.session-manifest/v99", + ], + unexpected: [ + "direct.browser-bridge/v1", + "direct.probe/v2", + "direct.session-manifest/v99", + ], + }); + }); + + test("rejects malformed or ambiguous exact-version policies", () => { + expect(() => inspectExactVersionedMarkers([], [])).toThrow("at least one"); + expect(() => inspectExactVersionedMarkers([], ["direct.probe/v01"])) + .toThrow("canonical numeric version"); + expect(() => inspectExactVersionedMarkers([], [ + "direct.probe/v1", + "direct.probe/v2", + ])).toThrow("family is duplicated"); + }); + + test("scans overlapping patterns once and returns deterministic violations", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "hraness-direct-boundary-")); + try { + await mkdir(path.join(directory, "nested")); + await writeFile(path.join(directory, "safe.js"), "production only"); + await writeFile( + path.join(directory, "nested", "fixture.js"), + Buffer.from("prefix\0__direct_scenario\0direct.fixture/v1\0suffix"), + ); + + const result = await checkBundleBoundary({ + directory, + markers: ["direct.fixture/v1", "__direct_scenario"], + patterns: ["**/*.js", "nested/**/*"], + }); + + expect(result.scanned).toEqual([ + path.join(directory, "nested", "fixture.js"), + path.join(directory, "safe.js"), + ]); + expect(result.violations).toEqual([{ + file: path.join(directory, "nested", "fixture.js"), + markers: ["direct.fixture/v1", "__direct_scenario"], + }]); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + test("excludes non-production files before scanning for forbidden markers", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "hraness-direct-boundary-")); + try { + await mkdir(path.join(directory, "nested")); + await writeFile(path.join(directory, "production.ts"), "export const production = true;"); + await writeFile( + path.join(directory, "production.test.ts"), + 'import "@hraness/direct";', + ); + await writeFile( + path.join(directory, "nested", "production.spec.tsx"), + 'import "@hraness/direct";', + ); + + const result = await checkBundleBoundary({ + directory, + excludePatterns: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + markers: ["@hraness/direct"], + patterns: ["**/*.ts", "**/*.tsx"], + }); + + expect(result.scanned).toEqual([path.join(directory, "production.ts")]); + expect(result.violations).toEqual([]); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/tooling/bundle-boundary.ts b/src/tooling/bundle-boundary.ts new file mode 100644 index 0000000..dd48da0 --- /dev/null +++ b/src/tooling/bundle-boundary.ts @@ -0,0 +1,157 @@ +import path from "node:path"; + +export const DIRECT_WIRE_MARKERS = Object.freeze([ + "direct.browser-bridge/", + "direct.coverage/", + "direct.fixture/", + "direct.probe/", + "direct.runtime/", + "direct.session-manifest/", +] as const); + +export interface BundleBoundaryViolation { + readonly file: string; + readonly markers: readonly string[]; +} + +export interface BundleBoundaryResult { + readonly scanned: readonly string[]; + readonly violations: readonly BundleBoundaryViolation[]; +} + +export interface BundleBoundaryOptions { + readonly directory: string; + readonly excludePatterns?: readonly string[]; + readonly markers: readonly string[]; + readonly patterns: readonly string[]; +} + +export interface ExactVersionedMarkerEvidence { + readonly missing: readonly string[]; + readonly observed: readonly string[]; + readonly unexpected: readonly string[]; +} + +function validatedMarkers(markers: readonly string[]): readonly string[] { + const seen = new Set(); + const output: string[] = []; + for (const marker of markers) { + if (marker.length === 0) throw new Error("Bundle-boundary markers cannot be empty."); + if (seen.has(marker)) throw new Error(`Bundle-boundary marker is duplicated: ${marker}`); + seen.add(marker); + output.push(marker); + } + if (output.length === 0) throw new Error("A bundle boundary needs at least one forbidden marker."); + return Object.freeze(output); +} + +function validatedPatterns(patterns: readonly string[]): readonly string[] { + if (patterns.length === 0) throw new Error("A bundle boundary needs at least one file pattern."); + return Object.freeze(patterns.map((pattern) => { + if (pattern.length === 0) throw new Error("Bundle-boundary file patterns cannot be empty."); + return pattern; + })); +} + +function validatedExcludePatterns(patterns: readonly string[] | undefined): readonly string[] { + return Object.freeze((patterns ?? []).map((pattern) => { + if (pattern.length === 0) throw new Error("Bundle-boundary exclusion patterns cannot be empty."); + return pattern; + })); +} + +function versionedMarkerFamilies( + expectedMarkers: readonly string[], +): readonly { readonly expected: string; readonly family: string }[] { + if (expectedMarkers.length === 0) { + throw new Error("An exact versioned-marker policy needs at least one expected marker."); + } + const seen = new Set(); + return Object.freeze(expectedMarkers.map((expected) => { + const match = /^(?[A-Za-z0-9][A-Za-z0-9._/-]*\/v)(?0|[1-9][0-9]*)$/u + .exec(expected); + const family = match?.groups?.["family"]; + if (family === undefined) { + throw new Error(`Exact versioned marker must end in a canonical numeric version: ${expected}`); + } + if (seen.has(family)) { + throw new Error(`Exact versioned-marker family is duplicated: ${family}`); + } + seen.add(family); + return Object.freeze({ expected, family }); + })); +} + +function escapedRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +export function inspectExactVersionedMarkers( + byteSequences: Iterable, + expectedMarkers: readonly string[], +): ExactVersionedMarkerEvidence { + const families = versionedMarkerFamilies(expectedMarkers); + const observed = new Set(); + const contents = [...byteSequences].map((bytes) => ( + Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1") + )); + for (const { family } of families) { + const pattern = new RegExp( + `(? observed.has(marker)); + const unexpected = [...observed] + .filter((marker) => !expected.has(marker)) + .sort((left, right) => left.localeCompare(right)); + return Object.freeze({ + missing: Object.freeze(expectedMarkers.filter((marker) => !observed.has(marker))), + observed: Object.freeze([...matching, ...unexpected]), + unexpected: Object.freeze(unexpected), + }); +} + +export function findForbiddenMarkers( + bytes: Uint8Array, + markers: readonly string[], +): readonly string[] { + const contents = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return validatedMarkers(markers).filter((marker) => contents.includes(Buffer.from(marker))); +} + +export async function checkBundleBoundary( + options: BundleBoundaryOptions, +): Promise { + const root = path.resolve(options.directory); + const markers = validatedMarkers(options.markers); + const patterns = validatedPatterns(options.patterns); + const excludePatterns = validatedExcludePatterns(options.excludePatterns) + .map((pattern) => new Bun.Glob(pattern)); + const scanned = new Set(); + const violations: BundleBoundaryViolation[] = []; + + for (const pattern of patterns) { + const glob = new Bun.Glob(pattern); + for await (const relative of glob.scan({ cwd: root, dot: true, onlyFiles: true })) { + if (excludePatterns.some((excludePattern) => excludePattern.match(relative))) continue; + const file = path.join(root, relative); + if (scanned.has(file)) continue; + scanned.add(file); + const found = findForbiddenMarkers( + new Uint8Array(await Bun.file(file).arrayBuffer()), + markers, + ); + if (found.length > 0) violations.push({ file, markers: found }); + } + } + + return { + scanned: Object.freeze([...scanned].sort()), + violations: Object.freeze(violations.toSorted((left, right) => left.file.localeCompare(right.file))), + }; +} diff --git a/tsconfig.json b/tsconfig.json index ebd05da..c3aac0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,12 @@ "@hraness/direct/testing": [ "./src/testing/index.ts" ], + "@hraness/direct/tooling/browser-verification": [ + "./src/tooling/browser-verification-entry.ts" + ], + "@hraness/direct/tooling/bundle-boundary": [ + "./src/tooling/bundle-boundary.ts" + ], "@hraness/direct/web": [ "./src/web.ts" ]