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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,27 @@ Releases before 1.1.3 predate this file; their notes live on the

## [Unreleased]

## [2.8.9] - 2026-10-12

### Added

- Cookie Sync accepts `source: { path }` and CLI `--source-path` for cookie
databases or Chromium profile directories outside the reader registry.
Domain filtering, source preservation, secure transfer limits, and target
verification remain in effect. (#230)
- Pi exposes `browser_visibility` and `/browser-visibility` for visible,
headless, and off-screen headed sessions. Mode changes report a restart and
lost tabs while preserving the configured profile's cookies and storage. (#231)
- Every host reads validated launch defaults from the home `config.json`:
display mode, profile, ad blocking, locale, timezone, and download policy.
Explicit options override environment variables, which override saved
defaults. CLI daemon signatures include effective launch settings. (#232)

### Changed

- Update the SHA-pinned `actions/upload-artifact` to 7.0.2 and
`actions/setup-node` to 7.1.0. (#233, #234)

- The browser worker delegates realm handling, live view, artifacts and
recording, snapshots, trusted input, site tools, and CAPTCHA operations to
import-safe modules with explicit dependencies. Launch, execution queues,
Expand Down Expand Up @@ -1833,7 +1852,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.8...HEAD
[Unreleased]: https://github.com/BetterWright/betterwright/compare/v2.8.9...HEAD
[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
[2.8.6]: https://github.com/BetterWright/betterwright/compare/v2.8.5...v2.8.6
Expand Down
14 changes: 14 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ manifest. Pi therefore loads `browser` and `browser_download` directly, keeps
one BetterWright session alive, attaches screenshots as vision content, and
adds the BetterWright operator guidance to the system prompt.

The `browser_visibility` tool accepts `{ "mode": "visible" }`,
`{ "mode": "headless" }`, or `{ "mode": "offscreen" }`. Users can also run
`/browser-visibility visible`, `/browser-visibility headless`, or
`/browser-visibility offscreen` in Pi. Changing mode restarts the browser and
the result reports `restarted: true` and `tabsLost: true`: open tabs and
in-memory page state are lost, while persistent cookies, storage, and logins
remain. Selecting the current mode keeps tabs. Current evidence proofs are
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.

Pi also reads the shared `launch` defaults from BetterWright's home
`config.json`; see [saved launch defaults](docs/environment.md#saved-launch-defaults).

For a published package:

```bash
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.8
generated_by: betterwright@2.8.9
---

# BetterWright browser
Expand Down
70 changes: 48 additions & 22 deletions bin/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
execTask,
interruptSession,
} from "../src/daemon-client.js";
import { launchOptionsFromEnv, loadLaunchConfig } from "../src/launch-config.js";
import { defaultLiveViewListen, guessLanHost } from "../src/live-view.js";
import { profileLabel, resolveProfileName } from "../src/profile-name.js";
// `agentSystemPrompt` comes from the light prompt module, not index.js, which
Expand Down Expand Up @@ -255,13 +256,14 @@ async function policyFromFlags(flags) {
}

/** Construct a local (non-daemon) BetterWright, loading the stack on demand. */
async function makeBrowser(flags, { headless }: any = {}) {
async function makeBrowser(flags, { headless, headedInvisible }: any = {}) {
const { BetterWright, NetworkPolicy } = await browserModule();
const options: BetterWrightOptions = {
policy: new NetworkPolicy(policyOptionsFromFlags(flags)),
headless: headless ?? !flags.has("--headed"),
...browserOptionsFromFlags(flags),
headless: headless ?? cliHeadless(flags),
};
if (headedInvisible !== undefined) options.headedInvisible = headedInvisible;
const profile = cliProfile();
if (profile) options.profile = profile;
return new BetterWright(options);
Expand All @@ -285,9 +287,10 @@ function cliProfile(): string | null {
// and then BETTERWRIGHT_PROFILE, the same variable the MCP server reads,
// so a host that runs both surfaces acts as one identity in both (and
// `betterwright view` attaches to the browser its MCP server is driving).
const fromEnv = String(process.env.BETTERWRIGHT_PROFILE || "").trim() || undefined;
cliProfileCache = resolveProfileName(
raw === undefined || raw === null ? fromEnv : raw,
raw === undefined || raw === null
? String(process.env.BETTERWRIGHT_PROFILE || "").trim() || loadLaunchConfig().profile
: raw,
);
}
return cliProfileCache;
Expand All @@ -298,15 +301,29 @@ function cliProfile(): string | null {
// --upstream-proxy chains an egress proxy through the policy guard (the IP
// layer); --geoip aligns locale and timezone with the egress IP unless
// --locale/--timezone pin them explicitly.
function browserOptionsFromFlags(flags, argv = process.argv) {
function launchOverridesFromFlags(flags, argv = process.argv) {
return {
headless: flags.has("--headed") || flags.has("--headed-invisible") ? false : undefined,
headedInvisible: flags.has("--headed-invisible") ? true : flags.has("--headed") ? false : undefined,
adBlock: flags.has("--no-ad-block") ? false : flags.has("--ad-block") ? true : undefined,
profile: flagValue(argv, "--profile") ?? undefined,
locale: flagValue(argv, "--locale") ?? undefined,
timezone: flagValue(argv, "--timezone") ?? undefined,
};
}

function browserOptionsFromFlags(flags, argv = process.argv, home = defaultDaemonHome(), env = process.env) {
const launch = launchOptionsFromEnv(home, env, launchOverridesFromFlags(flags, argv));
const options: any = {
adBlock: adBlockFromFlags(flags),
...launch,
adBlock: flags.has("--ad-block") || flags.has("--no-ad-block")
? adBlockFromFlags(flags, env) : launch.adBlock,
launchIdentity: !flags.has("--no-launch-identity"),
upstreamProxy: flagValue(argv, "--upstream-proxy") || undefined,
geoip: flags.has("--geoip"),
locale: flagValue(argv, "--locale") || undefined,
timezone: flagValue(argv, "--timezone") || undefined,
headedInvisible: flags.has("--headed-invisible"),
locale: flagValue(argv, "--locale") || launch.locale,
timezone: flagValue(argv, "--timezone") || launch.timezone,
headedInvisible: flags.has("--headed-invisible") || (!flags.has("--headed") && launch.headedInvisible === true),
platform: flagValue(argv, "--platform") || undefined,
stealthRuntimeFix: flags.has("--stealth") || undefined,
};
Expand All @@ -315,6 +332,11 @@ function browserOptionsFromFlags(flags, argv = process.argv) {
return options;
}

function cliHeadless(flags, home = defaultDaemonHome(), env = process.env, argv = process.argv): boolean | "auto" {
if (flags.has("--headed") || flags.has("--headed-invisible")) return false;
return launchOptionsFromEnv(home, env, launchOverridesFromFlags(flags, argv)).headless ?? true;
}

// --browser / --browser-key / BETTERWRIGHT_CDP_URL: the escape hatch from the
// managed BetterChromium fork. A flag value that parses as a URL is treated
// as a CDP endpoint; anything else names a cloud provider whose key comes
Expand Down Expand Up @@ -548,7 +570,7 @@ export function daemonConfigFromFlags(
flags,
{ argv = process.argv, home = defaultDaemonHome(), env = process.env }: any = {},
) {
const browser = browserOptionsFromFlags(flags, argv);
const browser = browserOptionsFromFlags(flags, argv, home, env);
if (browser.provider === undefined) {
// The daemon's BetterWright resolves this same ladder at construction —
// env shorthand, then the persisted default and its fallbacks. Baking the
Expand All @@ -573,10 +595,10 @@ export function daemonConfigFromFlags(
browser.provider = expandProviderChoice(browser.provider, { home, env });
}
return {
headless: !flags.has("--headed"),
headless: cliHeadless(flags, home, env, argv),
// Selects which daemon to talk to (one per profile per home), not merely
// how it is configured — see connectSessionDaemon.
profile: cliProfile(),
profile: resolveProfileName(flagValue(argv, "--profile") ?? browser.profile),
policy: {
allowLoopback: !flags.has("--block-loopback"),
allowPrivateNetwork: !flags.has("--block-private-network"),
Expand Down Expand Up @@ -718,8 +740,9 @@ async function cmdCookies(tokens, flags) {
}
return 0;
}
if (action !== "sync" || !browser || extra.length) {
return printFailure("Usage: betterwright cookies sync <browser> (--domain <host>... | --all)");
const sourcePath = cookieFlagValue(tokens, "--source-path");
if (action !== "sync" || Boolean(browser) === Boolean(sourcePath) || extra.length) {
return printFailure("Usage: betterwright cookies sync (<browser> | --source-path <path>) (--domain <host>... | --all)");
}

const domains = cookieFlagValues(tokens, "--domain");
Expand All @@ -729,7 +752,8 @@ async function cmdCookies(tokens, flags) {
}
const sourceProfile = cookieFlagValue(tokens, "--source-profile");
const cloudConsent = cookieFlagValue(tokens, "--allow-cloud");
const source = { browser, profile: sourceProfile || undefined };
if (sourcePath && sourceProfile) return printFailure("--source-path cannot be combined with --source-profile.");
const source = sourcePath ? { path: sourcePath } : { browser, profile: sourceProfile || undefined };
const options: any = {
source,
includeSession: flags.has("--include-session"),
Expand Down Expand Up @@ -765,7 +789,7 @@ async function cmdCookies(tokens, flags) {
if (json) console.log(JSON.stringify(printable, null, 2));
else if (result.ok) {
console.log(
`Synced ${result.synced} cookie${result.synced === 1 ? "" : "s"} from ${browser} into ${result.target}.`,
`Synced ${result.synced} cookie${result.synced === 1 ? "" : "s"} from ${browser || "the selected path"} into ${result.target}.`,
);
if (result.skipped) console.log(`Skipped ${result.skipped} cookie rows.`);
for (const warning of result.warnings || []) {
Expand Down Expand Up @@ -1137,9 +1161,10 @@ async function cmdInteractive(flags) {
const session = flagValue(argv, "--session", "default");
// Mutable so `/headed` and `/headless` can switch it (each recreates the
// browser, since headless is fixed at construction).
let headless = !flags.has("--headed");
let headless = cliHeadless(flags);
let headedInvisible = browserOptionsFromFlags(flags).headedInvisible;

const newBrowser = () => makeBrowser(flags, { headless });
const newBrowser = () => makeBrowser(flags, { headless, headedInvisible });
// Mutable: process --live-view starts a viewer at boot; `/live` enables the
// same path mid-session so /new /headed /headless re-open it after replace.
let interactiveLiveView = liveViewCliOptions(argv);
Expand Down Expand Up @@ -1401,14 +1426,15 @@ async function cmdInteractive(flags) {
}
if (cmd === "headed" || cmd === "headless") {
const wantHeadless = cmd === "headless";
if (wantHeadless === headless) {
if (wantHeadless === headless && !headedInvisible) {
console.log(dim(`already ${headless ? "headless" : "headed"}`));
continue;
}
// Headless is fixed at construction, so recreate the browser. The
// on-disk profile (logins/cookies) and the conversation carry over;
// open tabs do not.
headless = wantHeadless;
headedInvisible = false;
await browsers.replace();
console.log(dim(`switched to ${headless ? "headless" : "headed"} (fresh browser; you stay signed in)`));
continue;
Expand Down Expand Up @@ -1737,8 +1763,8 @@ async function cmdExec(flags) {
session,
history,
policy,
headless: !flags.has("--headed"),
adBlock: adBlockFromFlags(flags),
...browserOptionsFromFlags(flags),
headless: cliHeadless(flags),
Comment thread
SSHdotCodes marked this conversation as resolved.
liveView: liveViewCliOptions(argv),
onStep,
onPhase: (event) => spinner.setLabel(phaseLabel(event)),
Expand Down Expand Up @@ -2394,7 +2420,7 @@ export async function runCli() {
const { runMcpServer } = await import("../src/mcp-server.js");
await runMcpServer({
...process.env,
BETTERWRIGHT_AD_BLOCK: adBlockFromFlags(flags) ? "1" : "0",
BETTERWRIGHT_AD_BLOCK: (browserOptionsFromFlags(flags).adBlock ?? true) ? "1" : "0",
});
return 0;
}
Expand Down
19 changes: 19 additions & 0 deletions docs/cookie-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ domains whose cookies apply to that host. Repeat it for more sites. Use
The CLI requires one of these scopes so an omitted flag cannot copy every
site by accident.

For a Chromium-family browser outside the reader's registry, pass its exact
profile directory or cookie database file instead of a browser id:

```bash
betterwright cookies sync --source-path "$HOME/.config/net.imput.helium/Default" \
--domain github.com --profile work
```

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
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
local filename.

Persistent cookies are included by default. `--include-session` also reads
Firefox-family session stores. Chromium-family session cookies that exist
only in a running browser process cannot be recovered from its profile, so
Expand Down
43 changes: 39 additions & 4 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,47 @@ A handful of `BETTERWRIGHT_*` names exist only for the build and test harness
overrides used by development and CI. They are not supported configuration and
are not listed below.

## Saved launch defaults

Every host (SDK, CLI, MCP, and Pi) reads the `launch` section of
`$BETTERWRIGHT_HOME/config.json` when constructing a browser. For example:

```json
{
"launch": {
"headless": true,
"profile": "research",
"adBlock": true,
"locale": "en-US",
"timezone": "America/New_York",
"downloadPolicy": "ask"
}
}
```

The allowed keys are `headless` (boolean or `"auto"`), `headedInvisible`
(boolean), `profile` (valid profile name or `null` for the shared profile),
`adBlock` (boolean), `locale` (BCP 47), `timezone` (IANA), and
`downloadPolicy` (`"ask"`, `"allow"`, or `"deny"`). Unknown and invalid saved
values are ignored. Other sections, such as `browser` and `liveView`, keep
their existing meaning. Network policy, provider credentials, and arbitrary
constructor options are not accepted in `launch`.

Explicit constructor options and CLI flags override environment variables;
environment variables override saved defaults. Omitting a setting preserves
the host's existing default: the CLI starts headless, and the SDK, MCP, and Pi
use automatic display detection. `headedInvisible: true` forces a headed
window parked off-screen; an explicit `headless` option or
`BETTERWRIGHT_HEADLESS` cancels an inherited off-screen setting. Changes apply
to newly constructed browsers. Close a running CLI daemon before applying
changed launch defaults.

## Home, sessions, and the daemon

| Variable | Default | Effect |
| --- | --- | --- |
| `BETTERWRIGHT_HOME` | `~/.betterwright` | State directory: profiles, vault, artifacts, daemon sockets, `config.json` |
| `BETTERWRIGHT_PROFILE` | unset | Select a named identity for a whole shell; `--profile` wins. The only way to pick a profile for the MCP server |
| `BETTERWRIGHT_PROFILE` | unset | Select a named identity across all hosts; an explicit `profile` option or `--profile` wins |
| `BETTERWRIGHT_SESSION_TTL_SECONDS` | `900` | Idle time before a session's pages close |
| `BETTERWRIGHT_ORPHAN_GRACE_SECONDS` | `30` | Grace period before the daemon stops a run whose watcher disappeared; `0` lets detached runs finish |
| `BETTERWRIGHT_NO_DAEMON` | unset | `1` forces one-shot browsers, skipping the session daemon entirely |
Expand All @@ -43,10 +78,10 @@ fallback browser that no longer exists. See

| Variable | Default | Effect |
| --- | --- | --- |
| `BETTERWRIGHT_HEADLESS` | unset | MCP only: `0` requests a visible window, `1` forces headless |
| `BETTERWRIGHT_HEADLESS` | unset | Across all hosts: `0` requests a visible window, `1` forces headless, `auto` uses display detection |
| `BETTERWRIGHT_DISPLAY` | detected | `1` forces headed-capable detection, `0` forces headless |
| `BETTERWRIGHT_LOCALE` | unset | Browser locale (BCP 47) for the MCP server; the CLI uses `--locale` |
| `BETTERWRIGHT_TIMEZONE` | unset | Browser timezone (IANA) for the MCP server; the CLI uses `--timezone` |
| `BETTERWRIGHT_LOCALE` | unset | Browser locale (BCP 47) across all hosts; `locale` or `--locale` wins |
| `BETTERWRIGHT_TIMEZONE` | unset | Browser timezone (IANA) across all hosts; `timezone` or `--timezone` wins |

Identity must match egress geography; see
[launch-identity.md](launch-identity.md) and
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.8",
"version": "2.8.9",
"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
7 changes: 6 additions & 1 deletion src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,11 +725,16 @@ export async function runAgentTask(options: RunAgentTaskOptions) {
let browser = options.browser;
if (!browser) {
const ownedBrowserOptions: BetterWrightOptions = {
home: options.home,
policy: options.policy || new NetworkPolicy(),
headless: options.headless,
headedInvisible: options.headedInvisible,
adBlock: options.adBlock,
locale: options.locale,
timezone: options.timezone,
downloadPolicy: options.downloadPolicy,
};
if (options.profile) ownedBrowserOptions.profile = options.profile;
if (options.profile !== undefined) ownedBrowserOptions.profile = options.profile;
// `vault: undefined` and an absent vault mean different things to the
// constructor (explicit override vs built-in default), so mirror the key's
// presence, not just its value.
Expand Down
1 change: 1 addition & 0 deletions src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const VALUE_FLAGS = new Set([
"--session-id",
"--status",
"--source-profile",
"--source-path",
"--key-env",
"--add",
"--remove",
Expand Down
Loading
Loading