diff --git a/.audit/pi-evidence-2.9.0.tsv b/.audit/pi-evidence-2.9.0.tsv new file mode 100644 index 00000000..b616bab9 --- /dev/null +++ b/.audit/pi-evidence-2.9.0.tsv @@ -0,0 +1,7 @@ +version bun platform archived initialize_utf8_bytes browser_utf8_bytes prove_utf8_bytes audit_utf8_bytes ready proof_images +2.8.9 1.4.0 macOS-arm64 0 885 853 2384 954 true 1 +2.8.9 1.4.0 macOS-arm64 1 1341 1155 3145 1410 true 1 +2.8.9 1.4.0 macOS-arm64 2 1795 1464 3906 1864 true 1 +2.9.0 1.4.0 macOS-arm64 0 937 885 2295 1694 true 1 +2.9.0 1.4.0 macOS-arm64 1 937 884 2294 1694 true 1 +2.9.0 1.4.0 macOS-arm64 2 937 886 2294 1694 true 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cd9b07..52ec9175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ Releases before 1.1.3 predate this file; their notes live on the ## [Unreleased] +## [2.9.0] - 2026-10-19 + +### Added + +- Pi evidence history can be read with `browser_evidence` operations `list` + (paged, at most 100 journeys) and `get` (exact journey name), without + changing the active checklist, proof state, or audit. Proofs include original + and retained artifact paths; private copies survive worker quota eviction + until the Pi extension session shuts down. (#238) + +### Changed + +- Pi default evidence responses contain the current checklist and a scalar + `archivedCount`, replacing the full `archivedChecklists` array. Consumers + reading history should migrate to `list` and `get`. Successful proof calls + serialize one post-operation status. History reads and audit remain available + after the browser step budget is exhausted. See SETUP.md for session retention + and migration details. (#238) + +### Fixed + +- Pi no longer marks a requirement proven when its screenshot is missing, + unreadable, oversized, or cannot be retained. A new proof invalidates the + previous audit so the changed evidence must be audited again. +- Relative Cookie Sync source paths resolve in the invoking process instead + of the session daemon's working directory. Explicit paths with + `includeSession` fail validation with the specific unsupported-option error. + The other launch-precedence and path-redaction fixes proposed in #237 were + already included in 2.8.9. (#237) +- CLI launch-default validation errors identify their own environment variable + instead of being attributed to the profile setting or printed as a stack + trace. (#236) + ## [2.8.9] - 2026-10-12 ### Added @@ -1852,7 +1885,8 @@ number to be reused. refresh already-installed skill files but never create new ones; `doctor` tips when a managed skill is stale. -[Unreleased]: https://github.com/BetterWright/betterwright/compare/v2.8.9...HEAD +[Unreleased]: https://github.com/BetterWright/betterwright/compare/v2.9.0...HEAD +[2.9.0]: https://github.com/BetterWright/betterwright/compare/v2.8.9...v2.9.0 [2.8.9]: https://github.com/BetterWright/betterwright/compare/v2.8.8...v2.8.9 [2.8.8]: https://github.com/BetterWright/betterwright/compare/v2.8.7...v2.8.8 [2.8.7]: https://github.com/BetterWright/betterwright/compare/v2.8.6...v2.8.7 diff --git a/SETUP.md b/SETUP.md index 22c8ec80..a299b82d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -175,6 +175,61 @@ reset after a restart. This control does not select a profile; identity stays in host configuration. Visibility control applies to locally launched browsers; remote, attached, and caller-supplied browsers remain host-controlled. +### Evidence history in 2.9.0 + +`browser_evidence` supports `initialize`, `prove`, and `audit` for the current +journey. A completed journey must be audited before initializing another. +Names are unique for the extension session; omitted names are generated. + +Default tool responses include current requirements, exact constraints, +pending IDs, proof references, ready/audited status, and `archivedCount`. +They no longer serialize previous journeys in `archivedChecklists`. This +behavior is automatic in 2.9.0, with no environment switch. Integrations that +used that array should retrieve history explicitly: + +```json +{ "operation": "list", "offset": 0, "limit": 20 } +{ "operation": "get", "name": "find-product" } +``` + +`list` returns journey summaries in creation order, including the current +journey, with `total` and `nextOffset` (null at the end). Its default limit is +20 and maximum is 100. `get` requires an exact name and returns the full +selected `checklist`; unknown names fail. Neither operation browses, consumes +a browser step, changes the active journey, nor marks anything audited. +Both operations and `audit` remain available after the browser budget ends. + +Each proven requirement has `proofArtifacts` containing a private session copy +`path`, the worker's `originalPath`, and `mimeType`. `get` checks that retained +files are readable regular files and adds `available` to each reference; missing copies are reported as +unavailable. Proof images still accompany successful proof calls. A missing, +unreadable, empty, oversized (over 2,500,000 bytes), or unretainable screenshot +cannot prove a pending requirement. Rejected descriptions and browser errors +remain visible and leave pending requirements pending. + +The extension copies proof images outside the worker's quota directory before +accepting them. Worker quota eviction therefore cannot remove retained +history images. These copies consume disk space for the extension session, +are not subject to the worker quota, and are removed on `session_shutdown`, +even when the host owns the browser. Checklists live only in this extension +instance's memory. There is no cross-process recovery or indefinite retention +contract; an abrupt host exit can leave temporary copies for OS cleanup. +Visibility restarts invalidate current proofs and audit, while archived +journeys remain readable. + +The [native measurement record](.audit/pi-evidence-2.9.0.tsv) compares 2.8.9 +and 2.9.0 using Bun 1.4.0, BetterChromium 153.0.8010.36, and macOS arm64. +For three fixed local-fixture journeys, initialize text grew from 885 to +1,795 UTF-8 bytes in 2.8.9, and stayed at 937 bytes in 2.9.0. Proof images +were attached in every journey. Run `bun research/pi-evidence-history-probe.ts` +after building to repeat the real extension/browser calls through a minimal +Pi host adapter. These measurements exclude image bytes and provider framing; +the full Pi UI and model context accounting were not measured. The unit suite +also checks 120 archived journeys with 2,048-byte evidence, the shared download +response path without a real download, rejected/failed proofs, and history +selection. Browser tests force real worker quota eviction and verify visibility +restart invalidation. + Pi also reads the shared `launch` defaults from BetterWright's home `config.json`; see [saved launch defaults](docs/environment.md#saved-launch-defaults). diff --git a/SKILL.md b/SKILL.md index ee62cf74..272e2b7e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: browser description: Drive a persistent, policy-guarded real web browser via the betterwright CLI. Use for any task that needs the live web — logging in, filling forms, booking, buying, or reading a page an API will not give you. -generated_by: betterwright@2.8.9 +generated_by: betterwright@2.9.0 --- # BetterWright browser diff --git a/bin/cli-main.ts b/bin/cli-main.ts index 1bd3033b..e43e432d 100644 --- a/bin/cli-main.ts +++ b/bin/cli-main.ts @@ -2258,18 +2258,31 @@ export async function runCli() { const { runLocalCommand } = await import("../src/local-ai-cli.js"); return runLocalCommand(tokens.slice(1)); } + // The daemon receives resolved overrides on stdin, not CLI flags. Validating + // inherited environment here would reject values the initiating CLI overrode. + if (first === "__daemon") { + const { runSessionDaemon } = await import("../src/daemon.js"); + return runSessionDaemon(); + } // A bad `--profile` should read as one clear line before anything launches, // not as a TypeError stack from inside a browser constructor. Help and // --version still answer, so `--help` never depends on valid flags. if (!wantsHelp(tokens) && !flags.has("--version")) { try { cliProfile(); + const usesLaunchDefaults = !first || first.startsWith("-") || + ["run", "repl", "exec", "record", "mcp"].includes(first) || + (first === "view" && !flags.has("--set-password") && !flags.has("--clear-password")) || + (first === "cookies" && tokens[1] === "sync"); + if (usesLaunchDefaults) launchOptionsFromEnv(defaultDaemonHome(), process.env, launchOverridesFromFlags(flags)); } catch (error) { // Name where the bad value came from: an MCP host debugging a server // that will not start needs to know it is the environment, not a flag. - const source = - flagValue(process.argv, "--profile") === undefined ? "BETTERWRIGHT_PROFILE" : "--profile"; - console.error(`${source}: ${error?.message || error}`); + // Launch-default errors already name their variable. + const message = String(error?.message || error); + const source = /^BETTERWRIGHT_[A-Z_]+ /.test(message) ? "" + : `${flagValue(process.argv, "--profile") === undefined ? "BETTERWRIGHT_PROFILE" : "--profile"}: `; + console.error(`${source}${message}`); return 1; } } @@ -2424,10 +2437,6 @@ export async function runCli() { }); return 0; } - case "__daemon": { - const { runSessionDaemon } = await import("../src/daemon.js"); - return runSessionDaemon(); - } default: console.error(`Unknown command "${command}".\n\n${cliPaint({ stream: process.stderr }).help(MAIN_USAGE)}`); return 1; diff --git a/docs/cookie-sync.md b/docs/cookie-sync.md index 86ebed3e..2713cfe3 100644 --- a/docs/cookie-sync.md +++ b/docs/cookie-sync.md @@ -43,8 +43,11 @@ The SDK equivalent is `browser.syncCookies({ source: { path: "/absolute/path/to/Default" }, domains: ["github.com"] })`. Directories resolve `Network/Cookies` first, then `Cookies`; database files are passed directly to the pinned reader's `fromPath` API. Relative paths -resolve from the host's working directory. The reader handles supported -decryption without changing the source. Path sources cannot be combined with +resolve from the invoking host's working directory, including when a session +daemon was started elsewhere. Explicit paths support plaintext Chromium +cookies; encrypted Chromium databases need a registered browser source for +credential selection. Use a browser id for encrypted profiles. The source is +never changed. Path sources cannot be combined with a browser id, source profile, or `includeSession`. Domain scopes, transfer limits, remote-target consent, and target readback verification still apply. Result metadata reports the path as `"selected"` rather than exposing the diff --git a/package.json b/package.json index 4a3349d1..dc8ee17e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betterwright", - "version": "2.8.9", + "version": "2.9.0", "description": "A persistent, policy-guarded Playwright browser for AI agents with network controls, trusted credential filling, proof screenshots, and CAPTCHA helpers.", "type": "module", "main": "dist/src/index.js", diff --git a/research/pi-evidence-history-probe.ts b/research/pi-evidence-history-probe.ts new file mode 100644 index 00000000..0aa2a696 --- /dev/null +++ b/research/pi-evidence-history-probe.ts @@ -0,0 +1,45 @@ +// Run after bun run build. Exercises the real extension and managed browser +// through a minimal Pi host adapter; no model/provider framing is measured. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createPiExtension } from "../dist/src/pi-extension.js"; + +const home = await fs.mkdtemp(path.join(os.tmpdir(), "betterwright-evidence-probe-")); +const tools = new Map(); +const handlers = new Map(); +createPiExtension({ + browserOptions: { home, vault: false, adBlock: false, headless: true }, + requireEvidence: true, + autoScreenshot: false, +})({ + registerTool: tool => tools.set(tool.name, tool), + on: (name, handler) => handlers.set(name, handler), +}); + +const evidence = params => tools.get("browser_evidence").execute("evidence", params); +function textBytes(result) { + return Buffer.byteLength(result.content.filter(block => block.type === "text").map(block => block.text).join("\n")); +} + +try { + for (let archived = 0; archived < 3; archived += 1) { + const initialized = await evidence({ + operation: "initialize", name: `repro-0${archived + 1}`, + requirements: [{ id: "fixture", description: "The local fixture shows Evidence fixture and Visible marker ALPHA." }], + }); + const browsed = await tools.get("browser").execute("fixture", { code: + `await page.setContent('

Evidence fixture

Visible marker ALPHA

'); + return await page.locator('body').innerText();` }); + const proven = await evidence({ operation: "prove", + proofs: [{ id: "fixture", evidence: "The frame shows Evidence fixture and Visible marker ALPHA." }] }); + const audited = await evidence({ operation: "audit" }); + const images = proven.content.filter(block => block.type === "image").length; + if (!browsed.details.ok || !audited.details.ready || images !== 1) throw new Error("Native evidence probe failed."); + console.log(JSON.stringify({ archived, initialize: textBytes(initialized), browser: textBytes(browsed), + prove: textBytes(proven), audit: textBytes(audited), ready: audited.details.ready, images })); + } +} finally { + try { await handlers.get("session_shutdown")(); } + finally { await fs.rm(home, { recursive: true, force: true }); } +} diff --git a/src/cookie-sync.ts b/src/cookie-sync.ts index 37b9cab3..9dfe19e3 100644 --- a/src/cookie-sync.ts +++ b/src/cookie-sync.ts @@ -205,6 +205,9 @@ export function normalizeCookieSyncOptions( if (includeSessionValue !== undefined && !isBoolean(includeSessionValue)) { throw new TypeError("syncCookies includeSession must be a boolean."); } + if (includeSessionValue === true && "path" in source) { + throw new TypeError("includeSession is unavailable for explicit cookie paths."); + } const appBoundValue = untrustedField(value, "windowsAppBound"); const windowsAppBound = appBoundValue === undefined ? "disabled" diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 7d99463e..89c718c2 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -15,6 +15,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; import readline from "node:readline"; import { @@ -27,7 +28,7 @@ import { } from "./daemon.js"; import { profileLabel, resolveProfileName } from "./profile-name.js"; import { bunInheritedExecArgv } from "./runtime.js"; -import type { UntrustedValue } from "./untrusted-value.js"; +import { isRecord, isString, type UntrustedValue, untrustedField } from "./untrusted-value.js"; const CONNECT_TIMEOUT_MS = 1_000; const SPAWN_WAIT_MS = 8_000; @@ -242,6 +243,19 @@ async function waitForSocketGone(socketPath, timeoutMs = 5_000) { return false; } +// The daemon keeps the cwd of whichever CLI spawned it, so a relative +// cookie path must be resolved here, against the caller's directory. +function callerRelativeCookieSource(options: UntrustedValue) { + const source = isRecord(options) ? untrustedField(options, "source") : undefined; + const sourcePath = isRecord(source) ? untrustedField(source, "path") : undefined; + if (!isRecord(options) || !isRecord(source) || !isString(sourcePath) || !sourcePath.trim() || path.isAbsolute(sourcePath.trim())) { + return options; + } + // Preserve invalid inputs for the same canonical rejection as direct calls. + if (sourcePath.length > 4_096 || Array.from(sourcePath).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return options; + return { ...options, source: { ...source, path: path.resolve(sourcePath.trim()) } }; +} + /** * Connect to (or start) the session daemon and complete the hello handshake. * @@ -376,7 +390,7 @@ export function createDaemonBrowser(channel, { session = "default" }: any = {}) syncCookies: (options?: any) => call( "syncCookies", - [options], + [callerRelativeCookieSource(options)], Math.min( Math.max(Number(options?.timeoutMs) || 30_000, 1_000) + 45_000, 180_000, diff --git a/src/pi-extension.ts b/src/pi-extension.ts index ace64985..0637e09a 100644 --- a/src/pi-extension.ts +++ b/src/pi-extension.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { providerResolutionPlans, resolveBrowserProvider } from "./browser-providers.js"; import { BetterWright } from "./client.js"; @@ -10,7 +11,7 @@ import { } from "./pi.js"; import { agentSystemPrompt } from "./prompt.js"; import { piBrowserToolParameters, piLoginToolParameters } from "./tool-schemas.js"; -import { isCallable, isNumber, isRecord, type UntrustedValue, untrustedField } from "./untrusted-value.js"; +import { isCallable, isNumber, isRecord, isString, type UntrustedValue, untrustedField } from "./untrusted-value.js"; const BROWSER_TOOL_NAMES = new Set([ "browser", @@ -45,16 +46,18 @@ export const PI_EVIDENCE_PARAMETERS = { properties: { operation: { type: "string", - enum: ["initialize", "prove", "audit"], + enum: ["initialize", "prove", "audit", "list", "get"], description: - "Initialize a task checklist, prove visible requirements, or audit what remains. A ready, audited checklist may be followed by another initialize.", + "Initialize, prove, or audit the current checklist. Use list for a paged journey index and get for full evidence by name; history reads never change state.", }, name: { type: "string", minLength: 1, description: - "For initialize: optional checklist or journey name. Omitted names receive a deterministic fallback.", + "For initialize: optional unique journey name. For get: required exact current or archived journey name.", }, + offset: { type: "integer", minimum: 0, description: "For list: zero-based offset, default 0." }, + limit: { type: "integer", minimum: 1, maximum: 100, description: "For list: page size, default 20, maximum 100." }, requirements: { type: "array", minItems: 1, @@ -457,14 +460,15 @@ export function createPiExtension(options: any = {}) { let currentChecklistAudited = false; let completionNudges = 0; const evidenceChecklist = new Map(); - const archivedChecklists: any[] = []; + const archivedChecklists = new Map(); + let proofDirectory: string | null = null; const withLogin = browser ? Boolean(browser.vault) : !Object.hasOwn(options.browserOptions || {}, "vault") || Boolean(options.browserOptions.vault); function currentChecklistState() { - const requirements = [...evidenceChecklist.values()]; + const requirements = structuredClone([...evidenceChecklist.values()]); const pending = requirements.filter((item) => item.status !== "proven"); return { name: currentChecklistName, @@ -483,30 +487,60 @@ export function createPiExtension(options: any = {}) { pending: currentChecklist.pending, requirements: currentChecklist.requirements, currentChecklist, - archivedChecklists, + archivedCount: archivedChecklists.size, }; } function nextChecklistName() { - const used = new Set([ - currentChecklistName, - ...archivedChecklists.map((checklist) => checklist.name), - ]); - let index = archivedChecklists.length + 1; - while (used.has(`checklist-${index}`)) index += 1; + let index = archivedChecklists.size + 1; + while (currentChecklistName === `checklist-${index}` || archivedChecklists.has(`checklist-${index}`)) index += 1; return `checklist-${index}`; } - function checklistResult(extra: any = {}) { - const state = checklistState(); + function stateResult(state) { return { - content: [ - { type: "text", text: JSON.stringify({ ...state, ...extra }, null, 2) }, - ], - details: state, + content: [{ type: "text", text: JSON.stringify(state, null, 2) }], + // Callers must not be able to mutate retained evidence via details. + details: structuredClone(state), }; } + function checklistResult(extra: any = {}) { + return stateResult({ ...checklistState(), ...extra }); + } + + async function retainProof(result, step) { + const artifact = result.artifacts?.findLast((item) => item.kind === "proof"); + const image = piPrimaryImageArtifact({ artifacts: artifact ? [artifact] : [] }); + if (!image) throw new Error("The proof screenshot is missing; requirements remain pending."); + const data = await fs.readFile(image.path); + if (!data.length || data.length > 2_500_000) { + throw new Error("The proof screenshot cannot be attached (empty or over 2500000 bytes); requirements remain pending."); + } + // Worker quotas may evict the original. These private session copies are + // outside the worker's artifact directory and live until session shutdown. + if (!proofDirectory) proofDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "betterwright-pi-proof-")); + const retained = path.join(proofDirectory, `step-${step}${path.extname(image.path)}`); + await fs.writeFile(retained, data, { mode: 0o600, flag: "wx" }); + const reference = { path: retained, originalPath: image.path, mimeType: image.mimeType }; + result.artifacts = result.artifacts.map((item) => item === artifact ? { ...item, ...reference, media: `MEDIA:${retained}` } : item); + return reference; + } + + async function readChecklist(checklist) { + const copy = structuredClone(checklist); + for (const item of copy.requirements) { + for (const artifact of item.proofArtifacts || []) { + try { + const file = await fs.open(artifact.path, "r"); + try { artifact.available = (await file.stat()).isFile(); } + finally { await file.close(); } + } catch { artifact.available = false; } + } + } + return copy; + } + async function getBrowser() { if (!browser) browser = new BetterWright({ ...resolvedBrowserOptions(options.browserOptions), ...visibilityOptions }); @@ -576,6 +610,7 @@ export function createPiExtension(options: any = {}) { item.evidence = null; item.proofStep = null; item.proofUrl = null; + item.proofArtifacts = []; } currentChecklistAudited = false; } @@ -621,11 +656,11 @@ export function createPiExtension(options: any = {}) { const setActiveTools = pi.setActiveTools; if (!isCallable(getActiveTools) || !isCallable(setActiveTools)) return; pi.setActiveTools( - pi.getActiveTools().filter((name) => !BROWSER_TOOL_NAMES.has(name)), + pi.getActiveTools().filter((name) => name === "browser_evidence" || !BROWSER_TOOL_NAMES.has(name)), ); } - async function executeBrowser(toolName, params, signal, approvedDownloads) { + async function executeBrowser(toolName, params, signal, approvedDownloads, afterRun?: (result: any, step: number) => Promise) { if (signal?.aborted) throw new Error("Browser call cancelled."); if (requireEvidence && !checklistInitialized) { throw new Error( @@ -646,7 +681,7 @@ export function createPiExtension(options: any = {}) { approvedDownloads, }); let observation = null; - if (autoScreenshot && piImageArtifacts(result).length === 0) { + if (toolName !== "browser_evidence" && autoScreenshot && piImageArtifacts(result).length === 0) { observation = await instance.run( 'const artifact = await screenshot({ kind: "debug", name: "pi-observation" }); return { artifact, url: page.url(), title: await page.title() }', { session, note: "Capturing the current browser state" }, @@ -661,6 +696,7 @@ export function createPiExtension(options: any = {}) { ]; pendingStartWarning = ""; } + if (afterRun) await afterRun(combined, step); let traceWarning = ""; try { await traceStep(traceDir, step, toolName, params, combined); @@ -699,7 +735,36 @@ export function createPiExtension(options: any = {}) { } async function executeEvidence(params, signal) { + if (signal?.aborted) throw new Error("Browser evidence call cancelled."); const operation = String(params.operation || ""); + if (operation === "list") { + const offset = params.offset ?? 0; + const limit = params.limit ?? 20; + if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new TypeError("list requires a non-negative integer offset and a limit from 1 to 100."); + } + const total = archivedChecklists.size + (checklistInitialized ? 1 : 0); + const checklists: any[] = []; + let index = 0; + // Iterate only as far as the requested page; do not serialize evidence. + for (const checklist of archivedChecklists.values()) { + if (index >= offset && checklists.length < limit) checklists.push({ name: checklist.name, current: false, ready: checklist.ready, audited: checklist.audited }); + index += 1; + if (checklists.length === limit) break; + } + if (checklistInitialized && checklists.length < limit && archivedChecklists.size >= offset) { + const current = currentChecklistState(); + checklists.push({ name: current.name, current: true, ready: current.ready, audited: current.audited }); + } + return stateResult({ checklists, total, archivedCount: archivedChecklists.size, offset, nextOffset: offset + checklists.length < total ? offset + checklists.length : null }); + } + if (operation === "get") { + if (!isString(params.name) || !params.name.trim()) throw new TypeError("get requires an exact checklist name."); + const name = params.name.trim(); + const checklist = name === currentChecklistName ? currentChecklistState() : archivedChecklists.get(name); + if (!checklist) throw new Error(`Unknown checklist name: ${name}`); + return stateResult({ checklist: await readChecklist(checklist), current: name === currentChecklistName }); + } if (operation === "initialize") { const existing = currentChecklistState(); if (checklistInitialized && !existing.ready) { @@ -723,7 +788,7 @@ export function createPiExtension(options: any = {}) { const name = requestedName || nextChecklistName(); if ( name === currentChecklistName || - archivedChecklists.some((checklist) => checklist.name === name) + archivedChecklists.has(name) ) { throw new Error(`Checklist name is already in use: ${name}`); } @@ -744,9 +809,10 @@ export function createPiExtension(options: any = {}) { evidence: null, proofStep: null, proofUrl: null, + proofArtifacts: [], }); } - if (checklistInitialized) archivedChecklists.push(existing); + if (checklistInitialized) archivedChecklists.set(existing.name, structuredClone(existing)); evidenceChecklist.clear(); for (const [id, requirement] of nextRequirements) { evidenceChecklist.set(id, requirement); @@ -794,7 +860,7 @@ export function createPiExtension(options: any = {}) { return { id, evidence }; }); const note = `PROOF ${proofs.map((proof) => `${proof.id}: ${proof.evidence}`).join(" | ")}`; - const result = await executeBrowser( + return executeBrowser( "browser_evidence", { code: @@ -803,23 +869,30 @@ export function createPiExtension(options: any = {}) { }, signal, false, + async (result, step) => { + if (!result.ok) return; + let artifact; + try { artifact = await retainProof(result, step); } + catch (error) { + result.ok = false; + result.error = `Proof screenshot retention failed: ${error?.message || error}`; + return; + } + const proofUrl = result.pages?.find((page) => page?.active)?.url || null; + for (const proof of proofs) { + evidenceChecklist.set(proof.id, { + ...evidenceChecklist.get(proof.id), + status: "proven", + evidence: proof.evidence, + proofStep: step, + proofUrl, + proofArtifacts: [{ ...artifact }], + }); + } + currentChecklistAudited = false; + result.proven = proofs.map((proof) => proof.id); + }, ); - if (result.details.ok) { - const proofUrl = result.details.pages.find((page) => page?.active)?.url || null; - for (const proof of proofs) { - evidenceChecklist.set(proof.id, { - ...evidenceChecklist.get(proof.id), - status: "proven", - evidence: proof.evidence, - proofStep: result.details.step, - proofUrl, - }); - } - } - const state = checklistState(); - result.content[0].text += `\n\nEvidence checklist after this proof:\n${JSON.stringify(state, null, 2)}`; - result.details.evidenceChecklist = state; - return result; } pi.registerTool({ @@ -885,7 +958,7 @@ export function createPiExtension(options: any = {}) { name: "browser_evidence", label: "BetterWright Evidence Checklist", description: - "Track named, sequential browser-task checklists and capture grounded proof screenshots. Initialize before browsing, prove only requirements visible on the current page, and audit while ready. After that, initialize may archive the completed checklist and start the next journey.", + "Track named, sequential browser-task checklists and capture grounded proof screenshots. Initialize before browsing, prove only requirements visible on the current page, and audit while ready. After that, initialize may archive the completed checklist and start the next journey. Default responses include only the current checklist and archivedCount; use list (offset/limit) and get (name) to read history without changing state.", promptSnippet: "Initialize named task checklists, capture requirement-linked proof frames, audit completion, and retain prior journeys", promptGuidelines: [ @@ -965,9 +1038,14 @@ export function createPiExtension(options: any = {}) { pi.on("session_shutdown", async () => { await operationTail; - if (browser && options.closeBrowserOnShutdown !== false) await browser.close(); - browser = null; - startPromise = null; + try { + if (browser && options.closeBrowserOnShutdown !== false) await browser.close(); + } finally { + if (proofDirectory) await fs.rm(proofDirectory, { recursive: true, force: true }); + proofDirectory = null; + browser = null; + startPromise = null; + } }); }; } diff --git a/tests/node/browser.test.ts b/tests/node/browser.test.ts index a9879829..b38e5355 100644 --- a/tests/node/browser.test.ts +++ b/tests/node/browser.test.ts @@ -4556,11 +4556,24 @@ test("Pi visibility restarts across all display modes while retaining the config await page.evaluate(() => { document.cookie = 'synthetic_login=retained; max-age=3600; path=/'; localStorage.setItem('fixture', 'retained'); }); await openPage(); return pages.length;` }); assert.equal(JSON.parse(saved.content[0].text).ok, true); + const evidence = params => tools.get("browser_evidence").execute("evidence", params); + await evidence({ operation: "initialize", name: "before-restart", requirements: [{ id: "fixture", description: "Show local fixture" }] }); + await tools.get("browser").execute("proof-fixture", { code: "await page.setContent('

local fixture

'); return page.title()" }); + assert.equal((await evidence({ operation: "prove", proofs: [{ id: "fixture", evidence: "The frame shows local fixture" }] })).details.ok, true); + await evidence({ operation: "audit" }); + await evidence({ operation: "initialize", name: "current", requirements: [{ id: "fixture", description: "Show local fixture" }] }); + assert.equal((await evidence({ operation: "prove", proofs: [{ id: "fixture", evidence: "The frame shows local fixture" }] })).details.ok, true); + await evidence({ operation: "audit" }); for (const mode of ["headless", "visible", "offscreen"]) { const changed = await tools.get("browser_visibility").execute("mode", { mode }); assert.equal(changed.details.ok, true, changed.details.error); assert.equal(changed.details.restarted, true); assert.equal(changed.details.tabsLost, true); + const currentEvidence = await evidence({ operation: "get", name: "current" }); + assert.equal(currentEvidence.details.checklist.audited, false); + assert.deepEqual(currentEvidence.details.checklist.pending, ["fixture"]); + assert.deepEqual(currentEvidence.details.checklist.requirements[0].proofArtifacts, []); + assert.equal((await evidence({ operation: "get", name: "before-restart" })).details.checklist.requirements[0].proofArtifacts[0].available, true); const checked = await tools.get("browser").execute("check", { code: `const count = pages.length; await page.goto(${JSON.stringify(server.origin)}); return { count, cookie: await page.evaluate(() => document.cookie), storage: await page.evaluate(() => localStorage.getItem('fixture')) };` }); @@ -5582,3 +5595,44 @@ test("provider chain: a failed release reports the session instead of starting a removeBrowserHome(home); } }); + +test("Pi evidence retains proof images through real worker quota eviction and reads archived journeys without changing the current one", opts, async () => { + const home = tempHome(); + const tools = new Map(); + const handlers = new Map(); + createPiExtension({ browser: new LimitedBetterWright({ home, vault: false, adBlock: false, headless: true }, { maxArtifactBytes: 30_000 }), autoScreenshot: false })({ + registerTool: tool => tools.set(tool.name, tool), + on: (event, handler) => handlers.set(event, handler), + }); + const evidence = params => tools.get("browser_evidence").execute("evidence", params); + const requirements = [{ id: "fixture", description: "Show Evidence fixture and Visible marker ALPHA" }]; + let retained: string | undefined; + try { + await evidence({ operation: "initialize", name: "first", requirements }); + const browsed = await tools.get("browser").execute("fixture", { code: "await page.setContent('

Evidence fixture

Visible marker ALPHA

'); return await page.locator('body').innerText()" }); + assert.equal(browsed.details.ok, true, browsed.details.error); + const proof = await evidence({ operation: "prove", proofs: [{ id: "fixture", evidence: "The frame shows Evidence fixture and Visible marker ALPHA" }] }); + assert.equal(proof.details.ok, true, proof.details.error); + assert.equal(proof.content.filter(block => block.type === "image").length, 1); + const artifact = proof.details.evidenceChecklist.requirements[0].proofArtifacts[0]; + retained = artifact.path; + assert.equal(fs.readFileSync(retained).subarray(1, 4).toString(), "PNG"); + await evidence({ operation: "audit" }); + await evidence({ operation: "initialize", name: "second", requirements }); + // Repeated real screenshots force eviction without any network download. + for (let index = 0; index < 12 && fs.existsSync(artifact.originalPath); index += 1) { + const evicted = await tools.get("browser").execute("evict", { code: "return screenshot({ kind: 'debug', name: 'quota' })" }); + assert.equal(evicted.details.ok, true, evicted.details.error); + } + assert.equal(fs.existsSync(artifact.originalPath), false); + const old = await evidence({ operation: "get", name: "first" }); + assert.equal(old.details.checklist.requirements[0].proofArtifacts[0].available, true); + assert.equal(old.details.checklist.audited, true); + assert.equal((await evidence({ operation: "get", name: "second" })).details.checklist.ready, false); + assert.equal(fs.existsSync(retained), true); + } finally { + await handlers.get("session_shutdown")(); + if (retained) assert.equal(fs.existsSync(retained), false); + removeBrowserHome(home); + } +}); diff --git a/tests/node/cli-startup.test.ts b/tests/node/cli-startup.test.ts index d128a103..44dbbef8 100644 --- a/tests/node/cli-startup.test.ts +++ b/tests/node/cli-startup.test.ts @@ -79,3 +79,41 @@ test("an absent or empty environment model still loads default model discovery", assert.match(result.stderr, /Unexpected setup module: .*doctor\.js/); } }); + +test("invalid launch environment errors name their own variable, not the profile source", () => { + for (const [name, value, profileArgs] of [ + ["BETTERWRIGHT_HEADLESS", "maybe", []], + ["BETTERWRIGHT_HEADLESS", "maybe", ["--profile", "work"]], + ["BETTERWRIGHT_DOWNLOAD_POLICY", "sometimes", []], + ] as const) { + const result = spawnSync(process.execPath, [cli, "run", ...profileArgs, "-c", "return 1"], { + cwd: root, + env: { ...process.env, BETTERWRIGHT_HOME: makeTempDir("betterwright-startup-home-"), BETTERWRIGHT_NO_DAEMON: "1", [name]: value }, + encoding: "utf8", + timeout: 15_000, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, new RegExp(`^${name} must be`, "m")); + assert.doesNotMatch(result.stderr, /BETTERWRIGHT_PROFILE:|--profile:/); + } + const profile = spawnSync(process.execPath, [cli, "run", "-c", "return 1"], { + cwd: root, + env: { ...process.env, BETTERWRIGHT_HOME: makeTempDir("betterwright-startup-home-"), BETTERWRIGHT_NO_DAEMON: "1", BETTERWRIGHT_PROFILE: "../escape" }, + encoding: "utf8", + timeout: 15_000, + }); + assert.equal(profile.status, 1); + assert.match(profile.stderr, /^BETTERWRIGHT_PROFILE: /m); +}); + +test("management commands remain available with invalid unrelated launch defaults", () => { + for (const args of [["sessions"], ["close"], ["skill", "--claude"], ["view", "--clear-password"]]) { + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: root, + env: { ...process.env, BETTERWRIGHT_HOME: makeTempDir("betterwright-startup-management-"), BETTERWRIGHT_HEADLESS: "invalid", BETTERWRIGHT_AD_BLOCK: "invalid", BETTERWRIGHT_DOWNLOAD_POLICY: "invalid" }, + encoding: "utf8", timeout: 15_000, + }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stderr, /BETTERWRIGHT_(HEADLESS|AD_BLOCK|DOWNLOAD_POLICY) must/); + } +}); diff --git a/tests/node/cookie-sync.test.ts b/tests/node/cookie-sync.test.ts index 523fabef..c42d4b9a 100644 --- a/tests/node/cookie-sync.test.ts +++ b/tests/node/cookie-sync.test.ts @@ -82,7 +82,10 @@ test("explicit Chromium sources read plaintext cookies outside the registry with } assert.throws(() => normalizeCookieSyncOptions({ source: { browser: "chrome", path: profile } }), /cannot be combined/); assert.throws(() => normalizeCookieSyncOptions({ source: { path: "\n" } }), /single-line/); - await assert.rejects(extractCookieSync(normalizeCookieSyncOptions({ source: { path: profile }, includeSession: true })), /includeSession/); + assert.throws( + () => normalizeCookieSyncOptions({ source: { path: profile }, includeSession: true }), + /includeSession is unavailable for explicit cookie paths/, + ); }); test("Cookie Sync normalizes source, domains, timeout, and secure defaults", () => { diff --git a/tests/node/daemon.test.ts b/tests/node/daemon.test.ts index 2971d578..3b83943a 100644 --- a/tests/node/daemon.test.ts +++ b/tests/node/daemon.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; import readline from "node:readline"; import test from "node:test"; import { @@ -355,6 +356,17 @@ test("Cookie Sync crosses the daemon socket as source options, not cookie plaint assert.equal(result.synced, 4); assert.deepEqual(browser.calls.at(-1), ["syncCookies", options]); assert.equal(JSON.stringify(browser.calls.at(-1)).includes('"cookies"'), false); + const relative = { source: { path: path.join("profiles", "Default") }, domains: ["example.com"] }; + assert.equal((await proxy.syncCookies(relative)).ok, true); + assert.deepEqual(browser.calls.at(-1), ["syncCookies", { + ...relative, + source: { path: path.resolve("profiles", "Default") }, + }]); + for (const path of ["profiles/Default\n", "profiles/Default\r", "\tprofiles/Default", "profiles/\0Default", "p".repeat(4097)]) { + const malformed = { source: { path }, domains: ["example.com"] }; + await proxy.syncCookies(malformed); + assert.deepEqual(browser.calls.at(-1), ["syncCookies", malformed]); + } await proxy.close(); } finally { await cleanup(); @@ -642,3 +654,30 @@ test("daemon stdin config survives multi-byte characters split across chunks", a // And malformed JSON falls back to defaults too. assert.deepEqual(await daemonConfigFromStdin(Readable.from([Buffer.from("{oops")])), {}); }); + +test("spawned CLI daemon uses resolved stdin overrides instead of revalidating inherited launch env", async () => { + const home = makeTempDir("bw-daemon-overrides-"); + const cliPath = path.resolve(import.meta.dirname, "../../dist/bin/betterwright.js"); + const invalid = { BETTERWRIGHT_HEADLESS: "invalid", BETTERWRIGHT_AD_BLOCK: "invalid", BETTERWRIGHT_PROFILE: "../escape", BETTERWRIGHT_DOWNLOAD_POLICY: "invalid" }; + const previous = Object.fromEntries(Object.keys(invalid).map(key => [key, process.env[key]])); + Object.assign(process.env, invalid); + let channel; + try { + const connected = await connectSessionDaemon({ home, cliPath, config: { headless: false, profile: "explicit", browser: { adBlock: false, downloadPolicy: "ask" } } }); + assert.equal(connected.ok, true, JSON.stringify(connected)); + channel = connected.channel; + assert.equal(connected.hello.profile, "explicit"); + const status = await channel.request({ op: "status" }); + assert.equal(status.ok, true); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (channel) { + const closed = new Promise(resolve => channel.socket.once("close", resolve)); + await channel.request({ op: "shutdown" }).catch(() => {}); + await closed; + } + } +}); diff --git a/tests/node/pi-evidence-history.test.ts b/tests/node/pi-evidence-history.test.ts new file mode 100644 index 00000000..3729380d --- /dev/null +++ b/tests/node/pi-evidence-history.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { createPiExtension } from "../../dist/src/pi-extension.js"; + +async function fixture(t, options = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "betterwright-history-")); + const screenshot = path.join(dir, "proof.png"); + await fs.writeFile(screenshot, "fixture image"); + const tools = new Map(); + const handlers = new Map(); + const browser = { + downloadPolicy: "allow", screenshot, fail: false, calls: 0, + async run(code) { + this.calls += 1; + return { ok: !this.fail, error: this.fail ? "synthetic screenshot failure" : undefined, + result: 42, warnings: ["keep this warning"], pages: [{ active: true, url: "https://example.test/fixture" }], + artifacts: code.includes("screenshot(") && this.screenshot ? [{ kind: "proof", path: this.screenshot }] : [] }; + }, + async close() {}, + }; + createPiExtension({ browser, autoScreenshot: false, ...options })({ + registerTool: tool => tools.set(tool.name, tool), + on: (name, handler) => handlers.set(name, handler), + }); + t.after(async () => { await handlers.get("session_shutdown")(); await fs.rm(dir, { recursive: true, force: true }); }); + const call = params => tools.get("browser_evidence").execute("evidence", params); + return { dir, tools, handlers, browser, call }; +} + +const requirements = [{ id: "fixture", description: "Show exactly 2 visible markers" }]; +const proofs = [{ id: "fixture", evidence: "The frame shows exactly 2 visible markers" }]; +const text = result => result.content.filter(block => block.type === "text").map(block => block.text).join("\n"); + +test("Pi defaults stay bounded across large evidence histories and history reads are paged and read-only", async t => { + const { call, tools, browser } = await fixture(t); + const sizes: number[][] = []; + for (let index = 0; index <= 120; index += 1) { + const initialized = await call({ operation: "initialize", name: `journey-${index}`, requirements }); + const browsed = await tools.get("browser").execute("browse", { code: "return 42" }); + const downloaded = await tools.get("browser_download").execute("download", { code: "return 42" }); + const proven = await call({ operation: "prove", proofs: [{ id: "fixture", evidence: `${proofs[0].evidence} ${"e".repeat(2048)}` }] }); + const audited = await call({ operation: "audit" }); + const results = [initialized, browsed, downloaded, proven, audited]; + sizes.push(results.map(result => Buffer.byteLength(text(result)))); + for (const result of results) assert.doesNotMatch(text(result), /archivedChecklists|journey--1/); + const envelope = JSON.parse(text(proven)); + assert.equal(envelope.pi.evidenceChecklist.ready, true); + assert.equal(envelope.pi.evidenceChecklist.currentChecklist.audited, false); + assert.equal(proven.content.filter(block => block.type === "image").length, 1); + assert.deepEqual(envelope.warnings, ["keep this warning"]); + assert.equal(audited.details.archivedCount, index); + } + for (let column = 0; column < sizes[0].length; column += 1) { + assert.ok(Math.abs(sizes.at(-1)[column] - sizes[0][column]) < 100, JSON.stringify(sizes)); + } + const before = await call({ operation: "get", name: "journey-120" }); + const calls = browser.calls; + const firstPage = await call({ operation: "list" }); + assert.equal(firstPage.details.checklists.length, 20); + assert.equal(firstPage.details.nextOffset, 20); + assert.equal(firstPage.details.total, 121); + assert.doesNotMatch(text(firstPage), /requirements|proofArtifacts|eeeeeeee/); + const lastPage = await call({ operation: "list", offset: 119, limit: 100 }); + assert.deepEqual(lastPage.details.checklists.map(item => item.name), ["journey-119", "journey-120"]); + assert.equal(lastPage.details.checklists.at(-1).current, true); + assert.equal(lastPage.details.nextOffset, null); + assert.deepEqual((await call({ operation: "list", offset: 121 })).details.checklists, []); + const old = await call({ operation: "get", name: "journey-0" }); + assert.ok(old.details.checklist.requirements[0].evidence.length > 2048); + assert.equal(old.details.checklist.audited, true); + old.details.checklist.requirements[0].evidence = "external mutation"; + assert.notEqual((await call({ operation: "get", name: "journey-0" })).details.checklist.requirements[0].evidence, "external mutation"); + await assert.rejects(call({ operation: "get", name: "unknown" }), /Unknown checklist/); + await assert.rejects(call({ operation: "get" }), /exact checklist name/); + for (const page of [{ offset: -1 }, { offset: 0.5 }, { limit: 0 }, { limit: 101 }]) { + await assert.rejects(call({ operation: "list", ...page }), /offset.*limit/); + } + await assert.rejects(call({ operation: "initialize", name: "journey-0", requirements }), /already in use/); + assert.deepEqual(await call({ operation: "get", name: "journey-120" }), before); + assert.equal(browser.calls, calls); +}); + +test("Pi retains original proof links after worker eviction, reports missing copies, and removes session copies at shutdown", async t => { + const { call, browser, handlers } = await fixture(t); + await call({ operation: "initialize", name: "first", requirements }); + const proven = await call({ operation: "prove", proofs }); + const artifact = proven.details.evidenceChecklist.requirements[0].proofArtifacts[0]; + assert.equal(artifact.originalPath, browser.screenshot); + assert.notEqual(artifact.path, artifact.originalPath); + assert.equal(await fs.readFile(artifact.path, "utf8"), "fixture image"); + if (process.platform !== "win32") assert.equal((await fs.stat(artifact.path)).mode & 0o777, 0o600); + await call({ operation: "audit" }); + await call({ operation: "initialize", name: "second", requirements }); + await fs.rm(browser.screenshot); + assert.equal((await call({ operation: "get", name: "first" })).details.checklist.requirements[0].proofArtifacts[0].available, true); + if (process.platform !== "win32" && process.getuid?.() !== 0) { + await fs.chmod(artifact.path, 0o000); + await assert.rejects(fs.readFile(artifact.path), /EACCES/); + assert.equal((await call({ operation: "get", name: "first" })).details.checklist.requirements[0].proofArtifacts[0].available, false); + await fs.chmod(artifact.path, 0o600); + } + await fs.rm(artifact.path); + assert.equal((await call({ operation: "get", name: "first" })).details.checklist.requirements[0].proofArtifacts[0].available, false); + const directory = path.dirname(artifact.path); + await handlers.get("session_shutdown")(); + await assert.rejects(fs.stat(directory), /ENOENT/); +}); + +test("Pi rejected, missing, oversized and failed screenshot proofs remain pending and unaudited", async t => { + const { call, browser, dir } = await fixture(t); + await call({ operation: "initialize", name: "pending", requirements }); + await assert.rejects(call({ operation: "prove", proofs: [{ id: "fixture", evidence: "The marker failed" }] }), /proof rejected/); + for (const kind of ["failed", "missing", "oversized", "unreadable"]) { + browser.fail = kind === "failed"; + browser.screenshot = kind === "missing" ? "" : path.join(dir, `${kind}.png`); + if (kind === "oversized") await fs.writeFile(browser.screenshot, Buffer.alloc(2_500_001)); + const result = await call({ operation: "prove", proofs }); + assert.equal(result.details.ok, false, kind); + assert.match(result.details.error, /screenshot/i); + const audited = await call({ operation: "audit" }); + assert.equal(audited.details.ready, false); + assert.equal(audited.details.currentChecklist.audited, false); + assert.deepEqual(audited.details.pending, ["fixture"]); + } +}); + +test("Pi history and audit remain available after the browser budget, and new proof invalidates audit", async t => { + const { call, tools } = await fixture(t, { maxSteps: 2 }); + assert.deepEqual((await call({ operation: "list" })).details.checklists, []); + await call({ operation: "initialize", name: "budget", requirements }); + await call({ operation: "prove", proofs }); + assert.equal((await call({ operation: "audit" })).details.currentChecklist.audited, true); + const again = await call({ operation: "prove", proofs }); + assert.equal(again.details.evidenceChecklist.currentChecklist.audited, false); + assert.equal((await call({ operation: "audit" })).details.currentChecklist.audited, true); + assert.equal((await call({ operation: "get", name: "budget" })).details.checklist.ready, true); + await assert.rejects(tools.get("browser").execute("exhausted", { code: "return 42" }), /budget/); +}); diff --git a/tests/node/pi-extension.test.ts b/tests/node/pi-extension.test.ts index 022cba9f..bea9d513 100644 --- a/tests/node/pi-extension.test.ts +++ b/tests/node/pi-extension.test.ts @@ -137,7 +137,7 @@ class FakeBrowser { }, pages: [{ url, active: true }], artifacts: this.screenshot - ? [{ kind: "debug", path: this.screenshot }] + ? [{ kind: code.includes('name: "pi-evidence"') ? "proof" : "debug", path: this.screenshot }] : [], }; } @@ -272,7 +272,7 @@ test("native Pi extension grounds required checklist items in proof frames", asy ], }); assert.equal(proved.details.evidenceChecklist.ready, true); - assert.match(proved.content[0].text, /"status": "proven"/); + assert.match(proved.content[0].text, /"status":"proven"/); const audited = await pi.tools.get("browser_evidence").execute("call-5", { operation: "audit", @@ -285,6 +285,7 @@ test("native Pi extension grounds required checklist items in proof frames", asy .split("\n") .map((line) => JSON.parse(line)); assert.equal(rows.at(-1).action, "browser_evidence"); + await pi.handlers.get("session_shutdown")(); assert.match(rows.at(-1).response, /^PROOF filter:/); } finally { await fs.rm(dir, { recursive: true, force: true }); @@ -292,8 +293,10 @@ test("native Pi extension grounds required checklist items in proof frames", asy }); -test("native Pi extension retains sequential named evidence checklists", async () => { +test("native Pi extension retains sequential named evidence checklists", async (t) => { + const { dir, screenshot } = await fixture(); const browser = new FakeBrowser({ + screenshot, proofUrls: [ "https://example.com/search?journey=first", "https://example.com/cart?journey=second", @@ -302,6 +305,7 @@ test("native Pi extension retains sequential named evidence checklists", async ( const pi = new FakePi(); createPiExtension({ browser, requireEvidence: true, autoScreenshot: false })(pi); const evidence = pi.tools.get("browser_evidence"); + t.after(async () => { await pi.handlers.get("session_shutdown")(); await fs.rm(dir, { recursive: true, force: true }); }); const first = await evidence.execute("call-1", { operation: "initialize", @@ -322,7 +326,7 @@ test("native Pi extension retains sequential named evidence checklists", async ( requirements: [{ id: "cart", description: "Add the result to the cart" }], }); assert.equal(second.details.currentChecklist.name, "add-to-cart"); - assert.equal(second.details.archivedChecklists.length, 1); + assert.equal(second.details.archivedCount, 1); await evidence.execute("call-5", { operation: "prove", proofs: [{ id: "cart", evidence: "The cart shows the requested result" }], @@ -332,8 +336,8 @@ test("native Pi extension retains sequential named evidence checklists", async ( assert.equal(finalAudit.details.initialized, true); assert.equal(finalAudit.details.ready, true); assert.deepEqual(finalAudit.details.pending, []); - assert.equal(finalAudit.details.archivedChecklists.length, 1); - const archived = finalAudit.details.archivedChecklists[0]; + assert.equal(finalAudit.details.archivedCount, 1); + const archived = (await evidence.execute("history", { operation: "get", name: "find-product" })).details.checklist; assert.equal(archived.name, "find-product"); assert.equal(archived.ready, true); assert.equal(archived.audited, true); @@ -344,6 +348,7 @@ test("native Pi extension retains sequential named evidence checklists", async ( evidence: "The first result is visible", proofStep: 1, proofUrl: "https://example.com/search?journey=first", + proofArtifacts: archived.requirements[0].proofArtifacts, }); assert.equal(finalAudit.details.currentChecklist.name, "add-to-cart"); assert.equal(finalAudit.details.currentChecklist.audited, true); @@ -354,14 +359,17 @@ test("native Pi extension retains sequential named evidence checklists", async ( evidence: "The cart shows the requested result", proofStep: 2, proofUrl: "https://example.com/cart?journey=second", + proofArtifacts: finalAudit.details.requirements[0].proofArtifacts, }); }); -test("native Pi extension rejects replacing incomplete or unaudited checklists", async () => { - const browser = new FakeBrowser(); +test("native Pi extension rejects replacing incomplete or unaudited checklists", async (t) => { + const { dir, screenshot } = await fixture(); + const browser = new FakeBrowser({ screenshot }); const pi = new FakePi(); createPiExtension({ browser, requireEvidence: true, autoScreenshot: false })(pi); const evidence = pi.tools.get("browser_evidence"); + t.after(async () => { await pi.handlers.get("session_shutdown")(); await fs.rm(dir, { recursive: true, force: true }); }); const requirements = [{ id: "result", description: "Show the requested result" }]; const initialized = await evidence.execute("call-1", { @@ -447,7 +455,7 @@ test("native Pi extension enforces its browser step budget", async () => { const tool = pi.tools.get("browser"); const first = await tool.execute("call-1", { code: "return 1" }); assert.equal(first.details.budgetExhausted, true); - assert.deepEqual(pi.activeTools, ["read"]); + assert.deepEqual(pi.activeTools, ["read", "browser_evidence"]); await assert.rejects( tool.execute("call-2", { code: "return 2" }), /step budget \(1\) is exhausted/, diff --git a/types/pi-extension.d.ts b/types/pi-extension.d.ts index f19b96d1..e39e1945 100644 --- a/types/pi-extension.d.ts +++ b/types/pi-extension.d.ts @@ -80,7 +80,39 @@ export interface PiExtensionApiLike { /** A Pi extension entrypoint: registers the tools and lifecycle handlers. */ export type PiExtension = (pi: PiExtensionApiLike) => void; -/** JSON Schemas for the registered tools, exported for tests and audits. */ +/** A retained proof image, scoped to the lifetime of one extension session. */ +export interface PiEvidenceArtifact { + path: string; + originalPath: string; + mimeType: string; + /** Set by get after checking the retained copy on disk. */ + available?: boolean; +} + +export interface PiEvidenceRequirement { + id: string; + description: string; + status: "pending" | "proven"; + evidence: string | null; + proofStep: number | null; + proofUrl: string | null; + proofArtifacts: PiEvidenceArtifact[]; +} + +/** Full current or archived journey returned by browser_evidence get. */ +export interface PiEvidenceChecklist { + name: string | null; + audited: boolean; + ready: boolean; + pending: string[]; + requirements: PiEvidenceRequirement[]; +} + +/** JSON Schemas for the registered tools, exported for tests and audits. + * Evidence operations: initialize, prove, audit, list (offset/limit <= 100), + * and get (exact name). Default responses use archivedCount; list/get read + * retained history without modifying active state. See SETUP.md. + */ export const PI_BROWSER_PARAMETERS: Readonly; export const PI_LOGIN_PARAMETERS: Readonly; export const PI_EVIDENCE_PARAMETERS: Readonly;