From 376958294f62e4082df9549fd7c09e7f3f1af349 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 19 Oct 2026 18:29:10 +0300 Subject: [PATCH 1/3] test: stop the owner-pid lifetime test racing a slow launch The owner process lived 8 s and the test waited a fixed 2.5 s for the session to go away. Under eight parallel workers a Chrome launch can take most of those 8 s, so the owner sometimes exited before `open` registered it and the test only passed on retry. The owner now lives until the test kills it, and the test polls `list` for up to 15 s instead of sleeping. --- tests/integration/isolation.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/isolation.spec.ts b/tests/integration/isolation.spec.ts index 8e8aecf5..1e6b7b61 100644 --- a/tests/integration/isolation.spec.ts +++ b/tests/integration/isolation.spec.ts @@ -193,15 +193,15 @@ test('a session nobody comes back to closes itself after the idle timeout', asyn test('a session closes when the process that owns it exits, and an explicit close stops the watchdog', async ({}) => { const env = { PATCHRIGHT_CLI_WATCHDOG_INTERVAL_MS: '300' }; - const owner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 8000)'], { stdio: 'ignore' }); + // Killed below; long enough to outlive a slow launch on a loaded machine. + const owner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120000)'], { stdio: 'ignore' }); try { const opened = await runCli(['-s=owned', 'open', `--owner-pid=${owner.pid}`, '--idle-timeout=0', 'data:text/html,owned'], env); expect(opened.exitCode, opened.error).toBe(0); expect((await runCli(['list'], env)).output).toContain(`lifetime: closes when process ${owner.pid} exits`); owner.kill(); await new Promise(resolve => owner.on('exit', resolve)); - await new Promise(resolve => setTimeout(resolve, 2500)); - expect((await runCli(['list'], env)).output).toContain('(no browsers)'); + await expect.poll(async () => (await runCli(['list'], env)).output, { timeout: 15000 }).toContain('(no browsers)'); expect(watchdogLog('owned')).toContain(`owner process ${owner.pid} exited`); } finally { owner.kill(); From b98a1f73f20cf3ea34a2db64134dc20e1defe301 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 19 Oct 2026 18:29:11 +0300 Subject: [PATCH 2/3] feat(stealth): named profiles any session can open `open --profile=` (a bare name, no path separator) keeps the identity under the state root in named-profiles/, so any session in any workspace opens the same logins and cookies by name; a value with a path separator is still a user data directory, resolved to an absolute path before it reaches the daemon. Chrome runs one browser per profile. `open` now checks every live session in the registry (all workspaces) and refuses a profile another session holds, naming that session and how to free it, instead of letting the browser fail on the locked profile. Reopening the holding session itself is not a conflict. `profile-list` shows each named profile's size, the identity it presents and the session holding it (`--json` too); `profile-delete ` removes one, takes names only and is refused while the profile is held. Idea from AhaiMk01/patchright-cli; implemented on our identity file and session registry. --- CHANGELOG.md | 1 + README.md | 2 +- skills/patchright-cli/SKILL.md | 7 +- skills/patchright-cli/references/identity.md | 4 +- src/commands/profiles.ts | 185 +++++++++++++++++++ src/help/help.generated.json | 16 +- src/help/overlay.ts | 1 + src/paths.ts | 5 + src/program.ts | 4 + src/stealth/flags.ts | 13 ++ src/stealth/index.ts | 3 +- src/stealth/launchProfile.ts | 6 +- tests/integration/isolation.spec.ts | 25 +++ tests/unit/profiles.spec.ts | 44 +++++ 14 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 src/commands/profiles.ts create mode 100644 tests/unit/profiles.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 873f7d19..66203600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. The format follows [Kee - Headless sessions keep Chrome's scrollbars: Playwright's `--hide-scrollbars` made them 0 px wide where Chrome on Windows draws 15. ### Added +- Named profiles: `open --profile=` keeps an identity under the state root (`named-profiles/`) that any session in any workspace opens by name; a value with a path separator is still a directory. `open` refuses a profile another live session holds and names it, instead of Chrome failing on the locked profile. `profile-list` shows each profile's size, identity and holder; `profile-delete ` removes one and is refused while it is held. - `recording-start` / `recording-stop` (new upstream commands): record what you do in the window and print it back as code. They carry the new `recorder-injection` warning, because the recorder puts a `__pw_recorder` binding on the page main world and an overlay element on every page until it is stopped. - `install --global` (alias `-g`) is a real flag now that the daemon takes it; it needs `--skills`, and says so instead of ignoring it. - `set-color-scheme`, `set-reduced-motion`, `set-forced-colors`, `set-contrast`, `set-media` and their `clear-*` counterparts (new upstream commands): emulate CSS media features. The `set-*` commands carry the new `media-emulation` warning, because `matchMedia` then disagrees with the operating system setting that system colors and native controls follow. diff --git a/README.md b/README.md index 21de6ca5..5ca0063b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The skill tells agents when a window is worth it (interactive login or a one-tim | browser | Google Chrome, then Edge, then bundled Chromium | Patchright's validated configuration; real brands, codecs | `--browser=` | `bundled-chromium` (unsilenceable) | | mode | headless (no window); `--headed` for a maximized real window | the least intrusive mode that works everywhere; headed removes the headless tells when a task is protection-sensitive | `--headed`, `--headless`, config `stealth.headless` | `headless` (only together with `--proxy`) | | headed window | `--test-type=` added | Chrome shows an "unsupported command-line flag" bar for Patchright's `--disable-blink-features=AutomationControlled`; this switch (Playwright uses it for its own windows) suppresses it and is not page-visible as far as known | your own `--test-type` via `--extra-arg` or config | — | -| profile | persistent, one per session | a warm profile is a returning visitor | `--isolated`, `--profile=` | — | +| profile | persistent, one per session | a warm profile is a returning visitor | `--isolated`, `--profile=` | — | | viewport | `null` (the real window) | no `setDeviceMetricsOverride` | config `contextOptions.viewport` | `viewport-emulation` | | color scheme, motion, contrast | `no-override` | Playwright forces light/no-motion by default | config | — | | user agent, locale, timezone | untouched | emulation is observable | `--locale`, `--timezone`, config | `manual-geo`, `user-agent` | diff --git a/skills/patchright-cli/SKILL.md b/skills/patchright-cli/SKILL.md index 89277b53..accf649b 100644 --- a/skills/patchright-cli/SKILL.md +++ b/skills/patchright-cli/SKILL.md @@ -111,13 +111,17 @@ patchright-cli -s=shop click e6 patchright-cli -s=shop identity # throw the identity away (deletes the profile) patchright-cli -s=shop delete-data +# the same identity for any session, by name; list and remove named profiles +patchright-cli -s=checkout open https://shop.example.com --profile=shop-login +patchright-cli profile-list +patchright-cli profile-delete shop-login # without a name the session is "default"; or set PATCHRIGHT_CLI_SESSION=shop ``` - Profiles are persistent by default; `open --isolated` gives a throwaway in-memory profile (a new identity every time, which looks like a new device on every login). - Parallel sessions are parallel identities and parallel Chrome windows; tabs inside one session share the identity. - Pick a session name once per task and keep it (`login-acme`, `price-scan-42`); do not invent a new name for every command. -- `--profile=/abs/path` reuses a named identity across workspaces. +- `--profile=` opens a named identity from any session and any workspace (`--profile=acme-login`); one session holds it at a time, and `open` says which. `profile-list` shows them, `profile-delete ` removes one. A value with a path separator (`./dir`, `/abs/path`) is a profile directory. - A session nobody comes back to closes itself: in an agent harness, after 30 minutes without a command or when the agent process that opened it exits; `open` says so and `list` shows it. `--idle-timeout=2h` changes the limit, `--idle-timeout=0` and `--no-owner-pid` keep it open, `--owner-pid=` ties it to another process. The profile stays. Still `close` when a task is done. See [references/identity.md](references/identity.md) and [references/session-management.md](references/session-management.md). @@ -351,6 +355,7 @@ patchright-cli open --proxy=http://user:pass@host:3128 patchright-cli open --proxy=socks5://host:1080 --no-geoip --timezone=Europe/Berlin --locale=de-DE patchright-cli open --humanize patchright-cli open --isolated # throwaway in-memory profile (new identity) +patchright-cli open --profile=work # named profile, shared by name across sessions and workspaces patchright-cli open --profile=/path/to/profile # explicit persistent profile directory patchright-cli open --window-size=1440x900 # instead of maximized patchright-cli open --grant=geolocation # permissions for every origin (unusual for a real user) diff --git a/skills/patchright-cli/references/identity.md b/skills/patchright-cli/references/identity.md index 5f7dfd60..52fdc2a6 100644 --- a/skills/patchright-cli/references/identity.md +++ b/skills/patchright-cli/references/identity.md @@ -5,7 +5,9 @@ An identity is a persistent Chrome profile plus the geo-derived values it was la ## Where profiles live - Default: one per session name under the tool's state root (`/patchright-cli/daemon//ud--/`). `patchright-cli identity` prints the path. -- `open --profile=/abs/path` uses an explicit directory, which is how one identity is shared across workspaces. +- `open --profile=` (a bare name such as `work` or `acme-login`) uses a named profile under `/patchright-cli/named-profiles//`. Any session in any workspace opens the same identity by name; `profile-list` shows each one's size, identity and holder, `profile-delete ` removes it. +- `open --profile=./dir` or `--profile=/abs/path` (anything with a path separator) uses that directory instead. +- Chrome runs one browser per profile: `open` refuses a profile another live session holds and names that session. Work in it, or `close` it first. - `open --isolated` uses an in-memory profile: nothing survives `close`, and every open is a new identity. ## The identity file diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts new file mode 100644 index 00000000..65f7e468 --- /dev/null +++ b/src/commands/profiles.ts @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2026 b1zya (https://github.com/b1zya/patchright-cli). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Named profiles: `open --profile=work` keeps the identity (cookies, logins, history, the +// identity file) under the state root, so any session in any workspace can open it by name. +// A value with a path separator (`./work`, `/abs/dir`, `C:\dir`) is still a user data +// directory. Chrome lets one process at a time own a profile, so `open` refuses a profile a +// live session already holds instead of failing inside the browser. + +import fs from 'fs'; +import path from 'path'; + +import { namedProfilesDir } from '../paths'; +import { Session } from '../session'; +import { readIdentity } from '../stealth/identity'; + +import type { Registry, SessionFile } from '../registry'; +import type { ClientCommand } from '../stealth'; +import type { Identity } from '../stealth/identity'; + +const profileName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export function isProfileName(value: string): boolean { + return profileName.test(value) && value !== '.' && value !== '..'; +} + +// `--profile` value -> user data directory. +export function resolveProfileDir(value: string, cwd: string): string { + return isProfileName(value) ? path.join(namedProfilesDir(), value) : path.resolve(cwd, value); +} + +function sameDir(a: string, b: string): boolean { + const norm = (p: string) => path.resolve(p).replace(/[\\/]+$/, ''); + return process.platform === 'win32' || process.platform === 'darwin' + ? norm(a).toLowerCase() === norm(b).toLowerCase() + : norm(a) === norm(b); +} + +export type ProfileHolder = { session: string, workspace?: string, entry: SessionFile }; + +// Live sessions (any workspace) whose browser runs on this user data directory. +export async function profileHolders(registry: Registry, dir: string, alive: (entry: SessionFile) => Promise = entry => new Session(entry).canConnect()): Promise { + const holders: ProfileHolder[] = []; + for (const entries of registry.entryMap().values()) { + for (const entry of entries) { + const userDataDir = entry.config.browser?.userDataDir; + if (userDataDir && sameDir(userDataDir, dir) && await alive(entry)) + holders.push({ session: entry.config.name, workspace: entry.config.workspaceDir, entry }); + } + } + return holders; +} + +export function describeHolder(holder: ProfileHolder): string { + return `session '${holder.session}'${holder.workspace ? ` (workspace ${holder.workspace})` : ''}`; +} + +function sessionFlag(session: string): string { + return session === 'default' ? '' : ` -s=${session}`; +} + +// Called by `open` before anything is launched or written. +export async function assertProfileFree(registry: Registry, value: string, cwd: string, self: { session: string, daemonDir: string }): Promise { + const dir = resolveProfileDir(value, cwd); + const others = (await profileHolders(registry, dir)).filter(h => !(h.session === self.session && sameDir(h.entry.daemonDir, self.daemonDir))); + if (!others.length) + return; + const holder = others[0]; + const label = isProfileName(value) ? `Profile '${value}'` : `Profile directory ${dir}`; + throw new Error(`${label} is in use by ${describeHolder(holder)}: Chrome runs one browser per profile. Work in that session (\`patchright-cli${sessionFlag(holder.session)} \`), close it first (\`patchright-cli${sessionFlag(holder.session)} close\`), or pick another profile.`); +} + +function dirSize(dir: string): number { + let total = 0; + const stack = [dir]; + while (stack.length) { + const current = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile()) { + try { + total += fs.statSync(full).size; + } catch { + } + } + } + } + return total; +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; + if (bytes >= 1024 * 1024) + return `${Math.round(bytes / 1024 / 1024)} MB`; + return `${Math.round(bytes / 1024)} KB`; +} + +export type NamedProfile = { name: string, dir: string, size: number, identity?: Identity, inUseBy?: string }; + +export async function listNamedProfiles(registry: Registry): Promise { + const root = namedProfilesDir(); + let names: string[]; + try { + names = fs.readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory() && isProfileName(e.name)).map(e => e.name).sort(); + } catch { + return []; + } + const result: NamedProfile[] = []; + for (const name of names) { + const dir = path.join(root, name); + const holders = await profileHolders(registry, dir); + result.push({ name, dir, size: dirSize(dir), identity: readIdentity(dir), inUseBy: holders[0] ? describeHolder(holders[0]) : undefined }); + } + return result; +} + +function renderProfile(profile: NamedProfile): string { + const id = profile.identity; + const facts = [ + formatSize(profile.size), + id ? `${id.channel}, ${id.locale ?? 'host locale'}, ${id.timezone ?? 'host timezone'}${id.geo ? `, geo ${id.geo.countryCode}` : ''}` : 'never opened', + id ? `last opened ${id.updatedAt}` : undefined, + profile.inUseBy ? `in use by ${profile.inUseBy}` : 'idle', + ].filter(Boolean); + return `- ${profile.name}: ${facts.join('; ')}`; +} + +export const profileListCommand: ClientCommand = { + name: 'profile-list', + async run(ctx) { + const profiles = await listNamedProfiles(ctx.registry); + if (ctx.output.json) { + ctx.output.toolResult(JSON.stringify({ dir: namedProfilesDir(), profiles: profiles.map(p => ({ ...p, identity: p.identity ?? null, inUseBy: p.inUseBy ?? null })) }, null, 2)); + return; + } + if (!profiles.length) { + ctx.output.toolResult(`No named profiles yet. \`patchright-cli open --profile=\` creates one under ${namedProfilesDir()}.`); + return; + } + ctx.output.toolResult([`### Named profiles (${namedProfilesDir()})`, ...profiles.map(renderProfile)].join('\n')); + }, +}; + +export const profileDeleteCommand: ClientCommand = { + name: 'profile-delete', + async run(ctx) { + const name = ctx.args._[1]; + if (typeof name !== 'string' || !name) + throw new Error('Usage: patchright-cli profile-delete (see profile-list)'); + if (!isProfileName(name)) + throw new Error(`profile-delete takes a profile name, not a path: '${name}'. Delete a directory profile yourself.`); + const dir = path.join(namedProfilesDir(), name); + if (!fs.existsSync(dir)) + throw new Error(`No named profile '${name}'. See \`patchright-cli profile-list\`.`); + const holders = await profileHolders(ctx.registry, dir); + if (holders.length) + throw new Error(`Profile '${name}' is in use by ${describeHolder(holders[0])}; close it first (\`patchright-cli${sessionFlag(holders[0].session)} close\`).`); + await fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 }); + ctx.output.toolResult(ctx.output.json + ? JSON.stringify({ deleted: name, dir }, null, 2) + : `Deleted profile '${name}' (${dir}). The identity it carried (logins, cookies, history) is gone.`); + }, +}; diff --git a/src/help/help.generated.json b/src/help/help.generated.json index 9f1a4bfb..32c1e0d7 100644 --- a/src/help/help.generated.json +++ b/src/help/help.generated.json @@ -1,9 +1,9 @@ { "_generatedFrom": "patchright-core@1.64.1", - "global": "Usage: patchright-cli [args] [options]\nUsage: patchright-cli -s= [args] [options]\n\nCore:\n open [url] open the browser\n attach [name] attach to a running playwright browser\n close close the browser\n detach detach from an attached browser\n goto navigate to a url\n type type text into editable element\n click [button] perform click on a web page\n dblclick [button] perform double click on a web page\n fill fill text into editable element\n drag perform drag and drop between two elements\n drop drop files or data onto an element\n hover hover over element on page\n select select an option in a dropdown\n upload upload one or multiple files\n check check a checkbox or radio button\n uncheck uncheck a checkbox or radio button\n snapshot [target] capture page snapshot to obtain element ref\n find [text] search the page snapshot for text or a regexp, returning matching nodes with surrounding context (like search snippets)\n eval [target] evaluate javascript expression on page or element\n dialog-accept [prompt] accept a dialog\n dialog-dismiss dismiss a dialog\n resize resize the browser window\n delete-data delete session data\n\nNavigation:\n go-back go back to the previous page\n go-forward go forward to the next page\n reload reload the current page\n\nKeyboard:\n press press a key on the keyboard, `a`, `arrowleft`\n keydown press a key down on the keyboard\n keyup press a key up on the keyboard\n\nMouse:\n mousemove move mouse to a given position\n mousedown [button] press mouse down\n mouseup [button] press mouse up\n mousewheel scroll mouse wheel\n\nSave as:\n screenshot [target] screenshot of the current page or element\n pdf save page as pdf\n\nTabs:\n tab-list list all tabs\n tab-new [url] create a new tab\n tab-close [index] close a browser tab\n tab-select select a browser tab\n\nStorage:\n state-load loads browser storage (authentication) state from a file\n state-save [filename] saves the current storage (authentication) state to a file\n cookie-list list all cookies (optionally filtered by domain/path)\n cookie-get get a specific cookie by name\n cookie-set set a cookie with optional flags\n cookie-delete delete a specific cookie\n cookie-clear clear all cookies\n localstorage-list list all localstorage key-value pairs\n localstorage-get get a localstorage item by key\n localstorage-set set a localstorage item\n localstorage-delete delete a localstorage item\n localstorage-clear clear all localstorage\n sessionstorage-list list all sessionstorage key-value pairs\n sessionstorage-get get a sessionstorage item by key\n sessionstorage-set set a sessionstorage item\n sessionstorage-delete delete a sessionstorage item\n sessionstorage-clear clear all sessionstorage\n\nEmulation:\n set-color-scheme emulate the light or dark color scheme\n set-reduced-motion emulate the reduced motion preference\n set-forced-colors emulate forced colors mode\n set-contrast emulate the preferred contrast\n set-media emulate the css media type\n clear-color-scheme clear color scheme emulation\n clear-reduced-motion clear reduced motion emulation\n clear-forced-colors clear forced colors emulation\n clear-contrast clear preferred contrast emulation\n clear-media clear css media type emulation\n\nNetwork:\n requests list all network requests since loading the page. each request is numbered for use with the `request` command.\n request show full details (headers, body, response) of a single network request by its number from the `requests` command.\n request-headers print only the request headers for a single network request by its number from the `requests` command.\n request-body print only the request body for a single network request by its number from the `requests` command.\n response-headers print only the response headers for a single network request by its number from the `requests` command.\n response-body print the response body for a single network request by its number from the `requests` command. textual bodies are inlined; binary bodies are saved to a file and the path is printed.\n route mock network requests matching a url pattern\n route-list list all active network routes\n unroute [pattern] remove routes matching a pattern (or all routes)\n network-state-set set the browser network state to online or offline\n\nDevTools:\n console [min-level] list console messages\n run-code [code] run playwright code snippet\n recording-start start recording user actions\n recording-stop stop recording user actions and print them as playwright code\n tracing-start start trace recording\n tracing-stop stop trace recording\n video-start [filename] start video recording\n video-stop stop video recording\n video-chapter add a chapter marker to the video recording\n video-show-actions annotate subsequent cli/mcp actions on the page with a callout that names the action and, when styled, marks the action point and highlights the target element\n video-hide-actions stop annotating actions performed on the page\n show show playwright dashboard\n pause-at <location> run the test up to a specific location and pause there\n resume resume the test execution\n step-over step over the next call in the test\n generate-locator <target> generate a playwright locator for the given element\n highlight [target] show (or with --hide, remove) a highlight overlay for an element; `--hide` without a target hides all page highlights.\n\nWebMCP:\n webmcp-list list the webmcp tools registered by the page\n webmcp-call <name> call a webmcp tool registered by the page\n\nInstall:\n install initialize workspace\n install-browser [browser] install browser\n\nStealth:\n env show how to run commands on this system\n batch [file] run a whole flow from a script in one invocation (one command per line, # comments; \"-\" or no file reads stdin)\n wait-for-user [seconds] hand the visible browser window to the user (password, one-time code, consent dialog) and wait until the page navigates or the time runs out (default 300s), then take a fresh snapshot\n identity show the identity of the session profile\n doctor check the environment\n selftest run the detection self-test in a throwaway session\n\nBrowser sessions:\n list list browser sessions\n close-all close all browser sessions\n kill-all forcefully kill all browser sessions (for stale/zombie processes)\n\nGlobal options:\n --help [command] print help\n --json output response as JSON\n --raw output only the result value, without status and code\n --version print version", + "global": "Usage: patchright-cli <command> [args] [options]\nUsage: patchright-cli -s=<session> <command> [args] [options]\n\nCore:\n open [url] open the browser\n attach [name] attach to a running playwright browser\n close close the browser\n detach detach from an attached browser\n goto <url> navigate to a url\n type <text> type text into editable element\n click <target> [button] perform click on a web page\n dblclick <target> [button] perform double click on a web page\n fill <target> <text> fill text into editable element\n drag <startTarget> <endTarget> perform drag and drop between two elements\n drop <target> drop files or data onto an element\n hover <target> hover over element on page\n select <target> <val> select an option in a dropdown\n upload <files...> upload one or multiple files\n check <target> check a checkbox or radio button\n uncheck <target> uncheck a checkbox or radio button\n snapshot [target] capture page snapshot to obtain element ref\n find [text] search the page snapshot for text or a regexp, returning matching nodes with surrounding context (like search snippets)\n eval <func> [target] evaluate javascript expression on page or element\n dialog-accept [prompt] accept a dialog\n dialog-dismiss dismiss a dialog\n resize <w> <h> resize the browser window\n delete-data delete session data\n\nNavigation:\n go-back go back to the previous page\n go-forward go forward to the next page\n reload reload the current page\n\nKeyboard:\n press <key> press a key on the keyboard, `a`, `arrowleft`\n keydown <key> press a key down on the keyboard\n keyup <key> press a key up on the keyboard\n\nMouse:\n mousemove <x> <y> move mouse to a given position\n mousedown [button] press mouse down\n mouseup [button] press mouse up\n mousewheel <dx> <dy> scroll mouse wheel\n\nSave as:\n screenshot [target] screenshot of the current page or element\n pdf save page as pdf\n\nTabs:\n tab-list list all tabs\n tab-new [url] create a new tab\n tab-close [index] close a browser tab\n tab-select <index> select a browser tab\n\nStorage:\n state-load <filename> loads browser storage (authentication) state from a file\n state-save [filename] saves the current storage (authentication) state to a file\n cookie-list list all cookies (optionally filtered by domain/path)\n cookie-get <name> get a specific cookie by name\n cookie-set <name> <value> set a cookie with optional flags\n cookie-delete <name> delete a specific cookie\n cookie-clear clear all cookies\n localstorage-list list all localstorage key-value pairs\n localstorage-get <key> get a localstorage item by key\n localstorage-set <key> <value> set a localstorage item\n localstorage-delete <key> delete a localstorage item\n localstorage-clear clear all localstorage\n sessionstorage-list list all sessionstorage key-value pairs\n sessionstorage-get <key> get a sessionstorage item by key\n sessionstorage-set <key> <value> set a sessionstorage item\n sessionstorage-delete <key> delete a sessionstorage item\n sessionstorage-clear clear all sessionstorage\n\nEmulation:\n set-color-scheme <scheme> emulate the light or dark color scheme\n set-reduced-motion <motion> emulate the reduced motion preference\n set-forced-colors <colors> emulate forced colors mode\n set-contrast <contrast> emulate the preferred contrast\n set-media <media> emulate the css media type\n clear-color-scheme clear color scheme emulation\n clear-reduced-motion clear reduced motion emulation\n clear-forced-colors clear forced colors emulation\n clear-contrast clear preferred contrast emulation\n clear-media clear css media type emulation\n\nNetwork:\n requests list all network requests since loading the page. each request is numbered for use with the `request` command.\n request <index> show full details (headers, body, response) of a single network request by its number from the `requests` command.\n request-headers <index> print only the request headers for a single network request by its number from the `requests` command.\n request-body <index> print only the request body for a single network request by its number from the `requests` command.\n response-headers <index> print only the response headers for a single network request by its number from the `requests` command.\n response-body <index> print the response body for a single network request by its number from the `requests` command. textual bodies are inlined; binary bodies are saved to a file and the path is printed.\n route <pattern> mock network requests matching a url pattern\n route-list list all active network routes\n unroute [pattern] remove routes matching a pattern (or all routes)\n network-state-set <state> set the browser network state to online or offline\n\nDevTools:\n console [min-level] list console messages\n run-code [code] run playwright code snippet\n recording-start start recording user actions\n recording-stop stop recording user actions and print them as playwright code\n tracing-start start trace recording\n tracing-stop stop trace recording\n video-start [filename] start video recording\n video-stop stop video recording\n video-chapter <title> add a chapter marker to the video recording\n video-show-actions annotate subsequent cli/mcp actions on the page with a callout that names the action and, when styled, marks the action point and highlights the target element\n video-hide-actions stop annotating actions performed on the page\n show show playwright dashboard\n pause-at <location> run the test up to a specific location and pause there\n resume resume the test execution\n step-over step over the next call in the test\n generate-locator <target> generate a playwright locator for the given element\n highlight [target] show (or with --hide, remove) a highlight overlay for an element; `--hide` without a target hides all page highlights.\n\nWebMCP:\n webmcp-list list the webmcp tools registered by the page\n webmcp-call <name> call a webmcp tool registered by the page\n\nInstall:\n install initialize workspace\n install-browser [browser] install browser\n\nStealth:\n env show how to run commands on this system\n batch [file] run a whole flow from a script in one invocation (one command per line, # comments; \"-\" or no file reads stdin)\n wait-for-user [seconds] hand the visible browser window to the user (password, one-time code, consent dialog) and wait until the page navigates or the time runs out (default 300s), then take a fresh snapshot\n identity show the identity of the session profile\n profile-list list the named profiles (open --profile=<name>)\n profile-delete <name> delete a named profile and the identity it carries (logins, cookies, history); refused while a session holds it\n doctor check the environment\n selftest run the detection self-test in a throwaway session\n\nBrowser sessions:\n list list browser sessions\n close-all close all browser sessions\n kill-all forcefully kill all browser sessions (for stale/zombie processes)\n\nGlobal options:\n --help [command] print help\n --json output response as JSON\n --raw output only the result value, without status and code\n --version print version", "commands": { "open": { - "help": "patchright-cli open [url]\n\nOpen the browser\n\nArguments:\n [url] the url to navigate to\nOptions:\n --browser chromium channel to use: chrome (default), msedge or chromium (bundled, detectable). firefox and webkit are not supported.\n --config path to a patchright-cli config file, defaults to .playwright/patchright-cli.config.json\n --device emulate a specific device, for example \"iphone 15\".\n --headed run headed (visible window); the default is headless. use for interactive login, captchas, manual inspection, debugging you must watch, or a site that blocks headless. needs a display (xvfb-run -a on headless linux). --headful is an alias\n --mobile emulate a generic mobile device (pixel 10 for chromium, iphone 17 for webkit). mobile pages are usually lighter, which saves tokens.\n --persistent use the persistent per-session profile (already the default; see --isolated)\n --profile path to a persistent user data directory.\n --headless force headless (no window); already the default. use --headed for a visible window\n --headless-user-agent headless only: present the headed user agent of the same chrome build instead of HeadlessChrome (default on); --no-headless-user-agent keeps the raw one\n --isolated use a throwaway in-memory profile instead of the persistent per-session profile\n --proxy proxy url, e.g. http://user:pass@host:port (socks5 only without credentials)\n --proxy-bypass comma-separated hosts that bypass the proxy\n --geoip derive timezone, language and geolocation from the exit ip; on by default with --proxy, --no-geoip disables\n --locale browser locale, e.g. de-DE (overrides the geoip value)\n --timezone iana timezone, e.g. Europe/Berlin (overrides the geoip value)\n --grant comma-separated permissions to grant to every origin, e.g. geolocation\n --window-size window size as WxH instead of a maximized window\n --extra-arg additional chromium switch (repeatable, not vetted)\n --focus-emulation pass --no-focus-emulation to let pages observe the real window focus\n --humanize human-like mouse paths and typing cadence for this session\n --idle-timeout close the browser after this long without a command (30m, 2h, 90s; 0 keeps it open). default: 30m inside an agent harness, never otherwise\n --owner-pid close the browser when this process exits; defaults to PATCHRIGHT_CLI_OWNER_PID or CLAUDE_PID when set and alive, --no-owner-pid disables", + "help": "patchright-cli open [url]\n\nOpen the browser\n\nArguments:\n [url] the url to navigate to\nOptions:\n --browser chromium channel to use: chrome (default), msedge or chromium (bundled, detectable). firefox and webkit are not supported.\n --config path to a patchright-cli config file, defaults to .playwright/patchright-cli.config.json\n --device emulate a specific device, for example \"iphone 15\".\n --headed run headed (visible window); the default is headless. use for interactive login, captchas, manual inspection, debugging you must watch, or a site that blocks headless. needs a display (xvfb-run -a on headless linux). --headful is an alias\n --mobile emulate a generic mobile device (pixel 10 for chromium, iphone 17 for webkit). mobile pages are usually lighter, which saves tokens.\n --persistent use the persistent per-session profile (already the default; see --isolated)\n --profile a named profile any session can open (e.g. --profile=work; see profile-list), or a path to a user data directory (./dir, /abs/dir)\n --headless force headless (no window); already the default. use --headed for a visible window\n --headless-user-agent headless only: present the headed user agent of the same chrome build instead of HeadlessChrome (default on); --no-headless-user-agent keeps the raw one\n --isolated use a throwaway in-memory profile instead of the persistent per-session profile\n --proxy proxy url, e.g. http://user:pass@host:port (socks5 only without credentials)\n --proxy-bypass comma-separated hosts that bypass the proxy\n --geoip derive timezone, language and geolocation from the exit ip; on by default with --proxy, --no-geoip disables\n --locale browser locale, e.g. de-DE (overrides the geoip value)\n --timezone iana timezone, e.g. Europe/Berlin (overrides the geoip value)\n --grant comma-separated permissions to grant to every origin, e.g. geolocation\n --window-size window size as WxH instead of a maximized window\n --extra-arg additional chromium switch (repeatable, not vetted)\n --focus-emulation pass --no-focus-emulation to let pages observe the real window focus\n --humanize human-like mouse paths and typing cadence for this session\n --idle-timeout close the browser after this long without a command (30m, 2h, 90s; 0 keeps it open). default: 30m inside an agent harness, never otherwise\n --owner-pid close the browser when this process exits; defaults to PATCHRIGHT_CLI_OWNER_PID or CLAUDE_PID when set and alive, --no-owner-pid disables", "flags": { "browser": "string", "config": "string", @@ -836,6 +836,18 @@ "flags": {}, "args": [] }, + "profile-list": { + "help": "patchright-cli profile-list\n\nList the named profiles (open --profile=<name>): size, the identity each presents, and which session holds it.", + "flags": {}, + "args": [] + }, + "profile-delete": { + "help": "patchright-cli profile-delete <name>\n\nDelete a named profile and the identity it carries (logins, cookies, history); refused while a session holds it.\n\nArguments:\n <name> profile name from profile-list", + "flags": {}, + "args": [ + "name" + ] + }, "doctor": { "help": "patchright-cli doctor\n\nCheck the environment: installed browsers, versions, state directories and leftover upstream CLI configuration.", "flags": {}, diff --git a/src/help/overlay.ts b/src/help/overlay.ts index 4b9c2cb3..14e00ef4 100644 --- a/src/help/overlay.ts +++ b/src/help/overlay.ts @@ -80,6 +80,7 @@ export const overlay: HelpOverlay = { config: 'path to a patchright-cli config file, defaults to .playwright/patchright-cli.config.json', headed: 'run headed (visible window); the default is headless. use for interactive login, captchas, manual inspection, debugging you must watch, or a site that blocks headless. needs a display (xvfb-run -a on headless linux). --headful is an alias', persistent: 'use the persistent per-session profile (already the default; see --isolated)', + profile: 'a named profile any session can open (e.g. --profile=work; see profile-list), or a path to a user data directory (./dir, /abs/dir)', }, attach: { config: 'path to a patchright-cli config file, defaults to .playwright/patchright-cli.config.json', diff --git a/src/paths.ts b/src/paths.ts index 2c864a1d..28bd82b6 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -75,6 +75,11 @@ export function profilesDir(): string { return path.join(stateRoot(), 'profiles'); } +// Named profiles (`open --profile=<name>`): shared by every session and workspace. +export function namedProfilesDir(): string { + return path.join(stateRoot(), 'named-profiles'); +} + // A home-dir substitute with no `.playwright/cli.config.json`, so the daemon does not // merge the global config of a playwright-cli installed on the same machine. export function noGlobalConfigDir(): string { diff --git a/src/program.ts b/src/program.ts index e704c104..0a1806f0 100644 --- a/src/program.ts +++ b/src/program.ts @@ -34,6 +34,7 @@ import { Session } from './session'; import { coreBundle, corePackageJSON, dashboardAppPath, serverRegistry, utilsBundle } from './core'; import { loadUserConfig } from './config/load'; import { applyCoreEnvInProcess, coreProcessEnv } from './daemonEnv'; +import { assertProfileFree } from './commands/profiles'; import { loadHelp } from './help'; import { killAllDaemons } from './killAll'; import { bundledSkillDir, parseSkillTarget, skillInstallDir, workspaceMarker } from './paths'; @@ -358,6 +359,9 @@ async function startSession(sessionName: string, registry: Registry, clientInfo: output.warn({ key: 'config', severity: 'LOW', kind: 'notice', message }); // Resolved before the launch so a bad --idle-timeout/--owner-pid never leaves a daemon behind. const lifetime = resolveLifetime(args, loaded.config, process.env); + // Chrome runs one browser per profile; say who holds it instead of failing in the browser. + if (mode === 'open' && typeof args.profile === 'string' && args.profile && args.isolated !== true) + await assertProfileFree(registry, args.profile, cwd, { session: sessionName, daemonDir: clientInfo.daemonProfilesDir }); const profile = await stealth.resolveLaunchProfile({ mode, diff --git a/src/stealth/flags.ts b/src/stealth/flags.ts index 0f131131..1767b7d9 100644 --- a/src/stealth/flags.ts +++ b/src/stealth/flags.ts @@ -96,6 +96,19 @@ export const clientCommands: ClientCommandSpec[] = [ usage: 'identity', description: 'Show the identity of the session profile: browser channel, locale, timezone and geo it was last opened with.', }, + { + name: 'profile-list', + section: 'Stealth', + usage: 'profile-list', + description: 'List the named profiles (open --profile=<name>): size, the identity each presents, and which session holds it.', + }, + { + name: 'profile-delete', + section: 'Stealth', + usage: 'profile-delete <name>', + description: 'Delete a named profile and the identity it carries (logins, cookies, history); refused while a session holds it.', + args: [{ name: '<name>', help: 'profile name from profile-list' }], + }, { name: 'doctor', section: 'Stealth', diff --git a/src/stealth/index.ts b/src/stealth/index.ts index d1097a6a..d25add34 100644 --- a/src/stealth/index.ts +++ b/src/stealth/index.ts @@ -28,6 +28,7 @@ import { consoleCommand } from '../commands/console'; import { doctorCommand } from '../commands/doctor'; import { envCommand } from '../commands/env'; import { identityCommand } from '../commands/identity'; +import { profileDeleteCommand, profileListCommand } from '../commands/profiles'; import { selftestCommand } from '../commands/selftest'; import { waitForUserCommand } from '../commands/waitForUser'; import { defaultOutputDir, toDaemonConfig } from '../config/load'; @@ -156,6 +157,6 @@ export const noopStealth: StealthLayer = { export const stealth: StealthLayer = { resolveLaunchProfile, guardCommand, - extraCommands: [consoleCommand, configPrintCommand, identityCommand, doctorCommand, selftestCommand, envCommand, batchCommand, waitForUserCommand], + extraCommands: [consoleCommand, configPrintCommand, identityCommand, doctorCommand, selftestCommand, envCommand, batchCommand, waitForUserCommand, profileListCommand, profileDeleteCommand], clientOnlyFlags: clientOnlyFlagNames, }; diff --git a/src/stealth/launchProfile.ts b/src/stealth/launchProfile.ts index 0c3afc14..4db6a56d 100644 --- a/src/stealth/launchProfile.ts +++ b/src/stealth/launchProfile.ts @@ -23,6 +23,7 @@ import fs from 'fs'; import path from 'path'; import { detectDisplay } from '../commands/env'; +import { resolveProfileDir } from '../commands/profiles'; import { defaultOutputDir, toDaemonConfig } from '../config/load'; import { writeState } from '../humanize/state'; import { coreBundledVersionFinder, coreExecutableFinder, coreVersionForExecutable, customChannel, resolveChannel } from './browsers'; @@ -136,7 +137,7 @@ export function writeAcceptLanguagePreference(profileDir: string, locale: string } export function profileDirFor(request: LaunchRequest, channel: string, explicit?: string): string { - return explicit ? path.resolve(request.cwd, explicit) : path.join(request.daemonProfilesDir, `ud-${request.sessionName}-${channel}`); + return explicit ? resolveProfileDir(explicit, request.cwd) : path.join(request.daemonProfilesDir, `ud-${request.sessionName}-${channel}`); } export function createLaunchProfileResolver(deps: LaunchDeps) { @@ -193,8 +194,9 @@ export function createLaunchProfileResolver(deps: LaunchDeps) { const flags: DaemonFlags = {}; if (!isolated) flags.persistent = true; + // A bare name is a named profile under the state root; the daemon gets the directory. if (explicitProfile) - flags.profile = explicitProfile; + flags.profile = resolveProfileDir(explicitProfile, request.cwd); if (args.mobile) { flags.mobile = true; warnings.push(leak('device-emulation', '--mobile')); diff --git a/tests/integration/isolation.spec.ts b/tests/integration/isolation.spec.ts index 1e6b7b61..2752366c 100644 --- a/tests/integration/isolation.spec.ts +++ b/tests/integration/isolation.spec.ts @@ -236,3 +236,28 @@ test('reads the session name from PATCHRIGHT_CLI_SESSION', async ({}) => { expect(result.output).toContain(`The browser 'todo' is not open`); expect(result.output).toContain('patchright-cli -s=todo open'); }); + +test('a named profile is shared by name, held by one session at a time, listed and deleted', async ({}) => { + const opened = parseJson(await runCli(['-s=one', 'open', 'data:text/html,one', '--profile=work', '--idle-timeout=0', '--json'])); + const profileDir = path.join(homeDir(), 'named-profiles', 'work'); + expect(opened.launch?.profileDir ?? profileDir).toBe(profileDir); + expect(fs.existsSync(path.join(profileDir, 'patchright-cli.json'))).toBe(true); + + const second = await runCli(['-s=two', 'open', 'data:text/html,two', '--profile=work', '--idle-timeout=0']); + expect(second.exitCode).toBe(1); + expect(second.output + second.error).toContain(`Profile 'work' is in use by session 'one'`); + + const listed = parseJson(await runCli(['profile-list', '--json'])); + expect(listed.profiles).toEqual([expect.objectContaining({ name: 'work', dir: profileDir, inUseBy: `session 'one'` })]); + const refused = await runCli(['profile-delete', 'work']); + expect(refused.exitCode).toBe(1); + expect(refused.output + refused.error).toContain('-s=one close'); + + // Reopening the holding session with the same profile is not a conflict. + expect((await runCli(['-s=one', 'open', 'data:text/html,again', '--profile=work', '--idle-timeout=0'])).exitCode).toBe(0); + expect(await runCli(['-s=one', 'close'])).toEqual(expect.objectContaining({ exitCode: 0 })); + expect(parseJson(await runCli(['profile-list', '--json'])).profiles[0].inUseBy).toBeNull(); + expect((await runCli(['profile-delete', 'work'])).exitCode).toBe(0); + expect(fs.existsSync(profileDir)).toBe(false); + expect(parseJson(await runCli(['profile-list', '--json'])).profiles).toEqual([]); +}); diff --git a/tests/unit/profiles.spec.ts b/tests/unit/profiles.spec.ts new file mode 100644 index 00000000..40a82456 --- /dev/null +++ b/tests/unit/profiles.spec.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2026 b1zya (https://github.com/b1zya/patchright-cli). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { test, expect } from 'patchright/test'; + +import { isProfileName, profileHolders, resolveProfileDir } from '../../src/commands/profiles'; +import { namedProfilesDir } from '../../src/paths'; + +import type { Registry, SessionFile } from '../../src/registry'; + +test('a bare name is a named profile, anything with a path separator is a directory', () => { + for (const name of ['work', 'acme-login', 'shop_2', 'v1.2']) + expect(isProfileName(name), name).toBe(true); + for (const value of ['./work', '../work', '/abs/work', 'C:\work', 'a/b', '.', '..', '.hidden', '-x', '']) + expect(isProfileName(value), value).toBe(false); + expect(resolveProfileDir('work', '/ws')).toBe(path.join(namedProfilesDir(), 'work')); + expect(resolveProfileDir('./work', '/ws')).toBe(path.resolve('/ws', 'work')); +}); + +test('a profile is held by the live sessions, in any workspace, that run on its directory', async () => { + const dir = path.join(namedProfilesDir(), 'work'); + const entry = (name: string, userDataDir: string | undefined, daemonDir = '/d1'): SessionFile => + ({ file: '', daemonDir, config: { name, workspaceDir: daemonDir, browser: { userDataDir } } } as unknown as SessionFile); + const registry = { entryMap: () => new Map([ + ['/w1', [entry('one', dir), entry('other', '/elsewhere'), entry('closed', dir)]], + ['/w2', [entry('two', dir + path.sep, '/d2')]], + ]) } as unknown as Registry; + const holders = await profileHolders(registry, dir, async e => e.config.name !== 'closed'); + expect(holders.map(h => h.session)).toEqual(['one', 'two']); +}); From 09c6e47a27ab21ce4c562a6336abfb599ef45b45 Mon Sep 17 00:00:00 2001 From: Max <max.gorshunov@gmail.com> Date: Mon, 19 Oct 2026 19:20:59 +0300 Subject: [PATCH 3/3] test: stop the idle-timeout lifetime test racing a loaded machine With a 1 s idle timeout the session could close before the `list` right after `open` saw it, and the fixed 4 s sleep was sometimes too short; the test only passed on retry under parallel load. The timeout is 3 s and the test polls for the closed session and the watchdog log instead of sleeping. --- tests/integration/isolation.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/isolation.spec.ts b/tests/integration/isolation.spec.ts index 2752366c..215ed25d 100644 --- a/tests/integration/isolation.spec.ts +++ b/tests/integration/isolation.spec.ts @@ -182,13 +182,13 @@ function watchdogLog(sessionName: string): string { test('a session nobody comes back to closes itself after the idle timeout', async ({}) => { const env = { PATCHRIGHT_CLI_WATCHDOG_INTERVAL_MS: '300' }; - const opened = await runCli(['-s=idle', 'open', '--idle-timeout=1s', 'data:text/html,<title>idle'], env); + // 3s, not less: under parallel load `list` itself can take a second, and must still see the session. + const opened = await runCli(['-s=idle', 'open', '--idle-timeout=3s', 'data:text/html,idle'], env); expect(opened.exitCode, opened.error).toBe(0); - expect((await runCli(['list'], env)).output).toContain('lifetime: closes after 1s without a command'); - await new Promise(resolve => setTimeout(resolve, 4000)); - expect((await runCli(['list'], env)).output).toContain('(no browsers)'); - expect(watchdogLog('idle')).toContain('closing the browser: no command for 1s'); - expect(watchdogLog('idle')).toContain('closed'); + expect((await runCli(['list'], env)).output).toContain('lifetime: closes after 3s without a command'); + await expect.poll(async () => (await runCli(['list'], env)).output, { timeout: 15000 }).toContain('(no browsers)'); + await expect.poll(() => watchdogLog('idle'), { timeout: 5000 }).toContain('closed'); + expect(watchdogLog('idle')).toContain('closing the browser: no command for 3s'); }); test('a session closes when the process that owns it exits, and an explicit close stops the watchdog', async ({}) => {