From 1c468b57ae5a0013c31dfac7fadaecf34ae29cb6 Mon Sep 17 00:00:00 2001 From: SSHdotCodes <254190073+SSHdotCodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:01:31 -0400 Subject: [PATCH 1/4] feat: release 2.8.9 with cookie paths, Pi visibility, and launch defaults --- CHANGELOG.md | 22 +++- SETUP.md | 14 +++ SKILL.md | 2 +- bin/cli-main.ts | 56 ++++++---- docs/cookie-sync.md | 19 ++++ docs/environment.md | 43 +++++++- package.json | 2 +- src/cli-flags.ts | 1 + src/cli-help.ts | 3 + src/client.ts | 2 + src/cookie-sync.ts | 70 +++++++++--- src/daemon.ts | 15 ++- src/launch-config.ts | 82 ++++++++++++++ src/mcp-server.ts | 9 +- src/pi-extension.ts | 180 +++++++++++++++++++++++-------- src/worker.ts | 14 ++- tests/node/browser.test.ts | 76 ++++++++++++- tests/node/cookie-sync.test.ts | 35 +++++- tests/node/launch-config.test.ts | 86 +++++++++++++++ tests/node/pi-extension.test.ts | 26 +++++ tests/types/package.test.ts | 5 +- types/pi-extension.d.ts | 7 +- types/public.d.ts | 13 ++- 23 files changed, 672 insertions(+), 110 deletions(-) create mode 100644 src/launch-config.ts create mode 100644 tests/node/launch-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d852d989..82cd9b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, @@ -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 diff --git a/SETUP.md b/SETUP.md index 86c7f433..22c8ec80 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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 diff --git a/SKILL.md b/SKILL.md index d0e1081d..ee62cf74 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.8 +generated_by: betterwright@2.8.9 --- # BetterWright browser diff --git a/bin/cli-main.ts b/bin/cli-main.ts index ce2984f2..fb04514e 100644 --- a/bin/cli-main.ts +++ b/bin/cli-main.ts @@ -83,6 +83,7 @@ import { execTask, interruptSession, } from "../src/daemon-client.js"; +import { launchOptionsFromEnv } 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 @@ -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); @@ -285,7 +287,7 @@ 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; + const fromEnv = launchOptionsFromEnv().profile; cliProfileCache = resolveProfileName( raw === undefined || raw === null ? fromEnv : raw, ); @@ -298,15 +300,18 @@ 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 browserOptionsFromFlags(flags, argv = process.argv, home = defaultDaemonHome(), env = process.env) { + const launch = launchOptionsFromEnv(home, env); 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, }; @@ -315,6 +320,11 @@ function browserOptionsFromFlags(flags, argv = process.argv) { return options; } +function cliHeadless(flags, home = defaultDaemonHome(), env = process.env): boolean | "auto" { + if (flags.has("--headed") || flags.has("--headed-invisible")) return false; + return launchOptionsFromEnv(home, env).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 @@ -548,7 +558,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 @@ -573,10 +583,10 @@ export function daemonConfigFromFlags( browser.provider = expandProviderChoice(browser.provider, { home, env }); } return { - headless: !flags.has("--headed"), + headless: cliHeadless(flags, home, env), // Selects which daemon to talk to (one per profile per home), not merely // how it is configured — see connectSessionDaemon. - profile: cliProfile(), + profile: cliProfile() ?? launchOptionsFromEnv(home, env).profile ?? null, policy: { allowLoopback: !flags.has("--block-loopback"), allowPrivateNetwork: !flags.has("--block-private-network"), @@ -718,8 +728,9 @@ async function cmdCookies(tokens, flags) { } return 0; } - if (action !== "sync" || !browser || extra.length) { - return printFailure("Usage: betterwright cookies sync (--domain ... | --all)"); + const sourcePath = cookieFlagValue(tokens, "--source-path"); + if (action !== "sync" || Boolean(browser) === Boolean(sourcePath) || extra.length) { + return printFailure("Usage: betterwright cookies sync ( | --source-path ) (--domain ... | --all)"); } const domains = cookieFlagValues(tokens, "--domain"); @@ -729,7 +740,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"), @@ -765,7 +777,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 || []) { @@ -1137,9 +1149,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); @@ -1401,7 +1414,7 @@ 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; } @@ -1409,6 +1422,7 @@ async function cmdInteractive(flags) { // 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; @@ -1737,8 +1751,8 @@ async function cmdExec(flags) { session, history, policy, - headless: !flags.has("--headed"), - adBlock: adBlockFromFlags(flags), + ...browserOptionsFromFlags(flags), + headless: cliHeadless(flags), liveView: liveViewCliOptions(argv), onStep, onPhase: (event) => spinner.setLabel(phaseLabel(event)), @@ -2394,7 +2408,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; } diff --git a/docs/cookie-sync.md b/docs/cookie-sync.md index e70f9112..86ebed3e 100644 --- a/docs/cookie-sync.md +++ b/docs/cookie-sync.md @@ -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 diff --git a/docs/environment.md b/docs/environment.md index da0f85ce..9d89f07a 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -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 | @@ -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 diff --git a/package.json b/package.json index b7c7b335..4a3349d1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli-flags.ts b/src/cli-flags.ts index d0161f16..59e9717f 100644 --- a/src/cli-flags.ts +++ b/src/cli-flags.ts @@ -81,6 +81,7 @@ export const VALUE_FLAGS = new Set([ "--session-id", "--status", "--source-profile", + "--source-path", "--key-env", "--add", "--remove", diff --git a/src/cli-help.ts b/src/cli-help.ts index 5a9d7894..ddafe2dd 100644 --- a/src/cli-help.ts +++ b/src/cli-help.ts @@ -226,6 +226,7 @@ connect-only. Details: docs/browser-providers.md`, cookies: `Usage: betterwright cookies browsers [--json] betterwright cookies profiles [--json] betterwright cookies sync (--domain ... | --all) [options] + betterwright cookies sync --source-path (--domain ... | --all) [options] Copy cookies from a local browser profile into the selected persistent BetterWright profile. Sync merges cookies and can be run again safely. @@ -235,6 +236,8 @@ Options for sync: parent-domain cookies. Repeat for more domains --all sync every compatible cookie in the source profile --source-profile source profile id or name from \`cookies profiles\` + --source-path Chromium profile directory or cookie database, + instead of a registered browser id --include-session include Firefox-family session-store cookies --allow-app-bound on Windows, allow unprivileged browser-process injection for Chrome App-Bound cookie decryption diff --git a/src/client.ts b/src/client.ts index 3aeb0af4..03d92278 100644 --- a/src/client.ts +++ b/src/client.ts @@ -39,6 +39,7 @@ import { } from "./credential-constants.js"; import { normalizeDownloadPolicy } from "./downloads.js"; import { defaultHome } from "./home.js"; +import { resolveLaunchOptions } from "./launch-config.js"; import { defaultLiveViewListen } from "./live-view.js"; import { loadLiveViewConfig } from "./live-view-config.js"; import { NetworkPolicy } from "./policy.js"; @@ -496,6 +497,7 @@ export class BetterWright { * machine on the network. Pass `{host:"127.0.0.1"}` for loopback-only. */ constructor(options: BetterWrightOptions = {}) { + options = resolveLaunchOptions(options); this.hostTarget = options.hostTarget; this.hostUploadFiles = options.hostUploadFiles ?? []; if (this.hostTarget && (!isCallable(this.hostTarget.connect) || options.provider != null)) { diff --git a/src/cookie-sync.ts b/src/cookie-sync.ts index 0e7f8066..37b9cab3 100644 --- a/src/cookie-sync.ts +++ b/src/cookie-sync.ts @@ -1,6 +1,9 @@ +import fs from "node:fs/promises"; import { isIP } from "node:net"; +import path from "node:path"; import { domainToASCII } from "node:url"; -import { cookieReaderError } from "./cookie-reader-error.js"; +import type { CookieSyncSource } from "../types/public.js"; +import { CookieReaderError, cookieReaderError } from "./cookie-reader-error.js"; import { isBoolean, @@ -34,7 +37,7 @@ export interface CookieSyncTargetCookie { } export interface NormalizedCookieSyncOptions { - source: { browser: string; profile?: string }; + source: CookieSyncSource; domains?: string[]; includeSession: boolean; windowsAppBound: "disabled" | "injection"; @@ -53,6 +56,7 @@ interface CookieReaderReadOptions { interface CookieReaderModule { version: () => string; read: (options: CookieReaderReadOptions) => Promise; + fromPath?: (options: { path: string; timeoutMs: number; appBound: "disabled" | "injection_only" }) => Promise; supportedBrowsers: () => Promise; browserProfiles: ( browser: string, @@ -65,7 +69,7 @@ export interface CookieSyncExtraction { selected: number; skipped: number; warnings: Array<{ code: string; count: number }>; - source: { browser: string; profile?: string }; + source: CookieSyncSource; } function isCookieReaderModule(value: UntrustedValue): value is CookieReaderModule { @@ -160,20 +164,32 @@ export function normalizeCookieSyncOptions( } const sourceValue = untrustedField(value, "source"); if (!isRecord(sourceValue)) { - throw new TypeError("syncCookies options require source.browser."); - } - const browser = cleanText(untrustedField(sourceValue, "browser"), 128).toLowerCase(); - if (!browser || !/^[a-z0-9][a-z0-9._-]*$/.test(browser)) { - throw new TypeError("syncCookies source.browser must be a browser id."); - } - const source: NormalizedCookieSyncOptions["source"] = { browser }; - const profileValue = untrustedField(sourceValue, "profile"); - if (profileValue !== undefined) { - const profile = cleanText(profileValue, 4_096); - if (!profile || hasAsciiControl(profile)) { - throw new TypeError("syncCookies source.profile must be non-empty and single-line."); + throw new TypeError("syncCookies options require source.browser or source.path."); + } + const pathValue = untrustedField(sourceValue, "path"); + let source: CookieSyncSource; + if (pathValue !== undefined) { + if (!isString(pathValue) || !pathValue.trim() || hasAsciiControl(pathValue) || pathValue.length > 4_096) { + throw new TypeError("syncCookies source.path must be a non-empty, single-line local path."); + } + if (untrustedField(sourceValue, "browser") !== undefined || untrustedField(sourceValue, "profile") !== undefined) { + throw new TypeError("syncCookies source.path cannot be combined with browser or profile."); + } + source = { path: path.resolve(pathValue.trim()) }; + } else { + const browser = cleanText(untrustedField(sourceValue, "browser"), 128).toLowerCase(); + if (!browser || !/^[a-z0-9][a-z0-9._-]*$/.test(browser)) { + throw new TypeError("syncCookies source.browser must be a browser id."); + } + source = { browser }; + const profileValue = untrustedField(sourceValue, "profile"); + if (profileValue !== undefined) { + const profile = cleanText(profileValue, 4_096); + if (!profile || hasAsciiControl(profile)) { + throw new TypeError("syncCookies source.profile must be non-empty and single-line."); + } + source.profile = profile; } - source.profile = profile; } const domainsValue = untrustedField(value, "domains"); @@ -544,6 +560,27 @@ export async function extractCookieSync( let reader: CookieReaderModule; let snapshot: UntrustedValue; reader = await load(); + if ("path" in options.source) { + if (options.includeSession) throw new TypeError("includeSession is unavailable for explicit cookie paths."); + if (!reader.fromPath) throw new Error("Cookie Sync reader has no explicit-path support."); + try { + let dbPath = options.source.path; + if ((await fs.stat(dbPath)).isDirectory()) { + const network = path.join(dbPath, "Network", "Cookies"); + try { await fs.access(network); dbPath = network; } + catch { dbPath = path.join(dbPath, "Cookies"); } + } + snapshot = await reader.fromPath({ + path: dbPath, + timeoutMs: options.timeoutMs, + appBound: options.windowsAppBound === "injection" ? "injection_only" : "disabled", + }); + } catch (cause) { + // Path jobs have no registry profile to inspect; never rerun discovery. + throw new CookieReaderError(cause); + } + return normalizeCookieSnapshot(snapshot, options); + } const readOptions: CookieReaderReadOptions = { browser: options.source.browser, includeSession: options.includeSession, @@ -588,6 +625,7 @@ export async function listCookieSourceProfiles( ): Promise> { const normalized = normalizeCookieSyncOptions({ source: { browser }, timeoutMs }); const source = normalized.source; + if (!("browser" in source)) throw new TypeError("A browser id is required to list profiles."); const reader = await load(); let value: UntrustedValue; try { diff --git a/src/daemon.ts b/src/daemon.ts index 21db1dca..70895b76 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -312,6 +312,7 @@ interface NormalizedDaemonPolicy { interface NormalizedDaemonBrowser { adBlock: boolean; + downloadPolicy?: "ask" | "allow" | "deny"; launchIdentity: boolean; upstreamProxy: string | null; geoip: boolean; @@ -330,7 +331,7 @@ interface NormalizedDaemonBrowser { // signature JSON (the signature is compared byte-wise). export interface NormalizedDaemonConfig { protocol: number; - headless: boolean; + headless: boolean | "auto"; profile?: string; policy?: NormalizedDaemonPolicy; browser?: NormalizedDaemonBrowser; @@ -344,7 +345,7 @@ export function normalizeDaemonConfig(config: any = {}): NormalizedDaemonConfig const profile = resolveProfileName(config.profile); const normalized: NormalizedDaemonConfig = { protocol: DAEMON_PROTOCOL, - headless: config.headless !== false, + headless: config.headless === "auto" ? "auto" : config.headless !== false, }; // A daemon serves exactly one profile (it owns one browser), and the // socket is already keyed by profile — but keep it in the signature too so @@ -389,6 +390,13 @@ export function normalizeDaemonConfig(config: any = {}): NormalizedDaemonConfig .filter((note) => note.trim()); })(), }; + const downloadPolicy = untrustedField(browser, "downloadPolicy"); + if (downloadPolicy !== undefined) { + if (downloadPolicy !== "ask" && downloadPolicy !== "allow" && downloadPolicy !== "deny") { + throw new TypeError('downloadPolicy must be "ask", "allow", or "deny".'); + } + normalized.browser.downloadPolicy = downloadPolicy; + } // Include both modes in the signature: a pre-blocker daemon must not be // silently reused when the default now requires blocking. return normalized; @@ -459,7 +467,8 @@ export async function createBrowserFromDaemonConfig(config) { platform: normalized.browser.platform || undefined, stealthRuntimeFix: normalized.browser.stealthRuntimeFix || undefined, }; - if (normalized.profile) options.profile = normalized.profile; + options.profile = normalized.profile ?? null; + if (normalized.browser.downloadPolicy) options.downloadPolicy = normalized.browser.downloadPolicy; if (normalized.browser.provider) { // Assigned only when present: BetterWright treats a `provider` KEY on its // options — even a nullish one — as an explicit choice (Object.hasOwn) diff --git a/src/launch-config.ts b/src/launch-config.ts new file mode 100644 index 00000000..7598fafd --- /dev/null +++ b/src/launch-config.ts @@ -0,0 +1,82 @@ +// Launch defaults are a closed set, shared by every host before construction. +import fs from "node:fs"; +import path from "node:path"; +import type { BetterWrightOptions } from "../types/public.js"; +import { resolveAdBlock } from "./ad-block-config.js"; +import { defaultHome } from "./home.js"; +import { resolveProfileName } from "./profile-name.js"; +import { isBoolean, isRecord, isString, type UntrustedValue, untrustedField } from "./untrusted-value.js"; + +export type LaunchFileConfig = Pick; + +export function loadLaunchConfig(home = defaultHome()): LaunchFileConfig { + let section: UntrustedValue; + try { + const parsed: UntrustedValue = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")); + section = untrustedField(parsed, "launch"); + } catch { + return {}; + } + if (!isRecord(section)) return {}; + const out: LaunchFileConfig = {}; + const headless = untrustedField(section, "headless"); + if (isBoolean(headless) || headless === "auto") out.headless = headless; + const invisible = untrustedField(section, "headedInvisible"); + if (isBoolean(invisible)) out.headedInvisible = invisible; + const adBlock = untrustedField(section, "adBlock"); + if (isBoolean(adBlock)) out.adBlock = adBlock; + const profile = untrustedField(section, "profile"); + if (isString(profile) || profile === null) { + try { out.profile = resolveProfileName(profile); } catch { /* Ignore invalid saved names. */ } + } + const policy = untrustedField(section, "downloadPolicy"); + if (policy === "ask" || policy === "allow" || policy === "deny") out.downloadPolicy = policy; + const locale = untrustedField(section, "locale"); + if (isString(locale) && locale.trim()) { + try { out.locale = Intl.getCanonicalLocales(locale.trim())[0]; } catch { /* Invalid locale. */ } + } + const timezone = untrustedField(section, "timezone"); + if (isString(timezone) && timezone.trim()) { + try { + new Intl.DateTimeFormat("en", { timeZone: timezone.trim() }); + out.timezone = timezone.trim(); + } catch { /* Invalid timezone. */ } + } + return out; +} + +/** Environment overrides saved defaults; explicit host options come last. */ +export function launchOptionsFromEnv(home = defaultHome(), env = process.env): LaunchFileConfig { + const out = loadLaunchConfig(home); + const headless = String(env.BETTERWRIGHT_HEADLESS || "").trim().toLowerCase(); + if (headless) { + if (!["auto", "1", "true", "yes", "on", "0", "false", "no", "off"].includes(headless)) { + throw new TypeError('BETTERWRIGHT_HEADLESS must be auto, true, or false.'); + } + out.headless = headless === "auto" ? "auto" : ["1", "true", "yes", "on"].includes(headless); + out.headedInvisible = false; + } + if (String(env.BETTERWRIGHT_AD_BLOCK || "").trim()) out.adBlock = resolveAdBlock(undefined, env); + const profile = String(env.BETTERWRIGHT_PROFILE || "").trim(); + if (profile) out.profile = resolveProfileName(profile); + const locale = String(env.BETTERWRIGHT_LOCALE || "").trim(); + if (locale) out.locale = locale; + const timezone = String(env.BETTERWRIGHT_TIMEZONE || "").trim(); + if (timezone) out.timezone = timezone; + const policy = String(env.BETTERWRIGHT_DOWNLOAD_POLICY || "").trim().toLowerCase(); + if (policy) { + if (policy !== "ask" && policy !== "allow" && policy !== "deny") { + throw new TypeError('BETTERWRIGHT_DOWNLOAD_POLICY must be "ask", "allow", or "deny".'); + } + out.downloadPolicy = policy; + } + return out; +} + +export function resolveLaunchOptions(options: BetterWrightOptions): BetterWrightOptions { + const defaults = launchOptionsFromEnv(options.home || defaultHome()); + // A forced display mode must also cancel an inherited off-screen window. + if (options.headless !== undefined && options.headedInvisible === undefined) defaults.headedInvisible = false; + return { ...options, ...defaults, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }; +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index d3fb3277..13cffeec 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -62,6 +62,7 @@ import { } from "./client.js"; import { normalizeCredentialToolOptions } from "./credential-tool-options.js"; import { doctorReport } from "./doctor.js"; +import { launchOptionsFromEnv } from "./launch-config.js"; import { loadLiveViewConfig } from "./live-view-config.js"; import { importOptionalPeer } from "./optional-peer.js"; import { piImageArtifacts, piImageContent } from "./pi.js"; @@ -674,14 +675,16 @@ export async function runMcpServer(env = process.env, options: any = {}) { const { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } = await loadSdk(); - const downloadPolicy = downloadPolicyFromEnv(env); + const launch = launchOptionsFromEnv(undefined, env); + const downloadPolicy = launch.downloadPolicy ?? "ask"; // One persistent browser for the life of the server, so pages and logins // survive across tool calls the way an agent expects. The built-in encrypted // vault enables `browser_login`; an embedding may override or disable it. const browserOptions: BetterWrightOptions = { + ...launch, policy: policyFromEnv(env), - headless: headlessFromEnv(env), - adBlock: resolveAdBlock(undefined, env), + headless: launch.headless ?? "auto", + adBlock: launch.adBlock ?? resolveAdBlock(undefined, env), downloadPolicy, defaultTimeout: timeoutFromEnv(env), }; diff --git a/src/pi-extension.ts b/src/pi-extension.ts index df4bd3c7..ace64985 100644 --- a/src/pi-extension.ts +++ b/src/pi-extension.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; - +import { providerResolutionPlans, resolveBrowserProvider } from "./browser-providers.js"; import { BetterWright } from "./client.js"; import { normalizeCredentialToolOptions } from "./credential-tool-options.js"; import { @@ -10,13 +10,14 @@ import { } from "./pi.js"; import { agentSystemPrompt } from "./prompt.js"; import { piBrowserToolParameters, piLoginToolParameters } from "./tool-schemas.js"; -import { isCallable, isNumber, type UntrustedValue } from "./untrusted-value.js"; +import { isCallable, isNumber, isRecord, type UntrustedValue, untrustedField } from "./untrusted-value.js"; const BROWSER_TOOL_NAMES = new Set([ "browser", "browser_download", "browser_evidence", "browser_login", + "browser_visibility", ]); const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -29,6 +30,15 @@ export const PI_BROWSER_PARAMETERS = piBrowserToolParameters(); export const PI_LOGIN_PARAMETERS = piLoginToolParameters(); +export const PI_VISIBILITY_PARAMETERS = { + type: "object", + additionalProperties: false, + properties: { + mode: { type: "string", enum: ["visible", "headless", "offscreen"] }, + }, + required: ["mode"], +}; + export const PI_EVIDENCE_PARAMETERS = { type: "object", additionalProperties: false, @@ -145,8 +155,8 @@ function resolvedBrowserOptions(options) { .trim() .toLowerCase(); const resolved = { ...(options || {}) }; - if (timeout) resolved.defaultTimeout = timeout; - if (downloadPolicy) resolved.downloadPolicy = downloadPolicy; + if (timeout && resolved.defaultTimeout === undefined) resolved.defaultTimeout = timeout; + if (downloadPolicy && resolved.downloadPolicy === undefined) resolved.downloadPolicy = downloadPolicy; return resolved; } @@ -432,6 +442,13 @@ export function createPiExtension(options: any = {}) { options.startUrl ?? process.env.BETTERWRIGHT_PI_START_URL, ); let browser = options.browser || null; + let visibilityOptions = {}; + let operationTail = Promise.resolve(); + function serializeOperation(operation: () => Promise): Promise { + const result = operationTail.then(operation); + operationTail = result.then(() => undefined, () => undefined); + return result; + } let startPromise = null; let pendingStartWarning = ""; let stepCount = 0; @@ -492,7 +509,7 @@ export function createPiExtension(options: any = {}) { async function getBrowser() { if (!browser) - browser = new BetterWright(resolvedBrowserOptions(options.browserOptions)); + browser = new BetterWright({ ...resolvedBrowserOptions(options.browserOptions), ...visibilityOptions }); if (startUrl && !startPromise) { startPromise = browser .run( @@ -529,6 +546,74 @@ export function createPiExtension(options: any = {}) { return browser; } + async function setVisibility(params, signal) { + if (signal?.aborted) throw new Error("Browser visibility change cancelled."); + if (!isRecord(params) || Object.keys(params).some(key => key !== "mode")) { + throw new TypeError("browser_visibility accepts only mode."); + } + const mode = untrustedField(params, "mode"); + if (mode !== "visible" && mode !== "headless" && mode !== "offscreen") { + throw new TypeError("mode must be visible, headless, or offscreen."); + } + if (options.browser) throw new Error("A caller-supplied browser's visibility must be changed by its host."); + const headless = mode === "headless"; + const headedInvisible = mode === "offscreen"; + const restarted = Boolean(browser && (browser.headless !== headless || browser.headedInvisible !== headedInvisible)); + visibilityOptions = { headless, headedInvisible }; + if (!browser) browser = new BetterWright({ ...resolvedBrowserOptions(options.browserOptions), ...visibilityOptions }); + if (browser.hostTarget || providerResolutionPlans(resolveBrowserProvider(browser.provider)).some(plan => plan?.kind === "remote")) { + throw new Error("Browser visibility can only be controlled for locally launched browsers."); + } + if (restarted) { + await browser.close({ restart: true, endLiveView: true }); + browser.headless = headless; + browser.headedInvisible = headedInvisible; + startPromise = null; + pendingStartWarning = ""; + // Proofs describe the old tabs and cannot certify a fresh browser. + for (const item of evidenceChecklist.values()) { + item.status = "pending"; + item.evidence = null; + item.proofStep = null; + item.proofUrl = null; + } + currentChecklistAudited = false; + } + const instance = await getBrowser(); + const launch = await instance.run("return { visibility: await page.evaluate(() => document.visibilityState) }", { session }); + const details = { + ok: launch.ok === true, + error: launch.error, + mode, + restarted, + tabsLost: restarted, + message: restarted + ? "Browser restarted. Open tabs and in-memory page state were lost; persistent cookies, storage, and logins remain." + : "Browser visibility initialized; no restart was needed.", + }; + return { content: [{ type: "text", text: JSON.stringify(details) }], details }; + } + + pi.registerTool({ + name: "browser_visibility", + label: "BetterWright Browser Visibility", + description: "Initialize a locally launched browser as visible, headless, or offscreen (a headed window parked away from the desktop). Changing mode restarts the browser: open tabs and page state are lost, while persistent cookies, storage, and logins survive. The result reports whether a restart happened. Profile identity stays host-configured.", + parameters: PI_VISIBILITY_PARAMETERS, + execute: (_id, params, signal) => serializeOperation(() => setVisibility(params, signal)), + renderCall: makeRenderCall("browser_visibility"), + renderResult: summaryRenderResult, + }); + const registerCommand = pi.registerCommand; + if (isCallable(registerCommand)) { + pi.registerCommand("browser-visibility", { + description: "Set browser visibility: visible, headless, or offscreen. Changing mode loses open tabs.", + handler: async (args, ctx) => { + const result = await serializeOperation(() => setVisibility({ mode: String(args).trim() }, undefined)); + ctx.ui.notify(result.details.message, result.details.ok ? "info" : "error"); + }, + }); + } + function deactivateBrowserTools() { // Aliases keep the probe from narrowing the pi.* call paths to the // argument-less UntrustedFunction contract. @@ -748,7 +833,7 @@ export function createPiExtension(options: any = {}) { ], parameters: PI_BROWSER_PARAMETERS, execute: (_id, params, signal) => - executeBrowser("browser", params, signal, false), + serializeOperation(() => executeBrowser("browser", params, signal, false)), renderCall: makeRenderCall("browser"), renderResult: summaryRenderResult, }); @@ -767,27 +852,29 @@ export function createPiExtension(options: any = {}) { "failure. Generated credentials remain pending until committed. After a complete " + "host restart, credentials.listPending() recovers secret-free pending metadata.", parameters: PI_LOGIN_PARAMETERS, - async execute(_id, params, signal) { - if (signal?.aborted) throw new Error("Browser login cancelled."); - const instance = await getBrowser(); - const fillCredential = instance.fillCredential; - if (!isCallable(fillCredential)) { - throw new Error("This BetterWright build has no credential fill available."); - } - const result = await instance.fillCredential( - normalizeCredentialToolOptions(params, { session }), - ); - return { - content: [ - { type: "text", text: JSON.stringify(result, null, 2) }, - ...(await piImageContent(result)), - ], - details: { - ok: result?.ok === true, - error: result?.error, - pages: result?.pages || [], - }, - }; + execute(_id, params, signal) { + return serializeOperation(async () => { + if (signal?.aborted) throw new Error("Browser login cancelled."); + const instance = await getBrowser(); + const fillCredential = instance.fillCredential; + if (!isCallable(fillCredential)) { + throw new Error("This BetterWright build has no credential fill available."); + } + const result = await instance.fillCredential( + normalizeCredentialToolOptions(params, { session }), + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ...(await piImageContent(result)), + ], + details: { + ok: result?.ok === true, + error: result?.error, + pages: result?.pages || [], + }, + }; + }); }, renderCall: makeRenderCall("browser_login"), renderResult: summaryRenderResult, @@ -805,7 +892,7 @@ export function createPiExtension(options: any = {}) { "Use browser_evidence initialize before browser calls with an optional non-empty journey name and one atomic item per explicit filter, ranking, action, and requested datum; prove only items visible in the attached frame, and audit while ready before the final answer or before initializing the next sequential journey.", ], parameters: PI_EVIDENCE_PARAMETERS, - execute: (_id, params, signal) => executeEvidence(params, signal), + execute: (_id, params, signal) => serializeOperation(() => executeEvidence(params, signal)), renderCall: makeRenderCall("browser_evidence"), renderResult: summaryRenderResult, }); @@ -815,25 +902,27 @@ export function createPiExtension(options: any = {}) { label: "BetterWright Download", description: `${TOOL_DESCRIPTION} This approval-gated variant may save a remote file.`, parameters: PI_BROWSER_PARAMETERS, - async execute(_id, params, signal, _onUpdate, ctx) { - const instance = await getBrowser(); - if (instance.downloadPolicy === "deny") { - throw new Error("Browser downloads are disabled by host policy."); - } - if (instance.downloadPolicy === "ask") { - const confirm = ctx?.ui?.confirm; - if (!ctx?.hasUI || !isCallable(confirm)) { - throw new Error( - "Browser download requires user approval, but this Pi mode has no approval UI.", + execute(_id, params, signal, _onUpdate, ctx) { + return serializeOperation(async () => { + const instance = await getBrowser(); + if (instance.downloadPolicy === "deny") { + throw new Error("Browser downloads are disabled by host policy."); + } + if (instance.downloadPolicy === "ask") { + const confirm = ctx?.ui?.confirm; + if (!ctx?.hasUI || !isCallable(confirm)) { + throw new Error( + "Browser download requires user approval, but this Pi mode has no approval UI.", + ); + } + const approved = await ctx.ui.confirm( + "Allow browser download?", + params.note || "The agent requested one bounded download step.", ); + if (!approved) throw new Error("Browser download was not approved."); } - const approved = await ctx.ui.confirm( - "Allow browser download?", - params.note || "The agent requested one bounded download step.", - ); - if (!approved) throw new Error("Browser download was not approved."); - } - return executeBrowser("browser_download", params, signal, true); + return executeBrowser("browser_download", params, signal, true); + }); }, renderCall: makeRenderCall("browser_download"), renderResult: summaryRenderResult, @@ -875,6 +964,7 @@ export function createPiExtension(options: any = {}) { }); pi.on("session_shutdown", async () => { + await operationTail; if (browser && options.closeBrowserOnShutdown !== false) await browser.close(); browser = null; startPromise = null; diff --git a/src/worker.ts b/src/worker.ts index 241ee4a3..594149ad 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -140,7 +140,8 @@ let profileWarning = ""; let cookieSyncActive = false; interface CookieSyncResultSource { - browser: string; + browser?: string; + path?: string; profile?: string; } interface CookieSyncSuccessResult { @@ -2362,9 +2363,14 @@ async function cookieSync(message) { : 0; const source: CookieSyncResultSource = { browser: "" }; if (isObjectValue(message.source)) { - source.browser = String(untrustedField(message.source, "browser") || "").slice(0, 128); - if (isString(untrustedField(message.source, "profile"))) { - source.profile = "selected"; + if (isString(untrustedField(message.source, "path"))) { + delete source.browser; + source.path = "selected"; + } else { + source.browser = String(untrustedField(message.source, "browser") || "").slice(0, 128); + if (isString(untrustedField(message.source, "profile"))) { + source.profile = "selected"; + } } } const result: CookieSyncSuccessResult = { diff --git a/tests/node/browser.test.ts b/tests/node/browser.test.ts index 5d36175e..a9879829 100644 --- a/tests/node/browser.test.ts +++ b/tests/node/browser.test.ts @@ -12,18 +12,17 @@ import { test } from "node:test"; import { pathToFileURL } from "node:url"; import zlib from "node:zlib"; import { PlaywrightBlocker } from "@ghostery/adblocker-playwright"; -import { fromPath } from "rookie-cookies"; import { WebSocket, WebSocketServer } from "ws"; import { AD_BLOCK_CACHE_FILE } from "../../dist/src/ad-blocker.js"; import { saveBrowserFallbacks, saveDefaultBrowser } from "../../dist/src/browser-config.js"; import { chromiumNeedsSoftwareGpu, managedForkArgs } from "../../dist/src/browser-runtime.js"; import { guardProxyLaunchArgs } from "../../dist/src/chromium-args.js"; import { resolveChromiumForkBinary } from "../../dist/src/chromium-fork.js"; -import { normalizeCookieSnapshot, normalizeCookieSyncOptions } from "../../dist/src/cookie-sync.js"; import { doctorReport } from "../../dist/src/doctor.js"; import { createGuardProxy } from "../../dist/src/guard-proxy.js"; import { BetterWright, NetworkPolicy, runAgentTask } from "../../dist/src/index.js"; import { _createMcpHandlersForTest } from "../../dist/src/mcp-server.js"; +import { createPiExtension } from "../../dist/src/pi-extension.js"; import { isBoolean, isCallable, isString } from "../../dist/src/untrusted-value.js"; import { createLocalCredentialVault } from "../../dist/src/vault.js"; import { makeTempDir } from "./helpers/temp-dir.js"; @@ -4492,11 +4491,10 @@ test("native Cookie Sync reads a live SQLite WAL and authenticates after target }); const config = { home, headless: true, adBlock: false, vault: false }; const browser = new BetterWright(config); - browser._extractCookieSync = async (options) => normalizeCookieSnapshot( - await fromPath({ path: cookieFile, timeoutMs: 10000 }), normalizeCookieSyncOptions(options)); try { - const result = await browser.syncCookies({ source: { browser: "firefox" }, domains: ["127.0.0.1"] }); + const result = await browser.syncCookies({ source: { path: cookieFile }, domains: ["127.0.0.1"] }); assert.equal(result.ok, true, result.error); + assert.deepEqual(result.source, { path: "selected" }); assert.equal(result.synced, 1); assert.equal(JSON.stringify(result).includes(sentinel), false); assert.equal(db.prepare("SELECT COUNT(*) AS count FROM moz_cookies").get().count, 1); @@ -4513,6 +4511,74 @@ test("native Cookie Sync reads a live SQLite WAL and authenticates after target } finally { db.close(); await browser.close(); await server.close(); } }); +test("Pi visibility initializes headless and preserves tabs when the mode stays the same", opts, async () => { + const home = tempHome(); + const tools = new Map(); + const handlers = new Map(); + createPiExtension({ browserOptions: { home, vault: false, adBlock: false }, autoScreenshot: false })({ + registerTool: tool => tools.set(tool.name, tool), + on: (event, handler) => handlers.set(event, handler), + }); + try { + const initialized = await tools.get("browser_visibility").execute("init", { mode: "headless" }); + assert.equal(initialized.details.ok, true, initialized.details.error); + assert.equal(initialized.details.restarted, false); + await tools.get("browser").execute("page", { code: "await page.setContent('

retained

')" }); + const same = await tools.get("browser_visibility").execute("same", { mode: "headless" }); + assert.equal(same.details.restarted, false); + const read = await tools.get("browser").execute("read", { code: "return page.locator('h1').innerText()" }); + assert.equal(JSON.parse(read.content[0].text).result, "retained"); + } finally { + await handlers.get("session_shutdown")(); + removeBrowserHome(home); + } +}); + +test("Pi visibility restarts across all display modes while retaining the configured profile and login", { + skip: !ready ? opts.skip : process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY ? "headed display unavailable" : false, +}, async () => { + const home = tempHome(); + fs.writeFileSync(path.join(home, "config.json"), JSON.stringify({ launch: { profile: "pi-identity", adBlock: false } })); + const server = await listen((_request, response) => { + response.setHeader("content-type", "text/html"); + response.end("

local fixture

"); + }); + const tools = new Map(); + const handlers = new Map(); + createPiExtension({ browserOptions: { home, vault: false }, autoScreenshot: false })({ + registerTool: tool => tools.set(tool.name, tool), + on: (event, handler) => handlers.set(event, handler), + }); + try { + await tools.get("browser_visibility").execute("init", { mode: "offscreen" }); + const saved = await tools.get("browser").execute("login", { code: + `await page.goto(${JSON.stringify(server.origin)}); + 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); + 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 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')) };` }); + const result = JSON.parse(checked.content[0].text); + assert.equal(result.ok, true, result.error); + assert.equal(result.result.count, 1); + assert.match(result.result.cookie, /synthetic_login=retained/); + assert.equal(result.result.storage, "retained"); + } + assert.ok(fs.existsSync(path.join(home, "browser", "profiles", "pi-identity", "Default", "Cookies")) || + fs.existsSync(path.join(home, "browser", "profiles", "pi-identity", "Default", "Network", "Cookies"))); + } finally { + await handlers.get("session_shutdown")(); + await server.close(); + removeBrowserHome(home); + } +}); + test("Cookie Sync refuses a batch that could evict target cookies", opts, async () => { const sentinel = `cookie-capacity-${Date.now()}`; const home = tempHome(); diff --git a/tests/node/cookie-sync.test.ts b/tests/node/cookie-sync.test.ts index 50c0491d..177d307b 100644 --- a/tests/node/cookie-sync.test.ts +++ b/tests/node/cookie-sync.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import test from "node:test"; - import { COOKIE_SYNC_MAX_COOKIES, extractCookieSync, @@ -10,6 +12,7 @@ import { normalizeCookieSyncOptions, validateCookieSyncTargetCookies, } from "../../dist/src/cookie-sync.js"; +import { makeTempDir } from "./helpers/temp-dir.js"; function detailed(cookie: any = {}, context: any = {}) { return { @@ -50,6 +53,36 @@ function reader(snapshot: any, calls: any[] = []) { }; } +test("explicit Chromium sources read plaintext cookies outside the registry without changing the source", async () => { + const profile = makeTempDir("betterwright-cookie-path-"); + const cookieFile = path.join(profile, "Cookies"); + const db = new DatabaseSync(cookieFile); + db.exec(`CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO meta VALUES ('version', '24'); + CREATE TABLE cookies (creation_utc INTEGER, host_key TEXT, top_frame_site_key TEXT DEFAULT '', + name TEXT, value TEXT, encrypted_value BLOB DEFAULT X'', path TEXT, expires_utc INTEGER, + is_secure INTEGER, is_httponly INTEGER, last_access_utc INTEGER DEFAULT 0, + has_expires INTEGER DEFAULT 1, is_persistent INTEGER DEFAULT 1, priority INTEGER DEFAULT 1, + samesite INTEGER DEFAULT 1, source_scheme INTEGER DEFAULT 2, source_port INTEGER DEFAULT 443, + last_update_utc INTEGER DEFAULT 0, has_cross_site_ancestor INTEGER DEFAULT 0);`); + const expires = (Math.floor(Date.now() / 1000) + 3600 + 11644473600) * 1000000; + const insert = db.prepare("INSERT INTO cookies (host_key, name, value, path, expires_utc, is_secure, is_httponly) VALUES (?, ?, ?, '/', ?, 1, 1)"); + insert.run(".example.com", "auth", "synthetic-token", expires); + insert.run("unrelated.test", "other", "synthetic-other", expires); + db.close(); + const before = fs.readFileSync(cookieFile); + for (const selected of [profile, cookieFile]) { + const result = await extractCookieSync(normalizeCookieSyncOptions({ source: { path: selected }, domains: ["app.example.com"] })); + assert.equal(result.selected, 1); + assert.equal(result.cookies[0].name, "auth"); + assert.equal(result.cookies[0].value, "synthetic-token"); + assert.deepEqual(fs.readFileSync(cookieFile), before); + } + 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/); +}); + test("Cookie Sync normalizes source, domains, timeout, and secure defaults", () => { const options = normalizeCookieSyncOptions({ source: { browser: " Chrome " }, diff --git a/tests/node/launch-config.test.ts b/tests/node/launch-config.test.ts new file mode 100644 index 00000000..86509aba --- /dev/null +++ b/tests/node/launch-config.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { daemonConfigFromFlags } from "../../dist/bin/cli-main.js"; +import { BetterWright } from "../../dist/src/client.js"; +import { daemonConfigSignature, normalizeDaemonConfig } from "../../dist/src/daemon.js"; +import { launchOptionsFromEnv, loadLaunchConfig } from "../../dist/src/launch-config.js"; +import { makeTempDir } from "./helpers/temp-dir.js"; + +test("launch defaults validate each allowed key and ignore unknown options", () => { + const home = makeTempDir("betterwright-launch-"); + const file = path.join(home, "config.json"); + assert.deepEqual(loadLaunchConfig(home), {}); + fs.writeFileSync(file, "broken json"); + assert.deepEqual(loadLaunchConfig(home), {}); + fs.writeFileSync(file, JSON.stringify({ launch: { + headless: "auto", headedInvisible: true, profile: "research", adBlock: false, + locale: "en-us", timezone: "America/New_York", downloadPolicy: "deny", + provider: { cdpUrl: "http://example.test" }, policy: {}, vault: false, + } })); + assert.deepEqual(loadLaunchConfig(home), { + headless: "auto", headedInvisible: true, profile: "research", adBlock: false, + locale: "en-US", timezone: "America/New_York", downloadPolicy: "deny", + }); + fs.writeFileSync(file, JSON.stringify({ launch: { + headless: "false", headedInvisible: 1, profile: "../escape", adBlock: "yes", + locale: "not_a_locale", timezone: "Mars/Olympus", downloadPolicy: "sometimes", + } })); + assert.deepEqual(loadLaunchConfig(home), {}); +}); + +test("environment and explicit constructor options override saved launch defaults", async () => { + const home = makeTempDir("betterwright-launch-precedence-"); + fs.writeFileSync(path.join(home, "config.json"), JSON.stringify({ launch: { + headless: false, headedInvisible: true, profile: "saved", adBlock: false, + locale: "de-DE", timezone: "Europe/Berlin", downloadPolicy: "deny", + } })); + const env = { BETTERWRIGHT_HEADLESS: "1", BETTERWRIGHT_PROFILE: "environment", + BETTERWRIGHT_AD_BLOCK: "1", BETTERWRIGHT_LOCALE: "en-GB", + BETTERWRIGHT_TIMEZONE: "Europe/London", BETTERWRIGHT_DOWNLOAD_POLICY: "ask" }; + assert.deepEqual(launchOptionsFromEnv(home, env), { + headless: true, headedInvisible: false, profile: "environment", adBlock: true, + locale: "en-GB", timezone: "Europe/London", downloadPolicy: "ask", + }); + const inherited = new BetterWright({ home, vault: false }); + const explicit = new BetterWright({ home, vault: undefined, headless: true, profile: null, + adBlock: true, locale: "fr-FR", timezone: "UTC", downloadPolicy: "allow" }); + try { + assert.equal(inherited.headedInvisible, true); + assert.equal(inherited.headless, false); + assert.equal(inherited.profile, "saved"); + assert.equal(inherited.downloadPolicy, "deny"); + assert.equal(explicit.headedInvisible, false); + assert.equal(explicit.headless, true); + assert.equal(explicit.profile, null); + assert.equal(explicit.adBlock, true); + assert.equal(explicit.locale, "fr-FR"); + assert.equal(explicit.timezone, "UTC"); + assert.equal(explicit.downloadPolicy, "allow"); + assert.equal(explicit.vault, null); + } finally { + await inherited.close(); + await explicit.close(); + } +}); + +test("CLI daemon config includes saved identity, display and download policy in its signature", () => { + const home = makeTempDir("betterwright-launch-daemon-"); + const file = path.join(home, "config.json"); + fs.writeFileSync(file, JSON.stringify({ launch: { + profile: "research", headless: "auto", downloadPolicy: "deny", adBlock: false, + } })); + const config = daemonConfigFromFlags(new Set(), { home, argv: [], env: {} }); + assert.equal(config.profile, "research"); + assert.equal(config.headless, "auto"); + assert.equal(config.browser.adBlock, false); + assert.equal(normalizeDaemonConfig(config).browser.downloadPolicy, "deny"); + const signature = daemonConfigSignature(config); + fs.writeFileSync(file, JSON.stringify({ launch: { profile: "research", downloadPolicy: "allow" } })); + assert.notEqual(daemonConfigSignature(daemonConfigFromFlags(new Set(), { home, argv: [], env: {} })), signature); + const headed = daemonConfigFromFlags(new Set(["--headed", "--ad-block"]), { home, argv: [], env: {} }); + assert.equal(headed.headless, false); + assert.equal(headed.browser.headedInvisible, false); + assert.equal(headed.browser.adBlock, true); +}); diff --git a/tests/node/pi-extension.test.ts b/tests/node/pi-extension.test.ts index d07b7956..022cba9f 100644 --- a/tests/node/pi-extension.test.ts +++ b/tests/node/pi-extension.test.ts @@ -14,12 +14,14 @@ class FakePi { tools: Map; handlers: Map; messages: any[]; + commands: Map; activeTools: string[]; constructor() { this.tools = new Map(); this.handlers = new Map(); this.messages = []; + this.commands = new Map(); this.activeTools = ["read", "browser", "browser_download", "browser_evidence"]; } @@ -27,6 +29,10 @@ class FakePi { this.tools.set(tool.name, tool); } + registerCommand(name, command) { + this.commands.set(name, command); + } + on(event, handler) { this.handlers.set(event, handler); } @@ -44,6 +50,25 @@ class FakePi { } } +test("Pi visibility rejects invalid modes, identity options and changes to host-supplied browsers", async () => { + const browser = new FakeBrowser(); + const pi = new FakePi(); + createPiExtension({ browser })(pi); + const tool = pi.tools.get("browser_visibility"); + assert.deepEqual(tool.parameters.properties.mode.enum, ["visible", "headless", "offscreen"]); + assert.equal(tool.parameters.additionalProperties, false); + assert.ok(pi.commands.has("browser-visibility")); + await assert.rejects(tool.execute("invalid", { mode: "auto" }), /mode must be/); + await assert.rejects(tool.execute("identity", { mode: "headless", profile: "other" }), /only mode/); + await assert.rejects(tool.execute("host", { mode: "headless" }), /changed by its host/); + assert.equal(browser.closeCount, 0); + assert.equal(browser.calls.length, 0); + const remote = new FakePi(); + createPiExtension({ browserOptions: { vault: false, provider: { cdpUrl: "ws://127.0.0.1:1" } } })(remote); + await assert.rejects(remote.tools.get("browser_visibility").execute("remote", { mode: "headless" }), /locally launched/); + await remote.handlers.get("session_shutdown")(); +}); + class FakeBrowser { calls: any[]; fills: any[]; @@ -150,6 +175,7 @@ test("native Pi extension registers persistent tools and records its supplied st })(pi); assert.deepEqual([...pi.tools.keys()], [ + "browser_visibility", "browser", "browser_login", "browser_evidence", diff --git a/tests/types/package.test.ts b/tests/types/package.test.ts index c8a835ee..50122e5c 100644 --- a/tests/types/package.test.ts +++ b/tests/types/package.test.ts @@ -125,9 +125,12 @@ const cookieSyncOptions: CookieSyncOptions = { windowsAppBound: "disabled", }; const cookieSync: Promise = browser.syncCookies(cookieSyncOptions); +const pathCookieSync: Promise = browser.syncCookies({ source: { path: "/tmp/helium/Default" } }); +// @ts-expect-error A source is either a registered browser or an explicit path. +const invalidCookieSource: CookieSyncOptions = { source: { path: "/tmp/Cookies", browser: "chrome" } }; const cookieBrowsers = listCookieSourceBrowsers(); const cookieProfiles = listCookieSourceProfiles("chrome", { timeoutMs: 10_000 }); -void [cookieSync, cookieBrowsers, cookieProfiles]; +void [cookieSync, pathCookieSync, invalidCookieSource, cookieBrowsers, cookieProfiles]; const run: Promise> = browser.run<{ title: string }>( "return { title: await page.title() }", { session: "docs", note: "Reading the page" }, diff --git a/types/pi-extension.d.ts b/types/pi-extension.d.ts index 2e54dda8..f19b96d1 100644 --- a/types/pi-extension.d.ts +++ b/types/pi-extension.d.ts @@ -68,6 +68,10 @@ export interface PiExtensionApiLike { }, ): Promise; }): void; + registerCommand?(name: string, command: { + description: string; + handler(args: string, context: { ui: { notify(message: string, type: "info" | "error"): void } }): Promise; + }): void; on(event: string, handler: (...args: any[]) => UntrustedValue): void; getActiveTools?(): string[]; setActiveTools?(names: string[]): void; @@ -80,10 +84,11 @@ export type PiExtension = (pi: PiExtensionApiLike) => void; export const PI_BROWSER_PARAMETERS: Readonly; export const PI_LOGIN_PARAMETERS: Readonly; export const PI_EVIDENCE_PARAMETERS: Readonly; +export const PI_VISIBILITY_PARAMETERS: Readonly; /** * Build the extension `pi install npm:betterwright` loads: registers - * `browser`, `browser_download`, `browser_evidence`, and `browser_login` + * `browser`, `browser_download`, `browser_evidence`, `browser_visibility`, and `browser_login` * (the last only when the vault is enabled), keeps one browser session * alive, and appends the operator guidance to the system prompt. * See SETUP.md §2. diff --git a/types/public.d.ts b/types/public.d.ts index 55816972..2388d9cf 100644 --- a/types/public.d.ts +++ b/types/public.d.ts @@ -81,12 +81,18 @@ export type BrowserProviderOptions = | CloudBrowserProvider | ManagedBrowserProvider; -export interface CookieSyncSource { +export type CookieSyncSource = { /** Browser id from `listCookieSourceBrowsers()`, such as `chrome` or `firefox`. */ browser: string; /** Profile id or name from `listCookieSourceProfiles()`. */ profile?: string; -} + path?: never; +} | { + /** Cookie database file or Chromium profile directory, independent of browser discovery. */ + path: string; + browser?: never; + profile?: never; +}; export interface CookieSyncOptions { source: CookieSyncSource; @@ -113,7 +119,8 @@ export type CookieSyncResult = synced: number; selected: number; skipped: number; - source: { browser: string; profile?: string }; + /** Explicit paths are reported as "selected" to avoid exposing local paths. */ + source: CookieSyncSource; target: string; /** * Host-owned targets (`hostTarget`) only: deduplicated cookie domains From e956016b7e2eedb8797baa0b4f012914b9572fb1 Mon Sep 17 00:00:00 2001 From: SSHdotCodes <254190073+SSHdotCodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:03:29 -0400 Subject: [PATCH 2/4] fix: forward launch settings to one-shot agent browsers --- src/agent.ts | 7 ++++++- tests/node/agent.test.ts | 9 +++++++++ tests/types/package.test.ts | 5 +++++ types/agent.d.ts | 6 ++++-- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index a2ab16a8..20928416 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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. diff --git a/tests/node/agent.test.ts b/tests/node/agent.test.ts index dea3edfa..0320dc50 100644 --- a/tests/node/agent.test.ts +++ b/tests/node/agent.test.ts @@ -144,6 +144,15 @@ function scriptedModel(responses) { }; } +test("owned agent browsers validate forwarded launch settings before calling the model", async () => { + const model = scriptedModel([]); + await assert.rejects(runAgentTask({ + task: "read a page", model, home: makeTempDir("betterwright-agent-launch-"), + downloadPolicy: "sometimes", vault: false, + }), /downloadPolicy must be/); + assert.equal(model.seen.length, 0); +}); + test("runAgentTask drives browser then finishes on done", async () => { const browser = fakeBrowser({ runs: [{ ok: true, result: "HN", artifacts: [{ kind: "proof", path: "/tmp/p.png" }], durationMs: 9 }], diff --git a/tests/types/package.test.ts b/tests/types/package.test.ts index 50122e5c..a91ab916 100644 --- a/tests/types/package.test.ts +++ b/tests/types/package.test.ts @@ -198,6 +198,11 @@ const endpointModels: Promise<{ const openRouterBaseURL: string | null = MODEL_ENDPOINT_PRESETS.openrouter.baseURL; const agentResult: Promise = runAgentTask({ + home: "/tmp/betterwright-agent-types", + headedInvisible: false, + locale: "en-US", + timezone: "UTC", + downloadPolicy: "deny", task: "read the page title", model: customModel, maxDurationMs: 120_000, diff --git a/types/agent.d.ts b/types/agent.d.ts index 539b20eb..60caabff 100644 --- a/types/agent.d.ts +++ b/types/agent.d.ts @@ -2,6 +2,7 @@ import type { BetterWright } from "./client.js"; import type { CredentialVault } from "./common.js"; import type { NetworkPolicy } from "./policy.js"; import type { Guardrails } from "./prompt.js"; +import type { BetterWrightOptions } from "./public.js"; import type { UntrustedValue } from "./untrusted-value.js"; /** Per-request auth an OAuth adapter resolves before each model call. */ @@ -75,7 +76,8 @@ export interface AgentPhaseEvent { tools?: string[]; } -export interface RunAgentTaskOptions { +export interface RunAgentTaskOptions extends Pick { task: string; model?: string | AgentModel; modelOptions?: Record; @@ -87,7 +89,7 @@ export interface RunAgentTaskOptions { * browser this call creates. Ignored when `browser` is supplied, since that * browser already has a profile. */ - profile?: string; + profile?: string | null; headless?: boolean | "auto"; /** Ad/tracker blocking for a browser this call creates (default on). */ adBlock?: boolean; From 867468b36e3da78debe2a50f4815335330978ced Mon Sep 17 00:00:00 2001 From: SSHdotCodes <254190073+SSHdotCodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:07:43 -0400 Subject: [PATCH 3/4] test: release SQLite fixture statements before Windows cleanup --- tests/node/cookie-sync.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/node/cookie-sync.test.ts b/tests/node/cookie-sync.test.ts index 177d307b..523fabef 100644 --- a/tests/node/cookie-sync.test.ts +++ b/tests/node/cookie-sync.test.ts @@ -66,9 +66,11 @@ test("explicit Chromium sources read plaintext cookies outside the registry with samesite INTEGER DEFAULT 1, source_scheme INTEGER DEFAULT 2, source_port INTEGER DEFAULT 443, last_update_utc INTEGER DEFAULT 0, has_cross_site_ancestor INTEGER DEFAULT 0);`); const expires = (Math.floor(Date.now() / 1000) + 3600 + 11644473600) * 1000000; - const insert = db.prepare("INSERT INTO cookies (host_key, name, value, path, expires_utc, is_secure, is_httponly) VALUES (?, ?, ?, '/', ?, 1, 1)"); - insert.run(".example.com", "auth", "synthetic-token", expires); - insert.run("unrelated.test", "other", "synthetic-other", expires); + // Do not retain a prepared statement across close: Bun can defer releasing + // its SQLite file handle until the statement is collected on Windows. + db.exec(`INSERT INTO cookies (host_key, name, value, path, expires_utc, is_secure, is_httponly) VALUES + ('.example.com', 'auth', 'synthetic-token', '/', ${expires}, 1, 1), + ('unrelated.test', 'other', 'synthetic-other', '/', ${expires}, 1, 1);`); db.close(); const before = fs.readFileSync(cookieFile); for (const selected of [profile, cookieFile]) { From e04076318177b207fc84d029050837397a284ba1 Mon Sep 17 00:00:00 2001 From: SSHdotCodes <254190073+SSHdotCodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:18:17 -0400 Subject: [PATCH 4/4] fix: honor explicit launch overrides and redact empty cookie results --- bin/cli-main.ts | 28 +++++++++++++----- src/client.ts | 4 ++- src/launch-config.ts | 16 +++++----- tests/node/client.test.ts | 21 +++++++++++++ tests/node/launch-config.test.ts | 51 ++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/bin/cli-main.ts b/bin/cli-main.ts index fb04514e..1bd3033b 100644 --- a/bin/cli-main.ts +++ b/bin/cli-main.ts @@ -83,7 +83,7 @@ import { execTask, interruptSession, } from "../src/daemon-client.js"; -import { launchOptionsFromEnv } from "../src/launch-config.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 @@ -287,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 = launchOptionsFromEnv().profile; cliProfileCache = resolveProfileName( - raw === undefined || raw === null ? fromEnv : raw, + raw === undefined || raw === null + ? String(process.env.BETTERWRIGHT_PROFILE || "").trim() || loadLaunchConfig().profile + : raw, ); } return cliProfileCache; @@ -300,8 +301,19 @@ 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 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); + const launch = launchOptionsFromEnv(home, env, launchOverridesFromFlags(flags, argv)); const options: any = { ...launch, adBlock: flags.has("--ad-block") || flags.has("--no-ad-block") @@ -320,9 +332,9 @@ function browserOptionsFromFlags(flags, argv = process.argv, home = defaultDaemo return options; } -function cliHeadless(flags, home = defaultDaemonHome(), env = process.env): boolean | "auto" { +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).headless ?? true; + return launchOptionsFromEnv(home, env, launchOverridesFromFlags(flags, argv)).headless ?? true; } // --browser / --browser-key / BETTERWRIGHT_CDP_URL: the escape hatch from the @@ -583,10 +595,10 @@ export function daemonConfigFromFlags( browser.provider = expandProviderChoice(browser.provider, { home, env }); } return { - headless: cliHeadless(flags, home, env), + 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() ?? launchOptionsFromEnv(home, env).profile ?? null, + profile: resolveProfileName(flagValue(argv, "--profile") ?? browser.profile), policy: { allowLoopback: !flags.has("--block-loopback"), allowPrivateNetwork: !flags.has("--block-private-network"), diff --git a/src/client.ts b/src/client.ts index 03d92278..27b85138 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1361,10 +1361,12 @@ export class BetterWright { synced: 0, selected: extracted.selected, skipped: extracted.skipped, - source: extracted.source, + source: { ...extracted.source }, target: consentTarget || (this.hostTarget ? "host" : "local"), warnings: extracted.warnings, }; + if (empty.source.path !== undefined) empty.source.path = "selected"; + if (empty.source.profile !== undefined) empty.source.profile = "selected"; if (this.hostTarget) empty.cookieImportDomains = []; return empty; } diff --git a/src/launch-config.ts b/src/launch-config.ts index 7598fafd..61678ce9 100644 --- a/src/launch-config.ts +++ b/src/launch-config.ts @@ -47,25 +47,25 @@ export function loadLaunchConfig(home = defaultHome()): LaunchFileConfig { } /** Environment overrides saved defaults; explicit host options come last. */ -export function launchOptionsFromEnv(home = defaultHome(), env = process.env): LaunchFileConfig { +export function launchOptionsFromEnv(home = defaultHome(), env = process.env, overrides: LaunchFileConfig = {}): LaunchFileConfig { const out = loadLaunchConfig(home); const headless = String(env.BETTERWRIGHT_HEADLESS || "").trim().toLowerCase(); - if (headless) { + if (headless && overrides.headless === undefined && overrides.headedInvisible !== true) { if (!["auto", "1", "true", "yes", "on", "0", "false", "no", "off"].includes(headless)) { throw new TypeError('BETTERWRIGHT_HEADLESS must be auto, true, or false.'); } out.headless = headless === "auto" ? "auto" : ["1", "true", "yes", "on"].includes(headless); out.headedInvisible = false; } - if (String(env.BETTERWRIGHT_AD_BLOCK || "").trim()) out.adBlock = resolveAdBlock(undefined, env); + if (overrides.adBlock === undefined && String(env.BETTERWRIGHT_AD_BLOCK || "").trim()) out.adBlock = resolveAdBlock(undefined, env); const profile = String(env.BETTERWRIGHT_PROFILE || "").trim(); - if (profile) out.profile = resolveProfileName(profile); + if (profile && overrides.profile === undefined) out.profile = resolveProfileName(profile); const locale = String(env.BETTERWRIGHT_LOCALE || "").trim(); - if (locale) out.locale = locale; + if (locale && overrides.locale === undefined) out.locale = locale; const timezone = String(env.BETTERWRIGHT_TIMEZONE || "").trim(); - if (timezone) out.timezone = timezone; + if (timezone && overrides.timezone === undefined) out.timezone = timezone; const policy = String(env.BETTERWRIGHT_DOWNLOAD_POLICY || "").trim().toLowerCase(); - if (policy) { + if (policy && overrides.downloadPolicy === undefined) { if (policy !== "ask" && policy !== "allow" && policy !== "deny") { throw new TypeError('BETTERWRIGHT_DOWNLOAD_POLICY must be "ask", "allow", or "deny".'); } @@ -75,7 +75,7 @@ export function launchOptionsFromEnv(home = defaultHome(), env = process.env): L } export function resolveLaunchOptions(options: BetterWrightOptions): BetterWrightOptions { - const defaults = launchOptionsFromEnv(options.home || defaultHome()); + const defaults = launchOptionsFromEnv(options.home || defaultHome(), process.env, options); // A forced display mode must also cancel an inherited off-screen window. if (options.headless !== undefined && options.headedInvisible === undefined) defaults.headedInvisible = false; return { ...options, ...defaults, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }; diff --git a/tests/node/client.test.ts b/tests/node/client.test.ts index 9d38d06d..90ffe59c 100644 --- a/tests/node/client.test.ts +++ b/tests/node/client.test.ts @@ -373,6 +373,27 @@ test("the worker rechecks Cookie Sync consent before opening a remote CDP target } }); +test("empty Cookie Sync results redact source paths and profile filenames", async () => { + const browser = new BetterWright({ home: makeTempDir("betterwright-empty-cookie-source-"), vault: false }); + browser._extractCookieSync = async (normalized) => ({ + cookies: [], selected: 0, skipped: 0, warnings: [], source: { ...normalized.source }, + }); + try { + for (const [source, expected] of [ + [{ path: path.join(browser.home, "private-source-filename") }, { path: "selected" }], + [{ browser: "chrome", profile: "private-profile-filename" }, { browser: "chrome", profile: "selected" }], + ]) { + const result = await browser.syncCookies({ source, domains: ["example.test"] }); + assert.equal(result.ok, true); + assert.equal(result.synced, 0); + assert.deepEqual(result.source, expected); + assert.doesNotMatch(JSON.stringify(result), /private-(source|profile)-filename/); + } + } finally { + await browser.close(); + } +}); + test("Cookie Sync to a host-owned target needs no cloud consent and reports the granted domains", async () => { const hostTarget = { connect: async () => ({ provider: { cdpUrl: "ws://127.0.0.1:9/devtools/browser/unreachable" }, close: async () => {} }), diff --git a/tests/node/launch-config.test.ts b/tests/node/launch-config.test.ts index 86509aba..8e8a6853 100644 --- a/tests/node/launch-config.test.ts +++ b/tests/node/launch-config.test.ts @@ -84,3 +84,54 @@ test("CLI daemon config includes saved identity, display and download policy in assert.equal(headed.browser.headedInvisible, false); assert.equal(headed.browser.adBlock, true); }); + +test("explicit SDK settings bypass invalid lower-priority environment values", async () => { + const home = makeTempDir("betterwright-launch-invalid-env-"); + const settings = { + BETTERWRIGHT_HEADLESS: "invalid", + BETTERWRIGHT_PROFILE: "../invalid", + BETTERWRIGHT_AD_BLOCK: "invalid", + BETTERWRIGHT_DOWNLOAD_POLICY: "invalid", + }; + const previous = Object.fromEntries(Object.keys(settings).map(key => [key, process.env[key]])); + Object.assign(process.env, settings); + const explicit = { home, vault: false as const, headless: true, profile: null, adBlock: false, downloadPolicy: "deny" as const }; + try { + const browser = new BetterWright(explicit); + const invisible = new BetterWright({ ...explicit, headless: undefined, headedInvisible: true, profile: "valid" }); + try { + assert.equal(browser.headless, true); + assert.equal(browser.profile, null); + assert.equal(browser.adBlock, false); + assert.equal(browser.downloadPolicy, "deny"); + assert.equal(invisible.headless, false); + assert.equal(invisible.headedInvisible, true); + assert.equal(invisible.profile, "valid"); + // Invalid values still fail when the caller actually inherits them. + assert.throws(() => new BetterWright({ ...explicit, headless: undefined }), /BETTERWRIGHT_HEADLESS/); + assert.throws(() => new BetterWright({ ...explicit, profile: undefined }), /invalid profile/); + assert.throws(() => new BetterWright({ ...explicit, adBlock: undefined }), /BETTERWRIGHT_AD_BLOCK/); + assert.throws(() => new BetterWright({ ...explicit, downloadPolicy: undefined }), /BETTERWRIGHT_DOWNLOAD_POLICY/); + } finally { + await browser.close(); + await invisible.close(); + } + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test("explicit CLI flags bypass invalid overridden environment settings", () => { + const home = makeTempDir("betterwright-launch-cli-precedence-"); + const flags = new Set(["--headed", "--no-ad-block", "--profile"]); + const config = daemonConfigFromFlags(flags, { + home, argv: ["--headed", "--no-ad-block", "--profile", "valid"], + env: { BETTERWRIGHT_HEADLESS: "invalid", BETTERWRIGHT_AD_BLOCK: "invalid", BETTERWRIGHT_PROFILE: "../invalid" }, + }); + assert.equal(config.headless, false); + assert.equal(config.browser.adBlock, false); + assert.equal(config.profile, "valid"); +});