Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .audit/pi-evidence-2.9.0.tsv
Original file line number Diff line number Diff line change
@@ -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
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 16 additions & 7 deletions bin/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions docs/cookie-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
45 changes: 45 additions & 0 deletions research/pi-evidence-history-probe.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>();
const handlers = new Map<string, any>();
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('<body style="background:white;color:black;font:24px sans-serif;padding:40px"><h1>Evidence fixture</h1><p>Visible marker ALPHA</p></body>');
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 }); }
}
3 changes: 3 additions & 0 deletions src/cookie-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 16 additions & 2 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading