Compare commits

...
17 Commits
Author SHA1 Message Date
daniel156161 34aaf78c66 chore(deps): update python and node dependencies
Testing / test (push) Successful in 28s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 27s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 23s
- Refresh uv.lock with uv sync --upgrade for all resolved Python deps.
- Bump anyio 4.14.2 to 4.15.1 and coverage 7.15.4 to 7.16.0 in the lock.
- Bump pydantic 2.13.4 to 2.13.5 and pydantic-core 2.46.4 to 2.46.5.
- Bump sse-starlette 3.4.8 to 3.4.11 and wcwidth 0.8.2 to 0.8.3.
- Leave direct Python dependency ranges in pyproject.toml untouched.
- Update extension build tooling to esbuild 0.28.2 and typescript 7.0.2.
- Update @types/chrome to 0.2.9 for current MV3 typings.

- Bump n8n-nodes-browser-cli from 0.3.1 to 0.3.2 after the dep refresh.
- Update the node to @noble/post-quantum 0.7.1 and @types/node 26.4.1.
- Pin n8n-workflow dev dependency to 2.37.4 instead of the floating star range.
- The star range resolved to the 2.38.1 beta while npm latest points at 2.16.0.
- 2.37.4 is the current stable dist-tag and audits clean.
- Keep peerDependencies.n8n-workflow as star so host installs stay flexible.
- Switch the node tsconfig to module/moduleResolution Node16.
- TypeScript 7 removed moduleResolution node10, which broke the old build.

- Verified with uv run pytest -q: 639 passed, 98 skipped.
- Verified with npm run check:extension: typecheck, build and 37 tests pass.
- Verified the node with npm ci, npm run build and npm test: 42 tests pass.
- npm audit --audit-level=moderate reports no vulnerabilities in both packages.
2026-09-07 11:05:17 +02:00
daniel156161 914508e2db feat(read): target any tab for page info and extraction
Testing / test (push) Successful in 40s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 31s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 39s
- Add --tab option to all extract commands and page info
- Thread tab_id through the SDK extract and page namespaces
- Route page.info with a tab_id to tabs.status for cross-tab metadata
- Accept tab_id in the MCP page_info, extract_text, extract_markdown tools
- Forward tabId in the extension page.info and extract.html handlers
- Keep the active tab as default when no tab is given

- Cover tab-scoped reads in API, CLI, and MCP tests
- Bump package and extension version to 0.16.7
- Refresh uv.lock with current dependency versions
2026-08-28 11:19:02 +02:00
daniel156161 6352d9994e Bound extension job and window-alias state
Testing / test (push) Successful in 48s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 39s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 41s
Two service-worker leaks. Running jobs were only bounded by their watchdog, so
a command flood could hold many timers and results for up to JOB_TIMEOUT_MS;
cap concurrent jobs at 32 and reject beyond that, since a clear error beats an
unresponsive worker. Window aliases were never removed, so storage kept an
entry for every window the user had ever renamed.

Prune aliases against the live window set on list, rename, and close rather
than only on close, because windows also disappear without going through
windows.close.

Also extract the repeated job summary in perf.status into a helper.
2026-08-09 20:49:37 +02:00
daniel156161 541b950519 Bound remote pool and rate-limiter memory
Both tables grew one entry per distinct key and never shrank, so a long-running
SDK client or a public serve process accumulated state for every endpoint or
identity it had ever seen. GC cannot reclaim them while the pool and limiter
still reference them.

Cap the pool at 64 endpoint buckets and the limiter at 4096 buckets. Both evict
useless state first: connections past the idle timeout the server has likely
dropped anyway, and buckets that have fully refilled, which carry no throttling
information. Only then fall back to evicting the oldest entry.

Evicting a limiter bucket resets throttling for that identity, which is the
deliberate trade: an attacker cycling identities faster than they go idle can
regain tokens, but unbounded growth would take the process down instead.
2026-08-09 20:49:35 +02:00
daniel156161 1b32410575 Isolate the remote registry in tests
Testing / remote-protocol-compat (0.16.0) (push) Successful in 40s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / test (push) Successful in 49s
Endpoint resolution consults remembered remotes, so tests that assert the
bare-domain :443 default failed on any machine with an explicit-port remote
remembered for the same host, while passing in CI. That made a real behavior
difference look like a local-only flake.

Isolate the registry for the whole suite instead of patching the two affected
tests, since any test touching endpoint resolution has the same hidden
dependency. Tests that need remembered entries still override the path.

Also replace the hardcoded personal remote host with a documentation domain.
2026-08-09 20:45:56 +02:00
daniel156161 581cd73cac Default MCP tab tools to the active tab
Testing / remote-protocol-compat (0.15.0) (push) Successful in 30s
Testing / test (push) Successful in 40s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 24s
Requiring an explicit tab_id forced every navigate or close through a
preceding tabs_list call, which costs an MCP client a full round trip just to
learn the ID the browser already considers current.

navigate and tabs_close now resolve the active tab when tab_id is omitted,
matching the screenshot tool. Resolution is explicit rather than forwarding
None into the SDK, so the acting tool knows which tab it touched; tabs_close
reports it, since closing the wrong tab is not recoverable.

This stays in the MCP layer: the SDK and CLI signatures are unchanged.
2026-08-09 20:41:41 +02:00
daniel156161 bd2a18baba Add optional stateless MCP server for the real browser
Expose a conservative browser-cli tool surface to MCP hosts without
duplicating browser-control logic: every tool is a thin adapter over the
existing Python SDK, and each call builds a fresh BrowserCLI so the real
browser stays the only owner of tab and navigation state.

The MCP process resolves BROWSER_CLI_PROFILE, BROWSER_CLI_REMOTE, and
BROWSER_CLI_KEY explicitly instead of passing None down, so a pinned server
targets one browser rather than fanning listing calls out across every
connected browser. Explicit tool arguments still win.

Tool names keep a browser_ prefix for hosts that expose raw MCP names, while
BROWSER_CLI_MCP_TOOL_PREFIX lets hosts that already namespace by server drop
it and avoid names like browser_cli_testing_browser_tabs_list.

JavaScript evaluation, raw commands, storage writes, and session import stay
unexposed, and Streamable HTTP refuses non-loopback binds because the endpoint
has no authentication of its own; remote browsers go through browser-cli's
authenticated remote transport instead.

MCP stays an optional extra, so normal installs are unaffected.
2026-08-09 20:37:47 +02:00
daniel156161 7b4d96845d fix(remote): resolve remembered explicit-port endpoints
Testing / remote-protocol-compat (0.16.0) (push) Successful in 56s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m7s
Testing / test (push) Successful in 1m27s
- Resolve bare remote hosts through the remote registry when one explicit port is stored.
- Preserve bare host behavior when no unique explicit-port registry match exists.
- Route requested remote targets through the new registry resolver.
- Add registry tests for unique and ambiguous explicit-port matches.
- Bump package and extension versions to 0.16.6.
2026-07-07 00:38:06 +02:00
daniel156161 937c6a1ce0 fix(clients): flatten scoped remote output
Testing / remote-protocol-compat (0.15.0) (push) Successful in 49s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 52s
Testing / test (push) Successful in 49s
- Render explicit --remote clients results with profile-only labels.

- Suppress remote host group headers for scoped client queries.

- Keep global and mixed client listings grouped by host.

- Update client and CLI tests for scoped remote rendering.

- Bump browser-cli and extension versions to 0.16.5.
2026-06-26 09:12:44 +02:00
daniel156161 6270d8c956 feat: add remote trust and server identity pinning
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints.
- Add remote add/list/remove commands for explicit endpoint persistence.
- Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing.
- Add URL glob matching for tabs filter and count with extension tests.
- Add n8n credential pinning for server public keys or SHA256 fingerprints.
- Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes.
- Bump browser-cli to 0.16.4 and n8n node to 0.3.1.
- Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
2026-06-26 08:53:21 +02:00
daniel156161 1ae9c33f00 ci: test current browser-cli client versions
Testing / remote-protocol-compat (0.15.0) (push) Successful in 55s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 54s
Testing / test (push) Successful in 1m2s
- Update the compatibility matrix from legacy 0.9.x clients to the supported 0.15.0 and 0.16.0 client releases.

- Keep the Gitea workflow focused on versions that match the current n8n node protocol expectations.
2026-06-19 12:01:50 +02:00
daniel156161 b91b29d516 feat(n8n): expand browser-cli node operations
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 40s
- Add UI resources and mappings for groups, windows, sessions, storage, performance, extension, and more tab/DOM/page actions.

- Remove the synthetic Gateway Health operation so exposed operations match real browser-cli commands.

- Document the expanded command/policy matrix and cover the new request mappings with tests.

- Cap the node SVG icon at 60x60, bump the n8n package to 0.3.0, and advertise client protocol version 0.16.0.
2026-06-19 11:55:36 +02:00
daniel156161 2c38cc8874 docs: link n8n node to public npm package
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 55s
Package Extension / package-extension (push) Successful in 25s
Build & Publish Package / publish (push) Successful in 27s
- Point the main README n8n integration section at the published npm package.

- Clarify that the n8n node connects directly to browser-cli serve over the authenticated encrypted TCP protocol.

- Update the n8n node README installation text to use the public community-node package name.
2026-06-19 10:02:36 +02:00
daniel156161 cea8a7e994 feat: add n8n serve node and harden remote access
- Add the n8n community node package with credentials, command mapping, direct serve TCP client, and browser-cli protocol crypto helpers.

- Cover Ed25519 signing, canonical JSON, PQ transport encryption, request mapping, and security behavior with unit tests.

- Harden serve-http with per-address rate limiting, an 8 MB request body cap, and clear warnings when binding plain HTTP beyond loopback.

- Stop one-shot --key overrides from being persisted automatically; document explicit remote trust and keep key-management behind the keys policy tier.

- Make HTML-to-Markdown conversion safer by bounding tree depth and dropping unsafe link/image URL schemes.

- Bump package and extension release metadata to 0.16.3.
2026-06-19 10:00:23 +02:00
daniel156161 7fe0e27fec feat(auth): add interactive key policy editing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 36s
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve.
- Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes.
- Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected.
- Show policy descriptions in auth keys output so each capability is easier to understand.
- Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates.
- Bump real-browser-cli to 0.16.2 and lock the new questionary dependency.
- Cover local, remote, validation, and policy-category behavior in tests.
2026-06-18 15:02:18 +02:00
daniel156161 6fa931aa36 feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels.
- Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips.
- Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely.
- Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows.
- Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0.
- Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
2026-06-18 14:24:15 +02:00
daniel156161 8dece7800f feat: group multi-browser output by source
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
2026-06-18 00:52:04 +02:00
106 changed files with 13216 additions and 2753 deletions
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
fail-fast: false
matrix:
browser-cli-client-version:
- "0.9.3"
- "0.9.5"
- "0.15.0"
- "0.16.0"
steps:
- name: Checkout
+192 -83
View File
@@ -1,20 +1,21 @@
# browser-cli
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
---
## What it does
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel, or through the optional authenticated TCP remote bridge.
---
## How it works
```
terminal / python script
terminal / python script / remote client
│
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
│ or TCP remote bridge (Ed25519 auth, optional compression)
▼
Native Messaging Host (Python process, launched by the browser)
│
@@ -33,10 +34,9 @@ terminal / python script
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
No server needs to be running beforehand. The browser manages the native host's lifecycle.
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format**
Every command is a JSON object:
```json
{ "id": "uuid", "command": "tabs.list", "args": {} }
@@ -49,11 +49,12 @@ Every response:
---
## Installation
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
### Install with uv
Once published on PyPI, install the CLI as a uv tool:
Install the CLI from PyPI as a uv tool, then register the native host:
```sh
uv tool install real-browser-cli
@@ -69,98 +70,173 @@ For better remote-response compression, install the optional `fast` extra:
uv tool install "real-browser-cli[fast]"
```
To expose the conservative MCP tool surface, install the optional `mcp` extra:
```sh
uv tool install "real-browser-cli[mcp]"
```
To upgrade later:
```sh
uv tool upgrade real-browser-cli
```
### Add the browser extension
Install the extension from its public store listing (the `install` command prints the right link for you):
- Chrome / Chromium / Brave / Edge / Vivaldi — [Chrome Web Store](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp)
- Firefox — [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/browser-cli/)
The native host manifest trusts both the published store ID and the unpacked development ID, so the store extension works out of the box. If you are hacking on the extension yourself, run `browser-cli install <browser> --dev` for the unpacked / temporary-add-on load steps instead.
### Install from source
```sh
git clone <repo>
cd browser-cli
uv sync
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
npm ci && npm run build:extension # build the unpacked extension bundles
uv run browser-cli install brave --dev # --dev prints unpacked-load steps; or: chrome, chromium, edge, vivaldi, firefox
```
The `install` command will:
1. Ask you to load the browser-specific extension package
2. Show the stable extension ID used by that browser family
3. Write the native messaging manifest to your OS so the browser can find the host
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
Omit `--dev` to be pointed at the public store listing instead of loading the unpacked build.
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
The `install` command will:
1. Write the native messaging manifest to your OS so the browser can find the host
2. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
3. Print the public store link for installing the extension (or, with `--dev`, the unpacked / temporary-add-on load steps)
After install, add the extension from the store link above and **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper imports the installed `browser_cli.native.host` entry point. On Windows the install command also registers the host in the current user's Registry for the selected browser.
---
## Project structure
```text
browser-cli/
├── browser_cli/
│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
│ ├── cli.py # Click CLI entry point
│ ├── client/ # Client-side command routing used by CLI and SDK
│ │ ├── core.py # send_command and remote command routing
│ │ ├── targets.py # Browser target discovery and socket resolution
│ │ ├── auth.py # Remote auth fields and key lookup
│ │ └── messages.py # Request/response helpers
│ ├── models.py # Tab and Group helper models
│ ├── native/ # Native messaging host internals
│ │ ├── host.py # Browser-launched native host entry point
│ │ ├── local_server.py # Local CLI IPC server
│ │ └── protocol.py # Chrome Native Messaging framing
│ ├── remote/ # Client-side remote browser support
│ │ ├── transport.py # TCP/TLS remote transport
│ │ └── registry.py # Saved remote endpoints/keys
│ └── commands/
│ ├── navigate.py # nav open/reload/back/forward/focus
│ ├── search.py # search engine shortcuts
│ ├── tabs.py # tab management
│ ├── groups.py # tab group management
│ ├── windows.py # window management
│ ├── dom.py # DOM querying and interaction
│ ├── extract.py # content extraction
│ └── session.py # session save/load
│ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
│ ├── async_sdk.py # AsyncBrowserCLI
│ ├── cli.py # Click root command and native-host entry point
│ ├── client/ # send_command path, local/remote routing, message helpers
│ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
│ ├── commands/ # CLI presentation layer over the SDK namespaces
│ ├── native/ # Browser-launched Native Messaging host + local IPC server
│ ├── remote/ # TCP remote client transport and saved endpoint registry
│ ├── serve/ # Authenticated TCP server runtime
│ ├── transport/ # JSON/msgpack response encoding and compression helpers
│ ├── markdown/ # HTML-to-Markdown extraction helpers
│ ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
│ └── models.py # Tab, Group, BrowserCounts dataclasses
├── extension/
│ ├── manifest.json # MV3 extension manifest
│ ├── content.js # Content-script helpers
│ └── src/ # TypeScript source split by command area
│ ├── index.ts # Builds generated extension/background.js
│ └── content/ # Builds generated extension/content-dispatch.js
├── examples/
│ ├── demo.py # Python SDK walkthrough
│ └── demo.sh # Bash CLI walkthrough
├── tests/
│ ├── conftest.py # shared pytest fixtures
│ ├── test_api.py
│ ├── test_cli.py
│ ├── test_dom.py
│ ├── test_extract.py
│ ├── test_groups.py
│ ├── test_nav.py
│ ├── test_session.py
│ ├── test_tabs.py
│ └── test_windows.py
├── com.browsercli.host.json # native messaging manifest template
├── pyproject.toml # package metadata and CLI entry point
└── uv.lock # locked dependencies for uv
│ ├── manifest.json # Chromium MV3 manifest
│ └── src/ # TypeScript WebExtension source
│ ├── index.ts # Background/service-worker bundle entry
│ ├── content-dispatch.ts
│ ├── commands/ # Browser-side command implementations
│ ├── content/ # DOM/extract/Markdown logic injected into pages
│ └── core/ # Shared extension helpers
├── examples/ # Python and shell walkthroughs
├── scripts/ # Packaging and release helper scripts
├── tests/ # pytest suite
├── package.json # Extension build/test/package scripts
├── pyproject.toml # Python package metadata
└── uv.lock # locked Python dependencies
```
---
## Stateless MCP server
The optional MCP adapter exposes a small, typed subset of the Python SDK for
MCP hosts such as Claude Desktop, Claude Code, Cursor, or VS Code. It controls
the same real browser; it does not launch a headless browser or duplicate the
browser command implementation.
Install and run the local stdio server:
```sh
uv tool install "real-browser-cli[mcp]"
browser-cli-mcp
```
Example MCP host configuration:
```json
{
"mcpServers": {
"browser-cli": {
"command": "browser-cli-mcp"
}
}
}
```
The server is stateless at the MCP layer. Every tool call creates a fresh
`BrowserCLI` SDK client, and browser state remains in the real browser. Pass
`browser`, `remote`, and `key` on a tool call when a specific local profile or
authenticated browser-cli remote is required.
`browser_navigate`, `browser_tabs_close`, and `browser_screenshot` take an
optional `tab_id` and act on the active tab when it is omitted, so a caller
does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
reports the tab it closed.
Available tools:
- `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
- `browser_navigate`, `browser_page_info`
- `browser_extract_text`, `browser_extract_markdown`
- `browser_dom_query`, `browser_dom_click`, `browser_dom_type`
- `browser_screenshot`
Generic JavaScript evaluation, raw browser commands, storage writes, and
session import are intentionally not exposed.
### Pinning one browser and naming tools
Set `BROWSER_CLI_PROFILE` to pin every call from an MCP server to one browser,
so tools do not have to pass `browser` and listing tools do not fan out across
all connected browsers:
```json
{
"mcpServers": {
"browser-cli-testing": {
"command": "browser-cli-mcp",
"env": {
"BROWSER_CLI_PROFILE": "testing",
"BROWSER_CLI_MCP_TOOL_PREFIX": ""
}
}
}
}
```
`BROWSER_CLI_REMOTE` and `BROWSER_CLI_KEY` pin an authenticated remote the same
way. Explicit `browser`, `remote`, and `key` tool arguments still win.
Tool names carry a `browser_` prefix by default so they stay unambiguous in
hosts that expose raw MCP tool names. Hosts that already prefix tools with the
server name produce stutter such as `browser_cli_testing_browser_tabs_list`;
setting `BROWSER_CLI_MCP_TOOL_PREFIX` to an empty string drops the built-in
prefix and yields `browser_cli_testing_tabs_list`. Any other value replaces the
prefix.
For local development and testing, Streamable HTTP is also available:
```sh
browser-cli-mcp --transport streamable-http --port 8000
# endpoint: http://127.0.0.1:8000/mcp
```
HTTP uses stateless JSON responses and intentionally refuses non-loopback bind
addresses because this MCP endpoint has no independent authentication. For a
browser on another machine, keep MCP local and pass an authenticated
browser-cli `remote` target to each tool call.
---
## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
### Navigation (`nav`)
```sh
# Open a URL (no focus stealing by default)
browser-cli nav open https://example.com
@@ -182,7 +258,6 @@ browser-cli nav focus github # focuses first tab whose URL contains "
```
### Search
Each search command opens the search results in your browser using the same flags as `nav open`.
```sh
@@ -206,12 +281,12 @@ browser-cli search so click choices
```
### Tabs
```sh
browser-cli tabs list # list all open tabs (all windows)
browser-cli tabs count # count all tabs
browser-cli tabs count youtube # count tabs matching URL pattern
browser-cli tabs filter youtube # list tabs matching URL pattern
browser-cli tabs count youtube # count tabs whose URL contains "youtube"
browser-cli tabs filter youtube # list tabs whose URL contains "youtube"
browser-cli tabs filter 'twitch.tv/*' # glob: list every twitch.tv tab
browser-cli tabs query "pull request" # search tabs by URL or title
browser-cli tabs active 1234 # switch browser focus to tab
@@ -233,8 +308,11 @@ browser-cli tabs sort --by time
browser-cli tabs merge-windows # pull all tabs into the current window
```
### Tab groups
> URL patterns for `tabs filter` / `tabs count` match against the full tab URL.
> A plain string is a case-sensitive substring (`youtube`); a pattern containing
> `*` or `?` is treated as a glob (`twitch.tv/*`, `*.twitch.tv`).
### Tab groups
```sh
browser-cli groups list # list all tab groups
browser-cli groups count # count groups
@@ -256,7 +334,6 @@ browser-cli groups move 42 -l # short left alias
```
### Windows
```sh
browser-cli windows list # list all windows
browser-cli windows open # open a new window
@@ -266,7 +343,6 @@ browser-cli windows close 1 # close a window
```
### DOM
These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`.
```sh
@@ -279,7 +355,6 @@ browser-cli dom type "#search" "hello" # type text into an input
```
### Extract
```sh
browser-cli extract links # all <a href> links on the page
browser-cli extract images # all <img> tags (src + alt)
@@ -291,7 +366,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
```
### Sessions
A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared.
```sh
@@ -305,7 +379,6 @@ browser-cli session auto-save off
```
### Misc
```sh
browser-cli clients # show connected browser info from the registry
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
@@ -315,17 +388,56 @@ browser-cli completion zsh # print setup instructions
browser-cli completion zsh --script # output raw completion script
```
### Remote control, auth, and gateways
```sh
# On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
browser-cli auth trust "$PUBKEY"
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
# Allow remote browser control (navigation, clicks); safe-only otherwise
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --allow-control
# Per-key authorization (inline in authorized_keys) + a tighter rate limit
browser-cli auth trust "$PUBKEY" --name ci-bot --allow-read-page --allow-control
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --rate-limit 20
# From another machine
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
browser-cli --remote browser-host.example:8765 clients
# Local HTTP JSON gateway for small integrations
browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Use `browser-cli remote trust ENDPOINT KEY` to remember a key for later calls. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
#### n8n integration
The n8n community node is published as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli) on npm. It talks directly to a remote `browser-cli serve` endpoint over the same Ed25519-authenticated, ML-KEM-encrypted TCP protocol as the CLI remote client. Install it from n8n's Community Nodes UI, run `browser-cli serve` on the browser machine, paste the client key into the node credential, and drive tabs/DOM/extraction/raw commands from a workflow. See [`n8n-nodes-browser-cli/README.md`](n8n-nodes-browser-cli/README.md).
#### Security model
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
- **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`, `keys`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` when adding a key, or change it later with `auth policy <pubkey|name> …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy.
- **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own.
- **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser.
- **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address.
- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use.
For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection.
---
## Python SDK
```python
from browser_cli import AsyncBrowserCLI, BrowserCLI
b = BrowserCLI()
```
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
```python
# Navigation ── b.nav
@@ -454,7 +566,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
```
**Error handling**
```python
from browser_cli import BrowserCLI, BrowserNotConnected
@@ -480,12 +591,12 @@ counts = b.tabs.count()
if isinstance(counts, BrowserCounts):
print(counts.total)
print(counts.by_browser)
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
```
---
## Example scripts
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
```sh
@@ -496,7 +607,6 @@ bash examples/demo.sh
---
## Development
```sh
npm ci
npm run check:extension # type-check, build extension bundles, syntax-check bundle
@@ -510,11 +620,12 @@ nix-shell # automatically runs npm ci when node_modules is missing/outdated
npm run check:extension
```
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before loading the unpacked `extension/` directory; `browser-cli install <browser> --dev` prints the per-browser load steps. On NixOS, use `nix-shell` first if npm is not installed globally.
Packaging:
```bash
just publish # build to /tmp/dist-browser-cli and publish with .env credentials
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
@@ -536,7 +647,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
---
## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
@@ -545,7 +655,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
---
## License
PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE).
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
+6
View File
@@ -17,11 +17,14 @@ from browser_cli.auth.agent import (
)
from browser_cli.auth.keys import (
add_authorized_key,
format_authorized_line,
generate_keypair,
load_authorized_keys,
load_authorized_keys_with_names,
load_authorized_keys_with_policies,
load_private_key,
public_key_hex,
set_authorized_key_policy,
)
from browser_cli.auth.pq import (
new_nonce,
@@ -51,9 +54,11 @@ __all__ = [
"agent_list_keys",
"agent_sign_raw",
"canonical_payload",
"format_authorized_line",
"generate_keypair",
"load_authorized_keys",
"load_authorized_keys_with_names",
"load_authorized_keys_with_policies",
"load_private_key",
"new_nonce",
"pq_decrypt",
@@ -62,6 +67,7 @@ __all__ = [
"pq_kex_server_decapsulate",
"pq_kex_server_keypair",
"public_key_hex",
"set_authorized_key_policy",
"sign",
"verify",
]
+75 -9
View File
@@ -29,31 +29,97 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _parse_authorized_line(line: str) -> tuple[str, str, list[str] | None] | None:
"""Parse one authorized_keys line into (pubkey, name, categories).
Line format: ``<pubkey> [name words...] [allow:cat,cat,...]``. The optional
``allow:`` token may appear anywhere after the pubkey (conventionally last);
the remaining words form the name. ``categories`` is None when no ``allow:``
token is present (the key falls back to the server-wide policy), or a list of
category strings (possibly empty) otherwise.
"""
line = line.strip()
if not line or line.startswith("#"):
return None
tokens = line.split()
pubkey = tokens[0]
categories: list[str] | None = None
name_tokens: list[str] = []
for tok in tokens[1:]:
if tok.startswith("allow:"):
categories = [c for c in tok[len("allow:"):].split(",") if c]
else:
name_tokens.append(tok)
return pubkey, " ".join(name_tokens), categories
def format_authorized_line(pub_hex: str, name: str = "", categories: list[str] | None = None) -> str:
"""Render an authorized_keys line. Inverse of :func:`_parse_authorized_line`."""
parts = [pub_hex]
if name:
parts.append(name)
if categories is not None:
parts.append("allow:" + ",".join(categories))
return " ".join(parts)
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
return [(pubkey, name) for pubkey, name, _cats in load_authorized_keys_with_policies(path)]
def load_authorized_keys_with_policies(path: Path) -> list[tuple[str, str, list[str] | None]]:
"""Return list of (pubkey_hex, name, categories) triples. categories is None when unset."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
parsed = _parse_authorized_line(line)
if parsed is not None:
result.append(parsed)
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: list[str] | None = None) -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
line = format_authorized_line(pub_hex, name, categories) + "\n"
with open(path, "a", encoding="utf-8") as file:
file.write(line)
return True
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
"""Update the per-key policy for a trusted key.
``identifier`` may be the full public key or an exact key name. ``categories``
is written as the ``allow:`` token; ``None`` removes the token so the key uses
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
no key matched, and raises ``ValueError`` for ambiguous names.
"""
if not path.exists():
return None
wanted = identifier.strip()
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
matches: list[tuple[int, str, str, str]] = []
for index, line in enumerate(lines):
parsed = _parse_authorized_line(line)
if parsed is None:
continue
pubkey, name, _cats = parsed
if pubkey.lower() == wanted.lower() or (name and name == wanted):
newline = "\n" if line.endswith("\n") else ""
matches.append((index, pubkey, name, newline))
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
index, pubkey, name, newline = matches[0]
lines[index] = format_authorized_line(pubkey, name, categories) + newline
path.write_text("".join(lines), encoding="utf-8")
return pubkey, name
+57
View File
@@ -0,0 +1,57 @@
"""Persistent server identity keys for SSH-style remote host pinning."""
from __future__ import annotations
import json
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
from browser_cli.constants import CONFIG_DIR
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
"""Load the persistent serve identity key, creating it on first start."""
if path.exists():
key = load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"server identity key is not Ed25519: {path}")
return key
path.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
fd = path.open("xb")
try:
fd.write(pem)
finally:
fd.close()
path.chmod(0o600)
return key
def public_key_hex(key: Ed25519PrivateKey) -> str:
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _signed_challenge_fields(challenge: dict) -> dict:
return {key: value for key, value in challenge.items() if key != "server_sig"}
def challenge_payload(challenge: dict) -> bytes:
"""Canonical bytes signed by the server identity key."""
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
return key.sign(challenge_payload(challenge)).hex()
def verify_challenge_signature(challenge: dict) -> bool:
pub_hex = challenge.get("server_pubkey")
sig_hex = challenge.get("server_sig")
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
return True
except (InvalidSignature, ValueError):
return False
+1 -19
View File
@@ -5,9 +5,6 @@ browser-cli — Control your running browser from the terminal.
import click
import os
import shutil
import re
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
from rich.console import Console
from browser_cli.commands.navigate import nav_group
@@ -35,7 +32,7 @@ from browser_cli.commands.serve_http import cmd_serve_http
from browser_cli.commands.watch import watch_group
from browser_cli.commands.workspace import workspace_group
from browser_cli.commands.raw import cmd_command
from browser_cli.constants import PYPI_PACKAGE_NAME
from browser_cli.version_manager import project_version as _project_version
console = Console()
@@ -53,21 +50,6 @@ def _patched_group_shell_complete(self, ctx, incomplete):
click.Group.shell_complete = _patched_group_shell_complete
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
try:
return package_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
def _print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
+2
View File
@@ -7,6 +7,7 @@ from browser_cli.client.core import (
_send_remote,
_send_remote_async,
active_browser_targets,
collect_browser_clients,
remote_browser_targets,
remote_browser_targets_async,
remote_target_for_alias,
@@ -39,6 +40,7 @@ __all__ = [
"_send_remote",
"_send_remote_async",
"active_browser_targets",
"collect_browser_clients",
"display_browser_name",
"remote_browser_targets",
"remote_browser_targets_async",
-4
View File
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
private_key = load_private_key(key_spec)
if key is not None:
remote_registry.save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS:
@@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil
msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
private_key = await asyncio.to_thread(load_private_key, key_spec)
if key is not None:
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS:
+235 -8
View File
@@ -15,11 +15,39 @@ from browser_cli import local_transport
from browser_cli.client import auth, messages, targets as target_discovery
from browser_cli.client.targets import BrowserTarget
from browser_cli.remote import registry as remote_registry
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _remote_display_name
from browser_cli.endpoints import _remote_display_name, display_browser_name
from browser_cli.registry import load_registry
from browser_cli.remote.transport import _send_remote, _send_remote_async
def _run_concurrent(factories: list) -> list:
"""Run async thunks concurrently, returning results in order.
Each item in *factories* is a zero-arg callable returning a coroutine. The
return list mirrors the input order; a thunk that raises yields its exception
object in that slot (callers filter as they would in a sequential loop). Falls
back to sequential execution if an event loop is already running on this
thread (e.g. inside the async serve handler), where ``asyncio.run`` is illegal.
"""
if not factories:
return []
async def _gather():
return await asyncio.gather(*(factory() for factory in factories), return_exceptions=True)
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_gather())
# An event loop is already running on this thread (e.g. the async serve
# handler), where asyncio.run is illegal. Run the gather on a worker thread
# that has no loop of its own, preserving concurrency and result order.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(lambda: asyncio.run(_gather())).result()
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for item in items or []:
@@ -27,6 +55,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
display = str(item.get("displayName") or profile)
display_name = _remote_display_name(endpoint, profile, display)
browser_name = item.get("browserName") or item.get("name")
version = item.get("version")
extension_version = item.get("extensionVersion")
targets.append(
BrowserTarget(
profile=profile,
@@ -35,6 +65,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
remote=endpoint,
browser_name=str(browser_name) if browser_name else None,
display_group=display_name.rsplit(":", 1)[0],
version=str(version) if version else None,
extension_version=str(extension_version) if extension_version else None,
)
)
return targets
@@ -48,12 +80,20 @@ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool
)
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
endpoints = list(remote_registry.load_remotes())
if not endpoints:
return []
results = _run_concurrent([
(lambda ep=ep: asyncio.to_thread(remote_browser_targets, ep, key=key, suppress_pq_warning=suppress_pq_warning))
for ep in endpoints
])
targets: list[BrowserTarget] = []
for endpoint in remote_registry.load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
except (BrowserNotConnected, RuntimeError):
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
targets.extend(result)
return targets
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
@@ -111,6 +151,192 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
return targets
def _cached_client_row(target: BrowserTarget, *, scoped: bool = False) -> dict | None:
"""Build a clients row from a target's discovery data, skipping a roundtrip.
Returns None when the remote didn't advertise its version (older serve), so
callers fall back to an explicit ``clients.list`` query. When *scoped* is
true, the caller already selected one remote host, so render profile-only
labels instead of adding a host group header.
"""
if target.version is None and target.extension_version is None:
return None
row = {
"profile": target.profile if scoped else target.display_name,
"name": target.browser_name or "",
"version": target.version or "",
"extensionVersion": target.extension_version or "",
}
if target.display_group and not scoped:
row["profileGroup"] = target.display_group
return row
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
rows = []
for item in result or []:
row = dict(item)
row["profile"] = label
if profile_group:
row["profileGroup"] = profile_group
rows.append(row)
return rows
async def _client_rows_async(
label: str,
*,
profile: str | None = None,
remote: str | None = None,
key=None,
suppress_pq_warning: bool = False,
profile_group: str | None = None,
) -> list[dict]:
"""Return display-ready clients.list rows for one browser target."""
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
result = await asyncio.to_thread(
send_command,
"clients.list",
profile=profile,
remote=remote,
key=key,
**kwargs,
)
return _rows_from_result(result, label, profile_group)
def collect_browser_clients(
*,
browser_alias: str | None = None,
remote: str | None = None,
key=None,
registry_path=None,
) -> list[dict]:
"""Return display-ready browser client rows for CLI/SDK consumers.
Rows preserve the CLI-facing shape: ``profile``, optional ``profileGroup``,
``name``, ``version``, and ``extensionVersion``.
"""
rows: list[dict] = []
if not remote and browser_alias:
resolved = remote_target_for_alias(browser_alias)
if not resolved:
return rows
targets = remote_browser_targets(resolved.remote)
uncached = []
for target in targets:
cached = _cached_client_row(target)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=resolved.remote,
key=key,
profile_group=t.display_group,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
if remote:
targets = remote_browser_targets(remote, key=key)
if browser_alias:
targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
if targets:
uncached = []
for target in targets:
cached = _cached_client_row(target, scoped=True)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.profile,
profile=t.profile,
remote=remote,
key=key,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for item in result or []:
row = dict(item)
row["profile"] = row.get("profile") or browser_alias or "remote"
rows.append(row)
return rows
path = registry_path or target_discovery.REGISTRY_PATH
profiles: dict[str, str] = load_registry(path) if path.exists() else {}
local_items = list(profiles.items())
remote_targets = []
cached_remote_rows = [] # deferred so local profiles still render first
for target in active_browser_targets(suppress_pq_warning=True):
if target.remote is None:
continue
cached = _cached_client_row(target)
if cached is not None:
cached_remote_rows.append(cached) # discovery already carried version/extVersion — no extra roundtrip
else:
remote_targets.append(target)
factories = [
(lambda name=name, sock=sock: _client_rows_async(
display_browser_name(name, sock), profile=name, profile_group="local",
))
for name, sock in local_items
] + [
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=t.remote,
suppress_pq_warning=True,
profile_group=t.display_group,
))
for t in remote_targets
]
results = _run_concurrent(factories)
for (name, sock), result in zip(local_items, results[:len(local_items)]):
if isinstance(result, (BrowserNotConnected, RuntimeError)):
rows.append({
"profile": display_browser_name(name, sock),
"profileGroup": "local",
"name": "—",
"version": "—",
"extensionVersion": "disconnected",
})
elif isinstance(result, BaseException):
raise result
else:
rows.extend(result)
for result in results[len(local_items):]:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
rows.extend(cached_remote_rows)
return rows
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
@@ -159,11 +385,12 @@ def send_command(
return messages.decode_response(response)
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
async def remote_browser_targets_async(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
"""Async variant of :func:`remote_browser_targets`."""
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
return _remote_target_items(
endpoint,
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
await send_command_async("browser-cli.targets", remote=endpoint, key=key, **kwargs),
)
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
+2 -2
View File
@@ -5,8 +5,8 @@ import uuid
from typing import Any
from browser_cli import transport
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
from browser_cli.remote.registry import resolve_remote_endpoint
def base_message(command: str, args: dict | None) -> dict:
return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
@@ -14,7 +14,7 @@ def base_message(command: str, args: dict | None) -> dict:
def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
return requested_profile, _normalize_endpoint(remote_endpoint) if remote_endpoint else None
return requested_profile, resolve_remote_endpoint(remote_endpoint) if remote_endpoint else None
def encode_payload(msg: dict) -> bytes:
return json.dumps(msg).encode("utf-8")
+5
View File
@@ -19,6 +19,11 @@ class BrowserTarget:
remote: str | None = None
browser_name: str | None = None
display_group: str | None = None
# Populated from a remote ``browser-cli.targets`` response when the remote is
# new enough to advertise them, letting ``clients`` skip a redundant
# ``clients.list`` roundtrip. None means "unknown — fall back to a query".
version: str | None = None
extension_version: str | None = None
def is_reachable_unix_endpoint(endpoint: str) -> bool:
"""Return True when a Unix socket path exists and accepts connections."""
+16 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass
SAFE_COMMANDS = {
"browser-cli.targets",
"clients.list",
"extension.capabilities",
"extension.info",
@@ -74,15 +75,24 @@ DANGEROUS_PREFIXES = (
"storage.",
)
# Server-side key-management control commands. Gated separately so a key can be
# trusted for browser use without also being able to list or add trusted keys.
KEY_COMMANDS = {
"browser-cli.auth.keys",
"browser-cli.auth.trust",
"browser-cli.auth.policy",
}
@dataclass(frozen=True)
class CommandPolicy:
allow_read_page: bool = False
allow_control: bool = False
allow_dangerous: bool = False
allow_keys: bool = False
@classmethod
def unrestricted(cls) -> "CommandPolicy":
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True)
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True, allow_keys=True)
def _is_control(command: str) -> bool:
if command in CONTROL_COMMANDS:
@@ -93,6 +103,8 @@ def _is_control(command: str) -> bool:
def command_category(command: str) -> str:
name = str(command or "")
if name in KEY_COMMANDS:
return "keys"
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
return "dangerous"
if name in READ_PAGE_COMMANDS:
@@ -113,7 +125,9 @@ def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
return
if category == "dangerous" and policy.allow_dangerous:
return
if category == "keys" and policy.allow_keys:
return
raise PermissionError(
f"Raw command '{command}' is {category} and blocked by default; "
"use --allow-read-page, --allow-control, or --allow-dangerous explicitly"
"use --allow-read-page, --allow-control, --allow-dangerous, or --allow-keys explicitly"
)
+121 -47
View File
@@ -22,60 +22,134 @@ tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab I
def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
def command_policy_options(fn):
"""Reusable raw-command safety flags for /command-like entry points."""
fn = click.option(
"--allow-all",
is_flag=True,
help="Allow every command (equivalent to --allow-read-page --allow-control --allow-dangerous --allow-keys)",
)(fn)
fn = click.option(
"--allow-keys",
is_flag=True,
help="Allow key-management commands (list/trust authorized keys over --remote)",
)(fn)
fn = click.option(
"--allow-dangerous",
is_flag=True,
help="Allow high-risk commands such as dom.eval, storage.*, screenshots",
)(fn)
fn = click.option(
"--allow-control",
is_flag=True,
help="Allow browser-control commands such as nav.*, tabs.close, dom.click",
)(fn)
fn = click.option(
"--allow-read-page",
is_flag=True,
help="Allow page-content read commands such as extract.* and dom.text",
)(fn)
return fn
def command_policy_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
"""Build a CommandPolicy from shared raw-command safety flags."""
from browser_cli.command_security import CommandPolicy
if allow_all:
return CommandPolicy.unrestricted()
return CommandPolicy(
allow_read_page=allow_read_page,
allow_control=allow_control,
allow_dangerous=allow_dangerous,
allow_keys=allow_keys,
)
def command_categories_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
"""Convert the shared --allow-* flags into a category list, or None if none were set.
None means "no explicit policy" — the key falls back to the server-wide default.
"""
if allow_all:
return ["all"]
cats = []
if allow_read_page:
cats.append("read-page")
if allow_control:
cats.append("control")
if allow_dangerous:
cats.append("dangerous")
if allow_keys:
cats.append("keys")
return cats or None
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result.
"""Render a count result.
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items():
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser", no_wrap=True)
table.add_column(f"{noun.capitalize()}s", justify="right")
rendered_groups: set[str] = set()
for name, count in result.by_browser.items():
group = result.browser_groups.get(name)
if group:
if group not in rendered_groups:
group_total = sum(
browser_count
for browser_name, browser_count in result.by_browser.items()
if result.browser_groups.get(browser_name) == group
)
table.add_row(f"[bold]{group}[/bold]", str(group_total))
rendered_groups.add(group)
display_name = name.removeprefix(f"{group}:")
table.add_row(f" {display_name}", str(count))
else:
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options.
"""Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
return wrapper
return wrapper
+288 -9
View File
@@ -8,6 +8,8 @@ from pathlib import Path
import click
from rich.console import Console
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
console = Console()
@click.group("auth")
@@ -39,9 +41,15 @@ def cmd_auth_keygen(output, force):
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@command_policy_options
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
@handle_errors
def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Add a public key to the authorized keys file (locally or on a remote serve host).
Pass --allow-read-page/--allow-control/--allow-dangerous/--allow-all to record a
per-key policy (an ``allow:`` token); without any, the key uses the server default.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
@@ -53,34 +61,125 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
policy_label = f" [dim]allow:{','.join(categories)}[/dim]" if categories else ""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
args = {"pubkey": pubkey, "name": name}
if categories is not None:
args["allow"] = categories
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
args=args,
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]{policy_label}")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey, name)
added = add_authorized_key(path, pubkey, name, categories)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]{policy_label}")
console.print(f" File: {path}")
console.print("\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("policy")
@click.argument("identifier", required=False)
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
@command_policy_options
@click.pass_context
@handle_errors
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Change a trusted key's per-key policy.
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
an interactive terminal to pick a key first, then edit the policy with real
checkbox prompts. Use --safe for an explicit safe-only override,
--server-default to remove the override, or one or more --allow-* flags for
scriptable/non-interactive usage.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
if modes > 1:
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
sys.exit(1)
is_interactive = click.get_text_stream("stdin").isatty()
current_categories = None
if not identifier:
if not is_interactive:
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
sys.exit(1)
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
identifier = entry.get("pubkey") or entry.get("name") or ""
current_categories = entry.get("allow")
elif modes == 0 and is_interactive:
entry = _find_policy_entry(ctx, keys_file, identifier)
current_categories = entry.get("allow") if entry else None
if server_default:
categories = None
elif safe_only:
categories = []
elif explicit_allow:
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
else:
if not is_interactive:
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
sys.exit(1)
categories = _prompt_policy_categories(identifier, current_categories)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.policy",
args={"identifier": identifier, "allow": categories},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
name = (result or {}).get("name") or ""
pubkey = (result or {}).get("pubkey") or identifier
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
try:
updated = set_authorized_key_policy(path, identifier, categories)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
if updated is None:
console.print(f"[red]Trusted key not found:[/red] {identifier}")
sys.exit(1)
pubkey, name = updated
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
console.print(f" File: {path}")
@auth_group.command("show")
@click.option(
"--key",
@@ -123,6 +222,7 @@ def cmd_auth_show(key_src):
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
@handle_errors
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
@@ -138,9 +238,9 @@ def cmd_auth_keys(ctx, keys_file):
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
entries = [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
source_label = str(path)
if not entries:
@@ -151,7 +251,186 @@ def cmd_auth_keys(ctx, keys_file):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
table.add_column("Policy")
table.add_column("Description")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
allow = entry.get("allow")
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
console.print(table)
def _load_policy_entries(ctx, keys_file):
"""Load trusted-key entries for interactive selection."""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
return send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
) or []
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
def _find_policy_entry(ctx, keys_file, identifier: str):
"""Find the current key entry so the checkbox prompt can preselect values."""
wanted = identifier.strip()
for entry in _load_policy_entries(ctx, keys_file):
pubkey = str(entry.get("pubkey") or "")
name = str(entry.get("name") or "")
if pubkey.lower() == wanted.lower() or (name and name == wanted):
return entry
return None
def _prompt_key_entry(entries):
"""Interactive checkbox flow step 1: choose which key to edit."""
if not entries:
raise click.ClickException("no trusted keys found")
import questionary
choices = []
for entry in entries:
name = entry.get("name") or "unnamed key"
pubkey = entry.get("pubkey") or ""
policy = _plain_policy_label(entry.get("allow"))
choices.append(questionary.Choice(
title=f"{name} [{policy}] {pubkey[:12]}…{pubkey[-8:]}",
value=entry,
))
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
if selected is None:
raise click.ClickException("cancelled")
return selected
def _prompt_policy_categories(identifier: str, current_categories=None):
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
import questionary
checked = set(current_categories or [])
special_checked = {
"__server_default__": current_categories is None,
"__safe__": current_categories == [],
"__all__": isinstance(current_categories, list) and "all" in current_categories,
}
choices = [
questionary.Choice(
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
value="read-page",
checked="read-page" in checked,
),
questionary.Choice(
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
value="control",
checked="control" in checked,
),
questionary.Choice(
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
value="dangerous",
checked="dangerous" in checked,
),
questionary.Choice(
title="keys — admin access to key management over --remote: auth keys/trust/policy",
value="keys",
checked="keys" in checked,
),
questionary.Separator(),
questionary.Choice(
title="all — allow everything",
value="__all__",
checked=special_checked["__all__"],
),
questionary.Choice(
title="safe — explicit safe-only override",
value="__safe__",
checked=special_checked["__safe__"],
),
questionary.Choice(
title="server default — remove per-key override and inherit server policy",
value="__server_default__",
checked=special_checked["__server_default__"],
),
]
selected = questionary.checkbox(
f"Policy for {identifier}",
choices=choices,
instruction="(space to toggle, enter to save)",
).ask()
if selected is None:
raise click.ClickException("cancelled")
return _parse_checkbox_policy_selection(selected)
def _parse_checkbox_policy_selection(selected):
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
if len(special) > 1 or (special and normal):
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
if special == ["__server_default__"]:
return None
if special == ["__safe__"]:
return []
if special == ["__all__"]:
return ["all"]
return normal
def _parse_policy_selection(raw: str):
value = raw.strip().lower()
if value in {"default", "server-default", "server default", "inherit", "none"}:
return None
if value in {"safe", "safe-only", ""}:
return []
if value == "all":
return ["all"]
number_map = {
"1": "read-page",
"2": "control",
"3": "dangerous",
"4": "keys",
}
valid = {"read-page", "control", "dangerous", "keys"}
categories = []
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
category = number_map.get(token, token)
if category == "all":
return ["all"]
if category not in valid:
raise click.ClickException(f"unknown policy choice: {token}")
if category not in categories:
categories.append(category)
return categories
def _plain_policy_label(categories) -> str:
"""Plain-text policy label for interactive prompt titles."""
if categories is None:
return "server default"
if "all" in categories:
return "all"
return ", ".join(categories) if categories else "safe"
def _policy_label(categories) -> str:
"""Render an authorized_keys ``allow:`` token for display."""
if categories is None:
return "[dim]server default[/dim]"
if "all" in categories:
return "[yellow]all[/yellow]"
return ", ".join(categories) if categories else "safe"
def _policy_description(categories) -> str:
"""Human-readable explanation for a policy category list."""
if categories is None:
return "Inherits the policy from browser-cli serve"
if "all" in categories:
return "Full access: page reads, browser control, dangerous commands, key admin"
if not categories:
return "Safe status/list commands only"
descriptions = {
"read-page": "read page content",
"control": "control browser/tabs/page input",
"dangerous": "run high-risk commands",
"keys": "manage trusted keys remotely",
}
return "; ".join(descriptions.get(category, category) for category in categories)
+28 -95
View File
@@ -11,11 +11,10 @@ from browser_cli.client import (
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
remote_browser_targets,
remote_target_for_alias,
collect_browser_clients,
send_command,
)
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.registry import load_registry
console = Console()
@@ -36,21 +35,6 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
"""Query clients.list for one target and append each, tagged with *label*."""
if quiet_remote_warning:
result = send_command(
"clients.list",
profile=profile,
remote=remote,
key=key,
suppress_pq_warning=True,
)
else:
result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []):
c["profile"] = label
into.append(c)
@click.group("clients", invoke_without_command=True)
@click.pass_context
@@ -59,18 +43,20 @@ def clients_group(ctx):
if ctx.invoked_subcommand is not None:
return
all_clients = []
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
_collect_remote_alias_clients(all_clients, browser_alias, key)
elif remote:
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
else:
_collect_local_and_saved_remote_clients(all_clients)
try:
all_clients = collect_browser_clients(
browser_alias=browser_alias,
remote=remote,
key=key,
registry_path=REGISTRY_PATH,
)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
@@ -78,77 +64,24 @@ def clients_group(ctx):
_print_clients(all_clients)
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
resolved = remote_target_for_alias(browser_alias)
if not resolved:
return
try:
targets = remote_browser_targets(resolved.remote)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
for target in targets:
try:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
except (BrowserNotConnected, RuntimeError):
continue
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
try:
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for c in (result or []):
c["profile"] = c.get("profile") or browser_alias or "remote"
all_clients.append(c)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
_append_clients(all_clients, display_profile, profile=profile_name)
except (BrowserNotConnected, RuntimeError):
all_clients.append({
"profile": display_profile,
"name": "—",
"version": "—",
"extensionVersion": "disconnected",
})
targets = active_browser_targets(suppress_pq_warning=True)
for target in targets:
if target.remote is None:
continue
try:
_append_clients(
all_clients,
target.display_name,
profile=target.profile,
remote=target.remote,
quiet_remote_warning=True,
)
except (BrowserNotConnected, RuntimeError):
continue
def _print_clients(all_clients: list) -> None:
from rich.table import Table
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Version")
table.add_column("Extension Version")
for c in all_clients:
table.add_row(
c.get("profile", ""),
c.get("name", ""),
c.get("version", ""),
c.get("extensionVersion", ""),
)
console.print(table)
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
grouped = bool(groups and groups != {"local"})
columns = [
("Browser", lambda item: item.get("name", "")),
("Version", lambda item: item.get("version", "")),
("Extension Version", lambda item: item.get("extensionVersion", "")),
]
print_browser_grouped_table_rows(
all_clients,
columns,
console=console,
empty_message="[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]",
browser_getter=lambda item: item.get("profile", ""),
group_getter=lambda item: item.get("profileGroup", "") if grouped else "",
browser_header="Profile",
)
@clients_group.command("rename")
@click.option(
+3 -20
View File
@@ -1,35 +1,18 @@
from __future__ import annotations
import re
import shutil
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import handle_errors, client_from_ctx
from browser_cli.client import active_browser_targets
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME, PYPI_PACKAGE_NAME
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME
from browser_cli.platform import is_windows
from browser_cli.version_manager import project_version
console = Console()
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
try:
return package_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
def _status(ok: bool) -> str:
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
@@ -39,7 +22,7 @@ def _status(ok: bool) -> str:
def cmd_doctor(check_remote):
"""Diagnose browser-cli installation, extension, and connection health."""
rows: list[tuple[str, bool, str]] = []
version = _project_version()
version = project_version()
rows.append(("Python package", version != "unknown", version))
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
+25 -19
View File
@@ -1,7 +1,7 @@
import json
import click
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
from rich.table import Table
@@ -12,10 +12,11 @@ def extract_group():
"""Extract content from the active tab."""
@extract_group.command("links")
@tab_option
@handle_errors
def extract_links():
"""Extract all links from the active tab."""
links = client_from_ctx().extract.links()
def extract_links(tab_id):
"""Extract all links from the active tab or --tab."""
links = client_from_ctx().extract.links(tab_id)
if not links:
console.print("[yellow]No links found[/yellow]")
return
@@ -27,10 +28,11 @@ def extract_links():
console.print(table)
@extract_group.command("images")
@tab_option
@handle_errors
def extract_images():
"""Extract all images from the active tab."""
images = client_from_ctx().extract.images()
def extract_images(tab_id):
"""Extract all images from the active tab or --tab."""
images = client_from_ctx().extract.images(tab_id)
if not images:
console.print("[yellow]No images found[/yellow]")
return
@@ -42,29 +44,33 @@ def extract_images():
console.print(table)
@extract_group.command("text")
@tab_option
@handle_errors
def extract_text():
"""Extract all visible text from the active tab."""
console.print(client_from_ctx().extract.text())
def extract_text(tab_id):
"""Extract all visible text from the active tab or --tab."""
console.print(client_from_ctx().extract.text(tab_id))
@extract_group.command("json")
@click.argument("selector")
@tab_option
@handle_errors
def extract_json(selector):
"""Parse and pretty-print JSON content inside SELECTOR."""
data = client_from_ctx().extract.json(selector)
def extract_json(selector, tab_id):
"""Parse and pretty-print JSON content inside SELECTOR in the active tab or --tab."""
data = client_from_ctx().extract.json(selector, tab_id)
console.print_json(json.dumps(data))
@extract_group.command("html")
@tab_option
@handle_errors
def extract_html():
"""Print the full HTML of the active tab to stdout."""
click.echo(client_from_ctx().extract.html())
def extract_html(tab_id):
"""Print the full HTML of the active tab or --tab to stdout."""
click.echo(client_from_ctx().extract.html(tab_id))
@extract_group.command("markdown")
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
@tab_option
@handle_errors
def extract_markdown(selector):
"""Extract the page's main content as Markdown."""
markdown = client_from_ctx().extract.markdown(selector)
def extract_markdown(selector, tab_id):
"""Extract the page's main content as Markdown from the active tab or --tab."""
markdown = client_from_ctx().extract.markdown(selector, tab_id)
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
+9 -23
View File
@@ -1,33 +1,19 @@
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from rich.console import Console
from rich.table import Table
console = Console()
def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Name")
table.add_column("Color", width=10)
table.add_column("Collapsed", width=10)
table.add_column("Tabs", width=6)
for g in groups:
row = [
(g.browser or "") if show_browser else None,
str(g.id),
g.title or "",
g.color or "",
"yes" if g.collapsed else "no",
str(g.tab_count),
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns = [
("ID", lambda g: g.id),
("Name", lambda g: g.title or ""),
("Color", lambda g: g.color or ""),
("Collapsed", lambda g: "yes" if g.collapsed else "no"),
("Tabs", lambda g: g.tab_count),
]
print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
@click.group("groups")
def group_group():
+38 -17
View File
@@ -10,7 +10,9 @@ from rich.console import Console
from browser_cli.constants import (
ALLOWED_EXTENSION_IDS,
CHROME_WEBSTORE_URL,
EXTENSION_ID,
FIREFOX_ADDON_URL,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS,
NATIVE_HOST_NAME,
@@ -62,11 +64,44 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
@click.command("install")
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
def cmd_install(browser):
"""Register the native messaging host and print extension load instructions."""
@click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
def cmd_install(browser, dev):
"""Register the native messaging host and print extension install instructions."""
host_exe = native_host_exe()
write_native_host_exe(host_exe)
if dev:
_print_dev_instructions(browser)
else:
_print_store_instructions(browser)
manifest = _native_host_manifest(browser, host_exe)
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
label = "Registered native host" if is_windows() else "Wrote native host manifest"
console.print(f"[green]✓[/green] {label}: {p}")
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _print_store_instructions(browser: str) -> None:
console.print("\n[bold]Step 1:[/bold] Install the extension")
if browser == "firefox":
console.print(" Open Firefox Add-ons and click [bold]Add to Firefox[/bold]:")
console.print(f" [cyan]{FIREFOX_ADDON_URL}[/cyan]")
console.print(" [dim]Firefox support is experimental; tab-group commands require browser tab group APIs.[/dim]\n")
else:
console.print(f" Open the Chrome Web Store and click [bold]Add to {browser.capitalize()}[/bold]:")
console.print(f" [cyan]{CHROME_WEBSTORE_URL}[/cyan]")
console.print(" [dim]Brave, Edge, Vivaldi and Chromium can install from the Chrome Web Store too.[/dim]")
console.print(" [dim]Developing the extension? Run 'browser-cli install <browser> --dev' for the unpacked-load steps.[/dim]\n")
def _print_dev_instructions(browser: str) -> None:
ext_url = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
@@ -75,7 +110,7 @@ def cmd_install(browser):
"vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print("\n[bold]Step 1:[/bold] Load the unpacked extension (developer mode)")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox":
repo_root = Path(__file__).parent.parent.parent
@@ -93,20 +128,6 @@ def cmd_install(browser):
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
manifest = _native_host_manifest(browser, host_exe)
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
label = "Registered native host" if is_windows() else "Wrote native host manifest"
console.print(f"[green]✓[/green] {label}: {p}")
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
base = {
"name": NATIVE_HOST_NAME,
+5 -4
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
from rich.table import Table
@@ -10,10 +10,11 @@ def page_group():
"""Inspect current page metadata."""
@page_group.command("info")
@tab_option
@handle_errors
def page_info():
"""Show title, URL, readyState, language, and meta tags of the active tab."""
info = client_from_ctx().page.info()
def page_info(tab_id):
"""Show title, URL, readyState, language, and meta tags of the active tab or --tab."""
info = client_from_ctx().page.info(tab_id)
table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value")
+10 -12
View File
@@ -4,20 +4,18 @@ import json
import click
from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
@click.command("command")
@click.argument("name")
@click.argument("args_json", required=False, default="{}")
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
@command_policy_options
@handle_errors
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous):
"""Send a raw browser-cli wire command and print JSON."""
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
assert_command_allowed(name, policy)
args = json.loads(args_json) if args_json else {}
result = client_from_ctx().command(name, args)
click.echo(json.dumps(result, indent=2, default=str))
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Send a raw browser-cli wire command and print JSON."""
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
assert_command_allowed(name, policy)
args = json.loads(args_json) if args_json else {}
result = client_from_ctx().command(name, args)
click.echo(json.dumps(result, indent=2, default=str))
+113 -28
View File
@@ -1,17 +1,53 @@
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
from browser_cli.errors import BrowserNotConnected
from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
console = Console()
def _print_remotes() -> None:
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
console.print(table)
def _remove_remote(endpoint: str, *, verb: str) -> None:
if not remove_remote(endpoint):
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
console.print(f"[green]{verb} {endpoint}[/green]")
def _fetch_server_pubkey(endpoint: str) -> str:
from browser_cli.auth.server_identity import verify_challenge_signature
from browser_cli.remote.auth import parse_challenge
from browser_cli.remote.socket import connect_socket, recv_all
sock = connect_socket(endpoint)
try:
challenge, _nonce = parse_challenge(recv_all(sock) or b"")
finally:
sock.close()
if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
raise BrowserNotConnected("remote server did not advertise a server identity key")
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("remote server identity signature is invalid")
return str(challenge["server_pubkey"])
@click.group("remote")
def remote_group():
"""Manage remembered browser-cli remote endpoints."""
@@ -23,14 +59,34 @@ def remote_group():
def remote_status(endpoint, key):
"""Probe a remote endpoint and show server/client status."""
client = BrowserCLI(remote=endpoint, key=key)
clients = client.clients()
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Extension")
for item in clients:
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
console.print(table)
clients = [
{**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
for item in client.clients()
]
columns = [
("Browser", lambda item: item.get("name", "")),
("Extension", lambda item: item.get("extensionVersion", "")),
]
print_browser_grouped_table_rows(
clients,
columns,
console=console,
empty_message="[yellow]No browser clients found[/yellow]",
browser_getter=lambda item: item.get("profileLabel", ""),
group_getter=lambda item: item.get("profileGroup", ""),
browser_header="Profile",
)
@remote_group.command("add")
@click.argument("endpoint")
@click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
def remote_add(endpoint, key_spec):
"""Remember a remote endpoint for global multi-browser commands."""
save_remote(endpoint, key_spec)
if key_spec:
console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
else:
console.print(f"[green]Added remote {endpoint}[/green]")
@remote_group.command("trust")
@click.argument("endpoint")
@@ -40,29 +96,58 @@ def remote_trust(endpoint, key_spec):
save_remote_key(endpoint, key_spec)
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
@remote_group.command("list")
def remote_list():
"""List remembered remote endpoints."""
_print_remotes()
@remote_group.command("trust-host")
@click.argument("endpoint")
@click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
@handle_errors
def remote_trust_host(endpoint, pubkey):
"""Pin a remote server identity key, SSH known_hosts style."""
server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
save_known_host(endpoint, server_pubkey)
console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
@remote_group.command("known-hosts")
def remote_known_hosts():
"""List pinned remote server identity keys."""
known = load_known_hosts()
if not known:
console.print("[yellow]No known remote server identities[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
table.add_column("Fingerprint")
table.add_column("Public Key")
for endpoint, pubkey in sorted(known.items()):
table.add_row(endpoint, fingerprint(pubkey), pubkey)
console.print(table)
@remote_group.command("untrust-host")
@click.argument("endpoint")
def remote_untrust_host(endpoint):
"""Remove a pinned remote server identity key."""
if not remove_known_host(endpoint):
console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
return
console.print(f"[green]Removed server identity for {endpoint}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
_print_remotes()
@remote_group.command("remove")
@click.argument("endpoint")
def remote_remove(endpoint):
"""Remove a remembered remote endpoint."""
_remove_remote(endpoint, verb="Removed")
@remote_group.command("revoke")
@click.argument("endpoint")
def remote_revoke(endpoint):
"""Remove remembered key/config for ENDPOINT."""
remotes = load_remotes()
if endpoint not in remotes:
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
del remotes[endpoint]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
console.print(f"[green]Revoked {endpoint}[/green]")
_remove_remote(endpoint, verb="Revoked")
@@ -2,6 +2,7 @@
from browser_cli.commands.rendering.common import (
Column,
item_value,
print_browser_grouped_table_rows,
print_table_rows,
print_tree,
shorten,
@@ -44,6 +45,7 @@ __all__ = [
"group_tree_label",
"item_value",
"no_wrap_text",
"print_browser_grouped_table_rows",
"print_table_rows",
"print_tree",
"scoped_browser_label",
+53
View File
@@ -82,3 +82,56 @@ def print_table_rows(
for row in rows:
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
def print_browser_grouped_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
browser_header: str = "Browser",
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render rows with optional local/remote browser grouping.
Rows without a browser label are rendered as a normal table. Rows with
``browser_group``/``browserGroup`` get a group header (for example ``local``
or a remote host) and a short indented profile label below it.
"""
if not rows:
console.print(empty_message)
return
show_browser = any(bool(browser_getter(row)) for row in rows)
if not show_browser:
print_table_rows(
rows,
columns,
console=console,
empty_message=empty_message,
show_header=show_header,
header_style=header_style,
)
return
table = Table(show_header=show_header, header_style=header_style)
table.add_column(browser_header, no_wrap=True)
for header, _getter in columns:
table.add_column(header)
rendered_groups: set[str] = set()
for row in rows:
browser = text_value(browser_getter(row))
group = text_value(group_getter(row))
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
rendered_groups.add(group)
browser = browser.removeprefix(f"{group}:")
browser = f" {browser}"
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
+5 -7
View File
@@ -8,8 +8,8 @@ from typing import Any, cast
import click
from rich.console import Console
from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
console = Console()
@@ -38,17 +38,15 @@ def _parse_step(step):
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
@command_policy_options
@handle_errors
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool, allow_all: bool):
"""Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file)
if not isinstance(steps, list):
raise click.ClickException("Script root must be a list")
client = client_from_ctx()
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
results = []
for index, step in enumerate(steps, start=1):
command, args = _parse_step(step)
+3 -54
View File
@@ -1,61 +1,10 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from browser_cli.search.engines import DISPLAY_NAMES, SUBCOMMANDS
console = Console()
ENGINES = {
"google": "https://www.google.com/search?q={query}",
"brave": "https://search.brave.com/search?q={query}",
"duckduckgo": "https://duckduckgo.com/?q={query}",
"ddg": "https://duckduckgo.com/?q={query}",
"youtube": "https://www.youtube.com/results?search_query={query}",
"yt": "https://www.youtube.com/results?search_query={query}",
"spotify": "https://open.spotify.com/search/{query}",
"amazon": "https://www.amazon.com/s?k={query}",
"ecosia": "https://www.ecosia.org/search?q={query}",
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
"fa": "https://www.furaffinity.net/search/?q={query}",
"bing": "https://www.bing.com/search?q={query}",
"github": "https://github.com/search?q={query}",
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"reddit": "https://www.reddit.com/search/?q={query}",
"stackoverflow": "https://stackoverflow.com/search?q={query}",
"so": "https://stackoverflow.com/search?q={query}",
}
_DISPLAY_NAMES = {
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
}
_SUBCOMMANDS = [
("google", "Search with Google."),
("brave", "Search with Brave Search."),
("duckduckgo", "Search with DuckDuckGo."),
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
("youtube", "Search YouTube videos."),
("yt", "Search YouTube (alias for youtube)."),
("spotify", "Search Spotify."),
("amazon", "Search Amazon."),
("ecosia", "Search with Ecosia."),
("furaffinity", "Search FurAffinity."),
("fa", "Search FurAffinity (alias for furaffinity)."),
("bing", "Search with Bing."),
("github", "Search GitHub."),
("wikipedia", "Search Wikipedia."),
("wiki", "Search Wikipedia (alias for wikipedia)."),
("reddit", "Search Reddit."),
("stackoverflow", "Search Stack Overflow."),
("so", "Search Stack Overflow (alias for stackoverflow)."),
]
@click.group("search")
def search_group():
"""Search the web — open a query in a search engine."""
@@ -70,10 +19,10 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
terms = " ".join(query)
client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
display = DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
return _cmd
for _name, _help in _SUBCOMMANDS:
for _name, _help in SUBCOMMANDS:
search_group.add_command(_build_command(_name, _help))
+155 -78
View File
@@ -8,110 +8,187 @@ from pathlib import Path
import click
from browser_cli import transport
from browser_cli.command_security import CommandPolicy
from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.runtime import (
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
)
from browser_cli.serve.security import RateLimiter, ServeSecurity, key_policies_from_authorized_keys
from browser_cli.version_manager import get_installed_version
__all__ = [
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
]
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"}
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option(
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
)
@click.option(
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
)
@click.option(
"--rate-limit",
default=100.0,
show_default=True,
type=float,
help="Max commands/sec per client key (0 disables).",
)
@command_policy_options
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rate_limit,
allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose this browser over TCP so remote hosts can control it.
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
Commands are gated by a safe-only policy by default; remote clients can only
run read-only status/listing commands. Open more with --allow-read-page,
--allow-control, --allow-dangerous, or --allow-all (full control). Per-key
overrides come from an ``allow:`` token in authorized_keys (set via
``auth trust --allow-*``), and --rate-limit throttles each client key.
"""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
if no_auth and not _is_loopback(host):
console.print(
"[red]Error:[/red] --no-auth is only allowed on loopback hosts "
"(127.0.0.1, localhost, ::1). Use --authorized-keys to expose this browser to the network."
)
sys.exit(1)
_print_startup(host, port, profile, auth_keys_path, compress)
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
try:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
policy = command_policy_from_options(
allow_read_page=allow_read_page,
allow_control=allow_control,
allow_dangerous=allow_dangerous,
allow_keys=allow_keys,
allow_all=allow_all,
)
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
security = _build_security(policy, auth_keys_path, rate_limit)
_print_startup(host, port, profile, auth_keys_path, compress, security)
try:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress, security))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
def _build_security(policy, auth_keys_path, rate_limit) -> ServeSecurity:
"""Assemble the serve-time security context from the authorized_keys file."""
key_policies: dict = {}
key_names: dict = {}
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys_with_names
key_names = {pk.strip().lower(): name for pk, name in load_authorized_keys_with_names(auth_keys_path)}
key_policies = key_policies_from_authorized_keys(auth_keys_path)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
return ServeSecurity(policy=policy, key_policies=key_policies, key_names=key_names, rate_limiter=rate_limiter)
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool, security: ServeSecurity | None = None) -> None:
current_ver = get_installed_version()
security = security if security is not None else ServeSecurity()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
_print_policy_status(security.policy)
if security.key_policies:
console.print(f" Per-key: [green]{len(security.key_policies)} override(s)[/green] [dim](allow: in authorized_keys)[/dim]")
if security.rate_limiter is not None:
console.print(f" Rate: [green]{security.rate_limiter.rate:g}/s per key[/green] [dim](burst {security.rate_limiter.capacity:g})[/dim]")
else:
console.print(" Rate: [yellow]unlimited[/yellow] [dim](--rate-limit 0)[/dim]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
def _print_policy_status(policy: CommandPolicy | None) -> None:
if policy is None or policy == CommandPolicy.unrestricted():
console.print(" Policy: [yellow]unrestricted (--allow-all)[/yellow] [dim](every command allowed, incl. dom.eval/storage)[/dim]")
return
allowed = ["safe"]
if policy.allow_read_page:
allowed.append("read-page")
if policy.allow_control:
allowed.append("control")
if policy.allow_dangerous:
allowed.append("dangerous")
if policy.allow_keys:
allowed.append("keys")
console.print(f" Policy: [green]restricted[/green] [dim](allowed: {', '.join(allowed)})[/dim]")
def _print_encoding_status(compress: bool) -> None:
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)
+50 -19
View File
@@ -10,9 +10,14 @@ from rich.console import Console
from browser_cli import BrowserCLI
from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.security import RateLimiter
console = Console()
# Hard cap on request body size so a bogus Content-Length can't exhaust memory.
MAX_BODY_BYTES = 8 * 1024 * 1024
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"}
@@ -20,13 +25,16 @@ class _Handler(BaseHTTPRequestHandler):
client: BrowserCLI
token: str | None = None
policy: CommandPolicy = CommandPolicy()
rate_limiter: RateLimiter | None = None
def _authorized(self) -> bool:
if self.token is None:
return True
if self.headers.get("Authorization", "") == f"Bearer {self.token}":
bearer = self.headers.get("Authorization", "")
if bearer.startswith("Bearer ") and secrets.compare_digest(bearer[len("Bearer "):], self.token):
return True
return self.headers.get("X-Browser-CLI-Token") == self.token
header = self.headers.get("X-Browser-CLI-Token")
return header is not None and secrets.compare_digest(header, self.token)
def _require_auth(self) -> bool:
if self._authorized():
@@ -34,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
self._send(401, {"error": "missing or invalid token"})
return False
def _within_rate_limit(self) -> bool:
if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]):
return True
self._send(429, {"error": "rate limit exceeded; slow down and retry"})
return False
def _send(self, status: int, payload):
raw = json.dumps(payload, default=str).encode("utf-8")
self.send_response(status)
@@ -45,8 +59,11 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urlparse(self.path).path
try:
if path != "/health" and not self._require_auth():
return
if path != "/health":
if not self._require_auth():
return
if not self._within_rate_limit():
return
if path == "/tabs":
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
elif path == "/clients":
@@ -61,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = urlparse(self.path).path
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
if path == "/command":
if not self._require_auth():
return
command = body.get("command")
assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
else:
if path != "/command":
self._send(404, {"error": "not found"})
return
if not self._require_auth():
return
if not self._within_rate_limit():
return
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY_BYTES:
self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"})
return
body = json.loads(self.rfile.read(length) or b"{}")
command = body.get("command")
assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
except PermissionError as exc:
self._send(403, {"error": str(exc)})
except Exception as exc:
@@ -87,23 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
@click.option("--key", default=None, help="Remote auth key spec")
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
@click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands")
@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands")
@click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands")
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous):
@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)")
@command_policy_options
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
Auth is enabled by default. Pass the printed token as either
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
This gateway speaks plain HTTP — the token is sent in clear text. Keep it on
loopback, or put a TLS-terminating reverse proxy in front before exposing it.
"""
if no_auth and not _is_loopback(host):
raise click.ClickException("--no-auth is only allowed on loopback hosts")
if not _is_loopback(host):
console.print(
"[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the "
"token travels in clear text. Put a TLS-terminating reverse proxy in front, or use "
"[bold]browser-cli serve[/bold] (encrypted) instead."
)
auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
handler = type(
"BrowserCLIHTTPHandler",
(_Handler,),
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy},
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter},
)
server = ThreadingHTTPServer((host, port), handler)
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
+10 -14
View File
@@ -105,24 +105,20 @@ def session_diff(name_a, name_b):
def session_list():
"""List all saved sessions."""
from datetime import datetime
from rich.table import Table
from browser_cli.commands.rendering import print_browser_grouped_table_rows
sessions = client_from_ctx().session.list()
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
show_browser = any("browser" in s for s in sessions)
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("Name")
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
def saved_at(session):
return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
columns = [
("Name", lambda session: session["name"]),
("Tabs", lambda session: session["tabs"]),
("Saved at", saved_at),
]
print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
@session_group.command("remove")
@click.argument("name")
+4 -7
View File
@@ -2,25 +2,22 @@ import base64
import binascii
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree
from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console
from rich.table import Table
console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
columns = []
if show_browser:
columns.append(("Browser", lambda tab: tab.browser or ""))
columns.extend([
columns = [
("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]),
("URL", lambda tab: (tab.url or "")[:80]),
])
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
]
print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
@click.group("tabs")
def tabs_group():
+4 -7
View File
@@ -1,21 +1,18 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree
from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console
console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
columns = []
if show_browser:
columns.append(("Browser", lambda window: window.get("browser", "")))
columns.extend([
columns = [
("ID", lambda window: window.get("id", "")),
("Alias", lambda window: window.get("alias") or ""),
("Tabs", lambda window: window.get("tabCount", "")),
("State", lambda window: window.get("state") or ""),
])
print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
]
print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
@click.group("windows")
def windows_group():
+13 -27
View File
@@ -3,42 +3,28 @@ Auth-field normalizers — applied to the raw incoming message *before* the
auth check runs. Protocol fields (pubkey, sig, …) are still present here.
Add one entry per breaking auth-field change:
("X.Y.Z", transformer_fn)
("X.Y.Z", transformer_fn)
Entries must stay in ascending version order.
The registry is intentionally empty: the first public release was 0.14.1, so no
legacy-client shim has ever been needed. This module is the seam — add a tuple
here (and a unit test for it) the day a breaking auth-field change ships.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
def _auth_0_9_3(msg: dict) -> dict:
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
changed: dict = {}
pk = msg.get("pubkey")
if isinstance(pk, str) and pk:
changed["pubkey"] = pk.lower()
if msg.get("command") == "browser-cli.auth.trust":
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
if isinstance(trust_pk, str) and trust_pk:
changed["args"] = {**args, "pubkey": trust_pk.lower()}
return {**msg, **changed} if changed else msg
# ── registry ──────────────────────────────────────────────────────────────────
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
("0.9.3", _auth_0_9_3),
]
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
def adapt_auth(msg: dict, client_version: str) -> dict:
"""Apply all auth normalizers needed to bring msg up to the current format."""
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
"""Apply all auth normalizers needed to bring msg up to the current format."""
if not _AUTH_COMPAT:
return msg
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
return msg
+19 -16
View File
@@ -3,7 +3,7 @@ Command-format shims — applied to clean_msg (protocol fields already stripped)
before forwarding to the native host, and to responses before sending back.
Add one entry per breaking command-format change:
("X.Y.Z", request_fn, response_fn)
("X.Y.Z", request_fn, response_fn)
- request_fn(msg: dict) -> dict or None
- response_fn(resp: bytes, command: str) -> bytes or None
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
Entries must stay in ascending version order.
adapt_request walks forward (oldest first); adapt_response walks backward.
Current baseline: 0.9.3 — no command-format shims needed yet.
The registry is intentionally empty: no command-format shim has been needed
since the first public release (0.14.1). This module is the seam — add a tuple
here (and a unit test for it) the day a breaking command-format change ships.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
# ── registry ──────────────────────────────────────────────────────────────────
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
]
def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current browser command format."""
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
"""Upgrade a client message to the current browser command format."""
if not _COMPAT:
return msg
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a native-host response to the format the client expects."""
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
"""Downgrade a native-host response to the format the client expects."""
if not _COMPAT:
return resp
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+20 -2
View File
@@ -20,13 +20,31 @@ FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
# Public store listings — the default install path now that the extension is
# published. Chromium-family browsers (Brave/Edge/Vivaldi/Chromium) can all
# install from the Chrome Web Store.
CHROME_WEBSTORE_URL = f"https://chromewebstore.google.com/detail/browser-cli/{WEBSTORE_EXTENSION_ID}"
FIREFOX_ADDON_URL = "https://addons.mozilla.org/firefox/addon/browser-cli/"
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
DEFAULT_REMOTE_PORT = 443
DEFAULT_PAGE_SIZE = 100
# Count cap requested per page. The extension fills each page up to this many
# items OR a byte budget (whichever comes first), so large items (e.g. data-URI
# favicons) stay under the 1MB native-messaging limit while small items pack
# into far fewer roundtrips.
DEFAULT_PAGE_SIZE = 1000
# Hard upper bound on total items collected across all pages, and the loop-guard
# page count. Kept independent of page size so byte-budgeted small pages don't
# falsely trip the guard.
MAX_PAGED_ITEMS = 10_000
DEFAULT_TRANSPORT_THRESHOLD = 512
# How long a remote serve connection stays open waiting for the next command on
# an established encrypted session before closing. Lets the client reuse one
# authenticated connection for multiple commands instead of re-handshaking.
REMOTE_SESSION_IDLE_TIMEOUT = 30
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
PAGEABLE_COMMANDS = {
+26 -4
View File
@@ -11,6 +11,13 @@ class _HtmlNode:
self.text = text
self.children = []
# Cap how deep the parsed tree may nest. Hostile page content (thousands of
# nested elements) would otherwise blow Python's recursion limit in the
# depth-first render walkers below. Bounding here protects every walker at once.
# 200 levels is far beyond any real document; deeper content is flattened, not
# dropped (its text still reaches the output).
_MAX_TREE_DEPTH = 200
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
@@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
# Only descend while under the depth cap; beyond it, children of this node
# attach to the current (capped) parent — flattened but preserved.
if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
@@ -57,6 +66,14 @@ def _collapse_blank_lines(value):
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
# Schemes that are dangerous if the produced markdown is later rendered as HTML
# by a downstream consumer. The output is plain text here, but neutralising them
# keeps the converter from laundering an XSS payload through to such a consumer.
_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE)
def _safe_url(url):
return "" if _UNSAFE_URL_SCHEME.match(url or "") else url
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
@@ -86,14 +103,14 @@ def _inline_text(node):
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
src = _safe_url(node.attrs.get("src") or "")
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
href = _safe_url(node.attrs.get("href") or "")
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
@@ -235,5 +252,10 @@ def _block_to_markdown(node):
def convert_html_to_markdown(html, clean_markdown_output):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
try:
markdown = _block_to_markdown(parser.root)
except RecursionError:
# The depth cap should prevent this, but never let hostile page content
# crash the caller: fall back to a flat, tag-stripped text extraction.
markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or ""))
return clean_markdown_output(markdown)
+5
View File
@@ -0,0 +1,5 @@
"""Stateless Model Context Protocol adapter for browser-cli."""
from browser_cli.mcp.server import create_server, main
__all__ = ["create_server", "main"]
+39
View File
@@ -0,0 +1,39 @@
"""Tool-name prefixing for the MCP surface.
Hosts differ in how they namespace MCP tools. Hosts that already prefix tool
names with the server name turn the built-in ``browser_`` prefix into stutter
(``browser_cli_testing_browser_tabs_list``), while hosts that expose raw names
need the prefix to keep ``navigate`` or ``screenshot`` unambiguous. The prefix
is therefore configurable per MCP server process.
"""
from __future__ import annotations
import os
import re
TOOL_PREFIX_ENV = "BROWSER_CLI_MCP_TOOL_PREFIX"
DEFAULT_TOOL_PREFIX = "browser_"
_VALID_PREFIX = re.compile(r"\A[a-z][a-z0-9_]*\Z")
def resolve_tool_prefix(environ: dict[str, str] | None = None) -> str:
"""Return the configured tool-name prefix, defaulting to ``browser_``.
An empty value disables prefixing for hosts that namespace tools themselves.
"""
configured = (environ if environ is not None else os.environ).get(TOOL_PREFIX_ENV)
if configured is None:
return DEFAULT_TOOL_PREFIX
prefix = configured.strip()
if not prefix:
return ""
if not _VALID_PREFIX.match(prefix):
raise ValueError(
f"{TOOL_PREFIX_ENV} must be lowercase letters, digits, and underscores starting "
f"with a letter, or empty to disable prefixing; got {configured!r}"
)
return prefix if prefix.endswith("_") else f"{prefix}_"
def tool_name(base: str, prefix: str) -> str:
"""Apply *prefix* to a bare tool name such as ``tabs_list``."""
return f"{prefix}{base}"
+19
View File
@@ -0,0 +1,19 @@
"""Convert browser-cli SDK models into MCP structured-output values."""
from __future__ import annotations
from dataclasses import fields, is_dataclass
from typing import Any
def structured(value: Any) -> Any:
"""Return JSON-compatible data without private SDK binding fields."""
if is_dataclass(value) and not isinstance(value, type):
return {
field.name: structured(getattr(value, field.name))
for field in fields(value)
if not field.name.startswith("_")
}
if isinstance(value, dict):
return {str(key): structured(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [structured(item) for item in value]
return value
+237
View File
@@ -0,0 +1,237 @@
"""Stateless MCP server exposing a conservative browser-cli tool surface.
The MCP process stores no browser client, tab ID, or navigation state. Every
call constructs a fresh :class:`browser_cli.BrowserCLI`; the real browser is
the sole owner of browser state.
"""
from __future__ import annotations
import argparse
import base64
import os
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import urlsplit
from browser_cli import BrowserCLI
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
ClientFactory = Callable[..., BrowserCLI]
_SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-cli.
The server is stateless: pass browser, remote, and key on each tool call when a
specific target is required. Tool calls affect the user's actual browser. Read
current tabs/page state instead of assuming IDs or content from an earlier call.
"""
def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI:
"""Build a fresh client, making MCP process environment defaults explicit.
Explicit values are important for SDK multi-browser routing: leaving
``browser=None`` would fan out list/count calls before lower transport code
gets a chance to consult ``BROWSER_CLI_PROFILE``.
"""
return factory(
browser=browser or os.environ.get("BROWSER_CLI_PROFILE"),
remote=remote or os.environ.get("BROWSER_CLI_REMOTE"),
key=key or os.environ.get("BROWSER_CLI_KEY"),
)
def _screenshot_bytes(data_url: str) -> tuple[bytes, str]:
"""Decode a browser screenshot data URL into bytes and an MCP image format."""
header, separator, payload = data_url.partition(",")
if not separator or ";base64" not in header:
raise ValueError("Browser returned an invalid screenshot data URL")
media_type = header[5:].split(";", 1)[0].lower()
image_format = "jpeg" if media_type in {"image/jpeg", "image/jpg"} else "png"
return base64.b64decode(payload, validate=True), image_format
def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: str | None = None):
"""Create the MCP server. Supplying *client_factory* keeps tests browser-free."""
try:
from mcp.server import MCPServer
from mcp.server.mcpserver import Image
except ImportError as exc: # pragma: no cover - exercised without the optional extra
raise RuntimeError(
"MCP support is not installed. Install real-browser-cli with the 'mcp' extra: "
"uv tool install 'real-browser-cli[mcp]'"
) from exc
prefix = resolve_tool_prefix() if tool_prefix is None else tool_prefix
mcp = MCPServer(
"browser-cli",
description="Control a real running browser through the browser-cli SDK.",
instructions=_SERVER_INSTRUCTIONS,
)
@mcp.tool(name=tool_name("tabs_list", prefix))
def tabs_list(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""List current tabs. Optionally target a browser alias or authenticated remote."""
return structured(_client(client_factory, browser, remote, key).tabs.list())
@mcp.tool(name=tool_name("tabs_open", prefix))
def tabs_open(
url: str,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Open a URL in a new real-browser tab and return its current metadata."""
tab = _client(client_factory, browser, remote, key).tabs.open(
url, wait=wait, timeout=timeout, background=background, focus=focus
)
return structured(tab)
@mcp.tool(name=tool_name("tabs_close", prefix))
def tabs_close(
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, int]:
"""Close a tab, defaulting to the active tab. This changes the real browser."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
return {"closed": client.tabs.close(target), "tab_id": target}
@mcp.tool(name=tool_name("navigate", prefix))
def navigate(
url: str,
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Navigate a tab to a URL, defaulting to the active tab, and return it."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
client.nav.to(target, url)
return structured(client.tabs.status(target))
@mcp.tool(name=tool_name("page_info", prefix))
def page_info(
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Return title, URL, readiness, language, and metadata for the active page or tab_id."""
client = _client(client_factory, browser, remote, key)
if tab_id is not None:
return structured(client.tabs.status(tab_id))
return structured(client.page.info())
@mcp.tool(name=tool_name("extract_text", prefix))
def extract_text(
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract plain text from the active page or tab_id."""
return _client(client_factory, browser, remote, key).extract.text(tab_id)
@mcp.tool(name=tool_name("extract_markdown", prefix))
def extract_markdown(
selector: str | None = None,
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract clean Markdown from the active page or tab_id, optionally scoped by CSS selector."""
return _client(client_factory, browser, remote, key).extract.markdown(selector, tab_id)
@mcp.tool(name=tool_name("dom_query", prefix))
def dom_query(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""Return elements matching a CSS selector on the active page."""
return structured(_client(client_factory, browser, remote, key).dom.query(selector))
@mcp.tool(name=tool_name("dom_click", prefix))
def dom_click(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Click the first matching element, then return current active-page info."""
client = _client(client_factory, browser, remote, key)
client.dom.click(selector)
return structured(client.page.info())
@mcp.tool(name=tool_name("dom_type", prefix))
def dom_type(
selector: str,
text: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, bool]:
"""Type text into the first element matching a CSS selector."""
_client(client_factory, browser, remote, key).dom.type(selector, text)
return {"typed": True}
@mcp.tool(name=tool_name("screenshot", prefix), structured_output=False)
def screenshot(
tab_id: int | None = None,
format: Literal["png", "jpeg"] = "png",
quality: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> Any:
"""Capture the visible area of the active or specified tab as an image."""
data_url = _client(client_factory, browser, remote, key).tabs.screenshot(
tab_id, format=format, quality=quality
)
data, actual_format = _screenshot_bytes(data_url)
return Image(data=data, format=actual_format)
return mcp
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the stateless browser-cli MCP server.")
parser.add_argument("--transport", choices=("stdio", "streamable-http"), default="stdio")
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (streamable-http only).")
parser.add_argument("--port", type=int, default=8000, help="HTTP bind port (streamable-http only).")
parser.add_argument("--path", default="/mcp", help="MCP endpoint path (streamable-http only).")
return parser
def main(argv: list[str] | None = None) -> None:
"""Run over stdio, or stateless Streamable HTTP when explicitly selected."""
args = _parser().parse_args(argv)
mcp = create_server()
if args.transport == "stdio":
mcp.run()
return
if args.host not in {"127.0.0.1", "localhost", "::1"}:
raise SystemExit(
"Refusing to expose the unauthenticated MCP server beyond localhost. "
"Use browser-cli's authenticated remote transport from a local MCP server instead."
)
mcp.run(
transport="streamable-http",
host=args.host,
port=args.port,
streamable_http_path=args.path,
stateless_http=True,
json_response=True,
)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
"""Tab targeting for the MCP surface.
MCP callers pay a full round trip for every extra tool call, so tools that act
on a tab accept an optional ``tab_id`` and fall back to the browser's current
active tab. Resolution happens here rather than by forwarding ``None`` into the
SDK, so the acting tool always knows which tab it touched and can report it.
"""
from __future__ import annotations
from browser_cli import BrowserCLI
def resolve_tab_id(client: BrowserCLI, tab_id: int | None) -> int:
"""Return *tab_id*, or the ID of the currently active tab when it is ``None``."""
if tab_id is not None:
return tab_id
return client.tabs.active().id
+20 -23
View File
@@ -31,6 +31,7 @@ class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
browser_groups: dict[str, str] = field(default_factory=dict)
# ── Tab ───────────────────────────────────────────────────────────────────────
@@ -61,27 +62,27 @@ class Tab:
def close(self) -> None:
"""Close this tab."""
self._command("tabs.close", {"tabId": self.id})
self._b().tabs.close(self.id)
def activate(self) -> None:
"""Switch browser focus to this tab."""
self._command("tabs.active", {"tabId": self.id})
self._b().tabs.activate(self.id)
def mute(self) -> None:
"""Mute this tab."""
self._command("tabs.mute", {"tabId": self.id})
self._b().tabs.mute(self.id)
def unmute(self) -> None:
"""Unmute this tab."""
self._command("tabs.unmute", {"tabId": self.id})
self._b().tabs.unmute(self.id)
def reload(self) -> None:
"""Reload this tab."""
self._command("navigate.reload", {"tabId": self.id})
self._b().nav.reload(self.id)
def hard_reload(self) -> None:
"""Hard-reload this tab (bypass cache)."""
self._command("navigate.hard_reload", {"tabId": self.id})
self._b().nav.hard_reload(self.id)
def move(
self, *,
@@ -100,18 +101,18 @@ class Tab:
window_id: Move to the window with this ID.
index: Absolute position index in the target window.
"""
self._command("tabs.move", {
"tabId": self.id,
"forward": forward,
"backward": backward,
"groupId": group_id,
"windowId": window_id,
"index": index,
})
self._b().tabs.move(
self.id,
forward=forward,
backward=backward,
group_id=group_id,
window_id=window_id,
index=index,
)
def html(self) -> str:
"""Return the full HTML source of this tab."""
return self._command("tabs.html", {"tabId": self.id})
return self._b().tabs.html(self.id)
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
@@ -119,11 +120,11 @@ class Tab:
def pin(self) -> None:
"""Pin this tab."""
self._command("tabs.pin", {"tabId": self.id})
self._b().tabs.pin(self.id)
def unpin(self) -> None:
"""Unpin this tab."""
self._command("tabs.unpin", {"tabId": self.id})
self._b().tabs.unpin(self.id)
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
@@ -169,7 +170,7 @@ class Group:
def close(self) -> None:
"""Ungroup (and close) this tab group."""
self._command("group.close", {"groupId": self.id})
self._b().groups.close(self.id)
def tabs(self) -> list[Tab]:
"""Return all tabs inside this group."""
@@ -177,11 +178,7 @@ class Group:
def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups."""
self._command("group.move", {
"group": str(self.id),
"forward": forward,
"backward": backward,
})
self._b().groups.move(str(self.id), forward=forward, backward=backward)
def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID."""
+6 -4
View File
@@ -7,7 +7,6 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import math
import os
import queue
import socket
@@ -17,7 +16,7 @@ import uuid
from pathlib import Path
from browser_cli.native import local_server, protocol
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, MAX_PAGED_ITEMS, PAGEABLE_COMMANDS
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.registry import update_registry
@@ -126,7 +125,10 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
offset = 0
items = []
total = None
max_pages = math.ceil(10_000 / PAGE_SIZE)
# Independent of PAGE_SIZE: the extension may return fewer items per page than
# requested (byte budget), so a page-count guard derived from PAGE_SIZE would
# falsely trip. Bound the page count by the absolute item cap instead.
max_pages = MAX_PAGED_ITEMS
pages_fetched = 0
while True:
@@ -154,7 +156,7 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
items.extend(page_items)
total = data.get("total", total)
next_offset = data.get("nextOffset")
if next_offset is None:
if next_offset is None or len(items) >= MAX_PAGED_ITEMS:
break
offset = int(next_offset)
+108
View File
@@ -0,0 +1,108 @@
"""SSH-style known-hosts pinning for browser-cli remote servers."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from browser_cli.constants import CONFIG_DIR
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
def fingerprint(pubkey_hex: str) -> str:
"""Return a compact SHA256 fingerprint for display."""
import base64
import hashlib
digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def load_known_hosts(path: Path | None = None) -> dict[str, str]:
path = path or KNOWN_HOSTS_PATH
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
known[_normalize_endpoint(endpoint)] = pubkey_hex
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
normalized = _normalize_endpoint(endpoint)
if normalized not in known:
return False
del known[normalized]
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
return True
def _is_loopback_endpoint(endpoint: str) -> bool:
host, sep, _port = endpoint.rpartition(":")
check = host if sep else endpoint
return check in {"127.0.0.1", "localhost", "::1"}
def verify_known_host(endpoint: str, challenge: dict | None) -> None:
"""Verify and pin the server identity from a challenge frame.
First contact auto-adds the host when the process is interactive, mirroring
SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
`browser-cli remote trust-host ENDPOINT`.
"""
if not isinstance(challenge, dict):
return
pubkey = challenge.get("server_pubkey")
if not isinstance(pubkey, str) or not pubkey:
return
from browser_cli.auth.server_identity import verify_challenge_signature
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("Remote server identity signature is invalid")
normalized = _normalize_endpoint(endpoint)
known = load_known_hosts()
expected = known.get(normalized)
if expected is None and _is_loopback_endpoint(endpoint):
return
if expected is None:
if not sys.stdin.isatty():
raise BrowserNotConnected(
f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
f"Run: browser-cli remote trust-host {normalized}"
)
sys.stderr.write(
f"The authenticity of remote '{normalized}' can't be established.\n"
f"Server key fingerprint is {fingerprint(pubkey)}.\n"
"Trust this server and add it to known hosts? [y/N] "
)
answer = sys.stdin.readline().strip().lower()
if answer not in {"y", "yes"}:
raise BrowserNotConnected("Remote server identity was not trusted")
save_known_host(normalized, pubkey)
sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
return
if expected != pubkey:
raise BrowserNotConnected(
f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
f"Known: {fingerprint(expected)}\n"
f"Seen: {fingerprint(pubkey)}\n"
f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
)
+126
View File
@@ -0,0 +1,126 @@
"""Per-process pool of authenticated remote connections for reuse.
A ``browser-cli serve`` connection stays open after its first (encrypted)
command, so the client can send further commands over it without re-running the
TCP/TLS/challenge/auth handshake (~hundreds of ms each). Only encrypted (PQ)
sessions are pooled — plaintext/legacy sessions stay one-shot, matching the
server, which only loops for encrypted sessions.
Connections are checked out exclusively (never shared between threads at once),
returned on success, and dropped on any I/O error or once older than an idle
bound (kept below the server's idle timeout so we don't reuse a connection the
server has already closed).
"""
from __future__ import annotations
import atexit
import json
import socket
import threading
import time
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
from browser_cli.framing import frame
# Retire a pooled connection a few seconds before the server would, so we never
# hand back one the server has just timed out and closed.
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
_MAX_PER_ENDPOINT = 8
_MAX_ENDPOINTS = 64
class PooledConnection:
__slots__ = ("sock", "secret", "last_used")
def __init__(self, sock: socket.socket, secret: bytes) -> None:
self.sock = sock
self.secret = secret
self.last_used = time.monotonic()
_POOL: dict[str, list[PooledConnection]] = {}
_LOCK = threading.Lock()
def _close(sock: socket.socket) -> None:
try:
sock.close()
except OSError:
pass
def checkout(endpoint: str) -> PooledConnection | None:
"""Take an idle authenticated connection for *endpoint*, or None."""
now = time.monotonic()
with _LOCK:
conns = _POOL.get(endpoint)
while conns:
conn = conns.pop()
if now - conn.last_used <= _MAX_IDLE_SECONDS:
return conn
_close(conn.sock) # too old — assume the server has dropped it
return None
def _prune_endpoints_locked(now: float) -> None:
"""Keep the number of endpoint buckets bounded for long-running SDK users."""
for endpoint, bucket in list(_POOL.items()):
fresh = [conn for conn in bucket if now - conn.last_used <= _MAX_IDLE_SECONDS]
if fresh:
_POOL[endpoint] = fresh
else:
for conn in bucket:
_close(conn.sock)
_POOL.pop(endpoint, None)
while len(_POOL) >= _MAX_ENDPOINTS:
oldest_endpoint, bucket = min(
_POOL.items(),
key=lambda item: min(conn.last_used for conn in item[1]) if item[1] else 0.0,
)
for conn in bucket:
_close(conn.sock)
_POOL.pop(oldest_endpoint, None)
def checkin(endpoint: str, conn: PooledConnection) -> None:
"""Return a still-healthy connection to the pool for reuse."""
conn.last_used = time.monotonic()
with _LOCK:
if endpoint not in _POOL and len(_POOL) >= _MAX_ENDPOINTS:
_prune_endpoints_locked(conn.last_used)
bucket = _POOL.setdefault(endpoint, [])
if len(bucket) >= _MAX_PER_ENDPOINT:
_close(conn.sock)
return
bucket.append(conn)
def discard(conn: PooledConnection) -> None:
"""Drop a connection that errored or is no longer usable."""
_close(conn.sock)
def close_all() -> None:
"""Close every pooled connection (process exit / test isolation)."""
with _LOCK:
for bucket in _POOL.values():
for conn in bucket:
_close(conn.sock)
_POOL.clear()
def session_inner_message(msg: dict) -> dict:
"""Strip auth/transport fields, leaving the command for an established session."""
keep = {"id", "command", "args", "user_agent", "accept_encoding", "_route", "_suppress_pq_warning"}
return {k: v for k, v in msg.items() if k in keep}
def send_over(conn: PooledConnection, msg: dict) -> bytes | None:
"""Send one command over an existing encrypted session. Raises on I/O error."""
from browser_cli.auth import pq_encrypt
from browser_cli.remote.socket import recv_all
from browser_cli.remote.transport import _decode_pq_response
inner = json.dumps(session_inner_message(msg)).encode("utf-8")
envelope = json.dumps({"encrypted": pq_encrypt(conn.secret, "request", inner)}).encode("utf-8")
conn.sock.sendall(frame(envelope))
response = recv_all(conn.sock)
if not response:
# EOF — an older server (no session loop) closed after one command. Treat as
# a transport failure so the caller re-handshakes; never as an app error,
# which could double-execute a non-idempotent command on retry.
raise EOFError("remote closed the pooled connection")
return _decode_pq_response(response, conn.secret)
atexit.register(close_all)
+54 -7
View File
@@ -22,24 +22,71 @@ def load_remotes() -> dict[str, dict[str, str]]:
# Normalize keys so old entries stored as "domain:443" match current lookups.
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def resolve_remote_endpoint(endpoint: str | None) -> str | None:
"""Resolve a user-supplied remote alias to a remembered endpoint.
Domain-like remotes without an explicit port still default to :443 when no
matching remembered remote exists. If the user remembered exactly one
explicit-port remote for the same host (for example
``browser-host.example:8765``), use that endpoint so ``--remote
browser-host.example`` targets the stored service instead of assuming HTTPS.
"""
if not endpoint:
return None
normalized = _normalize_endpoint(endpoint)
host, sep, _port = normalized.rpartition(":")
if sep:
return normalized
remotes = load_remotes()
explicit_matches = []
for remote_endpoint in remotes:
remote_host, remote_sep, remote_port = remote_endpoint.rpartition(":")
if remote_sep and remote_host == normalized and remote_port != "443":
explicit_matches.append(remote_endpoint)
if len(explicit_matches) == 1:
return explicit_matches[0]
return normalized
def is_valid_key_spec(value: str) -> bool:
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
return value == "agent" or value.startswith("agent:") or (
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
)
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
def save_remote(endpoint: str, key_spec: str | None = None) -> None:
"""Persist a remote endpoint, optionally with a key spec."""
if not endpoint:
return
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
remotes[endpoint] = current
current = remotes.get(normalized, {})
if key_spec:
if not is_valid_key_spec(key_spec):
return
current["key"] = key_spec
remotes[normalized] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
def remove_remote(endpoint: str) -> bool:
"""Remove a remembered remote endpoint. Returns True when it existed."""
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
if normalized not in remotes:
return False
del remotes[normalized]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
return True
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
save_remote(endpoint, key_spec)
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
+7 -2
View File
@@ -25,8 +25,8 @@ def split_endpoint(endpoint: str) -> tuple[str, int]:
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
@contextmanager
def open_socket(endpoint: str):
def connect_socket(endpoint: str) -> socket.socket:
"""Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
host, port = split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
@@ -40,6 +40,11 @@ def open_socket(endpoint: str):
except Exception:
raw_sock.close()
raise
return sock
@contextmanager
def open_socket(endpoint: str):
sock = connect_socket(endpoint)
with sock:
yield sock
+35 -3
View File
@@ -20,29 +20,61 @@ from browser_cli.remote.auth import (
from browser_cli.remote.socket import (
async_recv_all as _async_recv_all,
async_recv_exact_bytes as _async_recv_exact,
connect_socket as _connect_socket,
open_async_connection as _open_async_connection,
open_socket as _open_socket,
recv_all as _recv_all,
recv_exact_bytes as _recv_exact,
split_endpoint as _split_endpoint,
)
from browser_cli.remote import pool as _pool
from browser_cli.remote.known_hosts import verify_known_host
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
# Reuse an already-authenticated connection when one is idle for this endpoint.
conn = _pool.checkout(endpoint)
if conn is not None:
try:
response = _pool.send_over(conn, msg)
_pool.checkin(endpoint, conn)
return response
except (OSError, ConnectionError, ValueError, EOFError):
_pool.discard(conn) # stale/closed — fall through to a fresh handshake
return _send_remote_handshake(endpoint, msg, private_key, warn_no_pq=warn_no_pq)
def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
with _open_socket(endpoint) as sock:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
sock = _connect_socket(endpoint)
try:
challenge_raw = _recv_all(sock)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
except BaseException:
_pool._close(sock)
raise
# Only encrypted sessions are reusable — the server keeps those open, and a
# fresh AEAD nonce per frame keeps reuse of the shared secret safe.
if pq_shared_secret is not None:
_pool.checkin(endpoint, _pool.PooledConnection(sock, pq_shared_secret))
else:
_pool._close(sock)
return response
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
reader, writer = await _open_async_connection(endpoint)
try:
challenge_raw = await _async_recv_all(reader)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
+25 -23
View File
@@ -131,39 +131,41 @@ class DomNS(Namespace):
})
class ExtractNS(Namespace):
"""Extract structured content from the active tab."""
"""Extract structured content from the active (or specified) tab."""
@sdk_command("extract.links", default=[])
def links(self) -> list[dict]:
"""Return links from the active tab."""
@sdk_command("extract.links", lambda self, tab_id=None: {"tabId": tab_id}, default=[])
def links(self, tab_id: int | None = None) -> list[dict]:
"""Return links from the active tab or *tab_id*."""
@sdk_command("extract.images", default=[])
def images(self) -> list[dict]:
"""Return images from the active tab."""
@sdk_command("extract.images", lambda self, tab_id=None: {"tabId": tab_id}, default=[])
def images(self, tab_id: int | None = None) -> list[dict]:
"""Return images from the active tab or *tab_id*."""
@sdk_command("extract.text", default="")
def text(self) -> str:
"""Return plain text from the active tab."""
@sdk_command("extract.text", lambda self, tab_id=None: {"tabId": tab_id}, default="")
def text(self, tab_id: int | None = None) -> str:
"""Return plain text from the active tab or *tab_id*."""
@sdk_command("extract.json", lambda self, selector: {"selector": selector})
def json(self, selector: str):
"""Extract JSON-like structured data from a selector."""
@sdk_command("extract.json", lambda self, selector, tab_id=None: {"selector": selector, "tabId": tab_id})
def json(self, selector: str, tab_id: int | None = None):
"""Extract JSON-like structured data from a selector in the active tab or *tab_id*."""
@sdk_command("extract.html", default="")
def html(self) -> str:
"""Return the full HTML source of the active tab."""
@sdk_command("extract.html", lambda self, tab_id=None: {"tabId": tab_id}, default="")
def html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active tab or *tab_id*."""
@sdk_command("extract.markdown", lambda self, selector=None: {"selector": selector}, mapper=_extract_markdown)
def markdown(self, selector: str | None = None) -> str:
"""Extract the page's main content as clean Markdown.
@sdk_command("extract.markdown", lambda self, selector=None, tab_id=None: {"selector": selector, "tabId": tab_id}, mapper=_extract_markdown)
def markdown(self, selector: str | None = None, tab_id: int | None = None) -> str:
"""Extract the page's main content as clean Markdown from the active tab or *tab_id*.
The extractor may return either Markdown or raw HTML; both are
normalized to Markdown here so SDK and CLI callers get identical output.
"""
class PageNS(Namespace):
"""Inspect the active page."""
"""Inspect the active page or a specified tab."""
@sdk_command("page.info", default={})
def info(self) -> dict:
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
def info(self, tab_id: int | None = None) -> dict:
"""Return metadata for the active page, or tab status for *tab_id*."""
if tab_id is not None:
return self.command("tabs.status", {"tabId": tab_id}) or {}
return self.command("page.info", {}) or {}
+101 -94
View File
@@ -11,107 +11,114 @@ from typing import Any, Protocol, cast
from browser_cli.models import Group, Tab
def _target_group(target) -> str | None:
if target is None:
return None
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
class _FactoryClient(Protocol):
_key: str | None
_key: str | None
class FactoryMixin:
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0,
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0,
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self.tab_from(data)
def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self.tab_from(data)
def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"),
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"),
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.tab_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=getattr(target, "display_group", None) if target else None,
)
def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.tab_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.group_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=getattr(target, "display_group", None) if target else None,
)
def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.group_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
@staticmethod
def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name}
@staticmethod
def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with browser metadata in multi-browser mode."""
if target is None:
return item
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
+1 -1
View File
@@ -114,7 +114,7 @@ class NavigationNS(Namespace):
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
from browser_cli.search.engines import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
+28 -11
View File
@@ -7,12 +7,14 @@ helpers; single-browser mode falls straight through to ``_cmd``.
"""
from __future__ import annotations
import asyncio
import importlib
import sys
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Protocol, cast
from browser_cli.client import BrowserTarget
from browser_cli.client.core import _run_concurrent
from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Tab
@@ -81,18 +83,28 @@ class RoutingMixin:
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = _browser_cli_package().send_command(
command, args, profile=target.profile, remote=target.remote, key=self._client._key
)
else:
data = _browser_cli_package().send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
def _send(target: BrowserTarget):
package = _browser_cli_package()
if target.remote:
return package.send_command(
command, args, profile=target.profile, remote=target.remote, key=self._client._key
)
return package.send_command(command, args, profile=target.profile)
# Run per-target roundtrips concurrently — each is a blocking, network-bound
# send_command, so offloading to threads gives real overlap while still
# invoking the (test-patchable) sync entry point.
raw = _run_concurrent([
(lambda t=t: asyncio.to_thread(_send, t)) for t in targets
])
results = []
for target, data in zip(targets, raw):
if isinstance(data, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(data, BaseException):
raise data
results.append((target, data))
if results:
return results
@@ -126,7 +138,12 @@ class RoutingMixin:
if not multi_results:
return self._client.dispatch(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
browser_groups = {
target.display_name: target.display_group or "local"
for target, _count in multi_results
if target.display_group or target.remote is None
}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
def multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode.
+1
View File
@@ -0,0 +1 @@
"""Search metadata and helpers shared by SDK and CLI layers."""
+53
View File
@@ -0,0 +1,53 @@
"""Shared search-engine metadata for SDK and CLI search commands."""
from __future__ import annotations
ENGINES = {
"google": "https://www.google.com/search?q={query}",
"brave": "https://search.brave.com/search?q={query}",
"duckduckgo": "https://duckduckgo.com/?q={query}",
"ddg": "https://duckduckgo.com/?q={query}",
"youtube": "https://www.youtube.com/results?search_query={query}",
"yt": "https://www.youtube.com/results?search_query={query}",
"spotify": "https://open.spotify.com/search/{query}",
"amazon": "https://www.amazon.com/s?k={query}",
"ecosia": "https://www.ecosia.org/search?q={query}",
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
"fa": "https://www.furaffinity.net/search/?q={query}",
"bing": "https://www.bing.com/search?q={query}",
"github": "https://github.com/search?q={query}",
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"reddit": "https://www.reddit.com/search/?q={query}",
"stackoverflow": "https://stackoverflow.com/search?q={query}",
"so": "https://stackoverflow.com/search?q={query}",
}
DISPLAY_NAMES = {
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
}
SUBCOMMANDS = [
("google", "Search with Google."),
("brave", "Search with Brave Search."),
("duckduckgo", "Search with DuckDuckGo."),
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
("youtube", "Search YouTube videos."),
("yt", "Search YouTube (alias for youtube)."),
("spotify", "Search Spotify."),
("amazon", "Search Amazon."),
("ecosia", "Search with Ecosia."),
("furaffinity", "Search FurAffinity."),
("fa", "Search FurAffinity (alias for furaffinity)."),
("bing", "Search with Bing."),
("github", "Search GitHub."),
("wikipedia", "Search Wikipedia."),
("wiki", "Search Wikipedia (alias for wikipedia)."),
("reddit", "Search Reddit."),
("stackoverflow", "Search Stack Overflow."),
("so", "Search Stack Overflow (alias for stackoverflow)."),
]
+4
View File
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
server_key = await asyncio.to_thread(load_or_create_server_identity)
challenge_msg["server_pubkey"] = public_key_hex(server_key)
challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
return nonce, pq_private_key, challenge_msg
+70 -11
View File
@@ -10,6 +10,7 @@ class ServeControlMixin:
addr: tuple
command: str
auth_keys_path: Path | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_ok(self, payload, command: str | None = None) -> None: ...
@@ -23,29 +24,39 @@ class ServeControlMixin:
try:
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
if clients:
browser_name = clients[0].get("name")
if browser_name:
item["browserName"] = browser_name
# Carry the full client info so a remote `clients` command can render
# from this single roundtrip instead of issuing another clients.list.
info = clients[0]
for src, dst in (("name", "browserName"), ("version", "version"), ("extensionVersion", "extensionVersion")):
value = info.get(src)
if value:
item[dst] = value
except Exception:
pass
targets.append(item)
await self.send_ok(targets, self.command)
log_request(self.addr, self.command, None, "OK")
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
if self.command == "browser-cli.auth.keys":
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file", identity=self.auth_label)
return True
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
from browser_cli.auth import load_authorized_keys_with_policies
entries = [
{"pubkey": pk, "name": name, "allow": cats}
for pk, name, cats in load_authorized_keys_with_policies(self.auth_keys_path)
]
await self.send_ok(entries, self.command)
log_request(self.addr, self.command, None, "OK")
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg)
if self.command == "browser-cli.auth.policy":
return await self._handle_policy(msg)
return False
async def _handle_trust(self, msg: dict) -> bool:
@@ -57,11 +68,59 @@ class ServeControlMixin:
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
categories = args.get("allow")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name)
if not await self._validate_categories(categories):
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
return True
async def _handle_policy(self, msg: dict) -> bool:
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import set_authorized_key_policy
args = msg.get("args") or {}
identifier = str(args.get("identifier") or "")
categories = args.get("allow")
if not identifier.strip():
await self.send_error("missing key identifier")
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
return True
if not await self._validate_categories(categories):
return True
try:
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
return True
if updated is None:
await self.send_error(f"trusted key not found: {identifier}")
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
return True
pubkey, name = updated
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
async def _validate_categories(self, categories) -> bool:
if categories is not None and not isinstance(categories, list):
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return False
if categories is not None:
from browser_cli.serve.security import policy_from_categories
try:
policy_from_categories(categories) # validate before persisting
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
return False
return True
+11 -3
View File
@@ -6,11 +6,19 @@ from rich.console import Console
console = Console()
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
def log_request(
addr: tuple,
command: str,
profile: str | None,
status: str,
error: str | None = None,
identity: str | None = None,
) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
identity_str = f"[magenta]{identity}[/magenta] " if identity else ""
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
+5 -4
View File
@@ -18,6 +18,7 @@ class ServeProxyMixin:
command: str
compress: bool
accept_encoding: dict | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_payload(self, data: bytes) -> None: ...
@@ -35,7 +36,7 @@ class ServeProxyMixin:
sock_path = resolve_socket(resolved_profile)
except BrowserNotConnected as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected", identity=self.auth_label)
return
try:
@@ -46,7 +47,7 @@ class ServeProxyMixin:
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
except (OSError, json.JSONDecodeError, ConnectionError) as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e), identity=self.auth_label)
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
from multiprocessing.connection import Client as PipeClient
@@ -74,6 +75,6 @@ class ServeProxyMixin:
else:
await self.send_payload(resp_payload)
if resp_data.get("success", True):
log_request(self.addr, self.command, resolved_profile, "OK")
log_request(self.addr, self.command, resolved_profile, "OK", identity=self.auth_label)
else:
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""), identity=self.auth_label)
+87 -6
View File
@@ -9,17 +9,20 @@ from __future__ import annotations
import asyncio
import json
import socket
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from browser_cli import transport
from browser_cli.command_security import assert_command_allowed
from browser_cli.compat import adapt_auth
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
from browser_cli.serve.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin
from browser_cli.serve.security import ServeSecurity
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data)
@@ -38,12 +41,15 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
nonce: str
pq_private_key: object | None = None
compress: bool = True
security: ServeSecurity = field(default_factory=ServeSecurity)
response_secret: bytes | None = None
accept_encoding: dict | None = None
client_ver: str = "0"
msg_id: object = None
command: str = "?"
auth_pubkey: str | None = None
auth_label: str | None = None
async def send_payload(self, data: bytes) -> None:
if self.response_secret is not None:
@@ -89,11 +95,73 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
msg = await self.authenticate(msg)
if msg is None:
return
self._apply_identity(msg)
await self._dispatch(msg)
# Once an encrypted session is established, keep serving further commands on
# the same connection — the client may reuse it without re-authenticating.
# Safe because every frame carries a fresh AEAD nonce (see pq_encrypt).
while self.response_secret is not None:
nxt = await self._read_session_message()
if nxt is None:
return
await self._dispatch(nxt)
def _apply_identity(self, msg: dict) -> None:
"""Record the authenticated pubkey (if any) for per-key policy and audit logs."""
pub = (msg.get("pubkey") or "").strip().lower()
self.auth_pubkey = pub or None
self.auth_label = self.security.label_for(self.auth_pubkey)
async def _enforce_rate_limit(self) -> bool:
limiter = self.security.rate_limiter
if limiter is None or limiter.allow(self.auth_pubkey or str(self.addr[0])):
return True
await self.send_error("rate limit exceeded; slow down and retry")
log_request(self.addr, self.command, None, "DENIED", "rate limit exceeded", identity=self.auth_label)
return False
async def _dispatch(self, msg: dict) -> None:
self.accept_encoding = msg.get("accept_encoding")
if not await self._enforce_rate_limit():
return
# Gate every command — including server control commands like the key-management
# ones — so the policy is enforced before handle_control_command acts on it.
try:
assert_command_allowed(self.command, self.security.effective_policy(self.auth_pubkey))
except PermissionError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "DENIED", "blocked by command policy", identity=self.auth_label)
return
if await self.handle_control_command(msg):
return
await self.forward_to_browser(msg)
async def _read_session_message(self) -> dict | None:
"""Read the next command on an established encrypted session, or None to close."""
try:
payload = await asyncio.wait_for(_async_recv_all(self.reader), timeout=REMOTE_SESSION_IDLE_TIMEOUT)
except (asyncio.TimeoutError, ConnectionError, OSError):
return None
if not payload:
return None
try:
outer = json.loads(payload)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(outer, dict) or "encrypted" not in outer:
return None # an authenticated session only accepts encrypted frames
from browser_cli.auth import pq_decrypt
try:
inner = json.loads(pq_decrypt(self.response_secret, "request", outer["encrypted"]))
except Exception:
return None
if not isinstance(inner, dict):
return None
inner = adapt_auth(inner, self.client_ver)
self.msg_id = inner.get("id")
self.command = inner.get("command", "?")
return inner
async def _async_proxy_request(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
@@ -104,8 +172,12 @@ async def _async_proxy_request(
nonce: str,
pq_private_key=None,
compress: bool = True,
security: ServeSecurity | None = None,
) -> None:
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
await ServeRequest(
reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress,
security if security is not None else ServeSecurity(),
).run()
async def _async_handle_client(
reader: asyncio.StreamReader,
@@ -115,6 +187,7 @@ async def _async_handle_client(
auth_keys_path: Path | None,
compress: bool = True,
conn_limit: asyncio.Semaphore | None = None,
security: ServeSecurity | None = None,
) -> None:
if conn_limit is None:
conn_limit = asyncio.Semaphore(64)
@@ -130,7 +203,7 @@ async def _async_handle_client(
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
except OSError:
return
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress, security)
finally:
conn_limit.release()
writer.close()
@@ -145,12 +218,13 @@ def _handle_client(
profile: str | None,
auth_keys_path: Path | None,
compress: bool = True,
security: ServeSecurity | None = None,
) -> None:
"""Run one accepted socket through the async serve pipeline."""
async def _run() -> None:
reader, writer = await asyncio.open_connection(sock=client_sock)
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress, None, security)
try:
asyncio.run(_run())
@@ -160,12 +234,19 @@ def _handle_client(
except OSError:
pass
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
async def _serve_async(
host: str,
port: int,
profile: str | None,
auth_keys_path: Path | None,
compress: bool,
security: ServeSecurity | None = None,
) -> None:
conn_limit = asyncio.Semaphore(64)
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername") or ("?", 0)
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit, security)
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
async with server:
+144
View File
@@ -0,0 +1,144 @@
"""Server-side authorization, per-key policy and rate limiting for ``browser-cli serve``.
This bundles the three serve-time security concerns that travel together through
the connection-handling chain:
- ``policy`` the server-wide default ``CommandPolicy`` (from ``--allow-*``)
- ``key_policies`` optional per-pubkey overrides parsed from the ``allow:`` token
in the ``authorized_keys`` file
- ``key_names`` pubkey -> friendly name (from authorized_keys), for audit logs
- ``rate_limiter`` optional per-identity token-bucket throttle
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from browser_cli.command_security import CommandPolicy
# ── per-key authorization ───────────────────────────────────────────────────────
_CATEGORY_FLAGS = {
"read-page": "allow_read_page",
"control": "allow_control",
"dangerous": "allow_dangerous",
"keys": "allow_keys",
}
def policy_from_categories(categories) -> CommandPolicy:
"""Build a CommandPolicy from category strings (``all``/``safe``/``read-page``/``control``/``dangerous``)."""
cats = [str(c).strip().lower() for c in categories]
if "all" in cats:
return CommandPolicy.unrestricted()
kwargs: dict[str, bool] = {}
for cat in cats:
if cat in ("", "safe"):
continue
flag = _CATEGORY_FLAGS.get(cat)
if flag is None:
raise ValueError(
f"unknown command category {cat!r}; expected one of: all, safe, read-page, control, dangerous"
)
kwargs[flag] = True
return CommandPolicy(**kwargs)
def key_policies_from_authorized_keys(path: Path | str | None) -> dict[str, CommandPolicy]:
"""Build ``{pubkey: CommandPolicy}`` from the ``allow:`` tokens in authorized_keys.
Only keys that carry an explicit ``allow:`` token get an entry; keys without
one fall back to the server-wide default policy. Pubkeys are normalised to
lowercase hex. Raises ``ValueError`` on an unknown category so the server fails
loudly at startup rather than silently mis-gating.
"""
if path is None:
return {}
from browser_cli.auth import load_authorized_keys_with_policies
out: dict[str, CommandPolicy] = {}
for pubkey, _name, categories in load_authorized_keys_with_policies(Path(path)):
if categories is not None:
out[pubkey.strip().lower()] = policy_from_categories(categories)
return out
# ── per-identity rate limiting ───────────────────────────────────────────────────
class RateLimiter:
"""Token bucket keyed by identity (pubkey, or client address when unauthenticated).
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
Thread-safe so it can be shared across all connections of one serve process.
The bucket table is capped. Without that bound, a long-running public server
could retain one entry per ever-seen identity/IP forever; GC cannot reclaim
those entries because the limiter still references them.
"""
def __init__(self, rate: float, burst: float | None = None, max_buckets: int = 4096) -> None:
self.rate = float(rate)
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
self.max_buckets = max(1, int(max_buckets))
self._buckets: dict[str, tuple[float, float]] = {}
self._lock = threading.Lock()
def _prune_locked(self, now: float) -> None:
"""Drop idle/full buckets, then oldest buckets, until the table is bounded."""
if len(self._buckets) < self.max_buckets or self.rate <= 0:
return
# Once a bucket has fully refilled, keeping it around carries no useful
# throttling state. Use at least 60s so normal active identities are not
# churned out aggressively on high-rate configs.
idle_seconds = max(60.0, (self.capacity / self.rate) * 2)
full_epsilon = 1e-9
for bucket_key, (tokens, last) in list(self._buckets.items()):
refilled = min(self.capacity, tokens + (now - last) * self.rate)
if refilled >= self.capacity - full_epsilon and now - last >= idle_seconds:
self._buckets.pop(bucket_key, None)
# If an attacker keeps creating fresh identities faster than they go idle,
# still keep memory bounded. Evict the oldest identity state; that may reset
# throttling for that identity, but bounded memory is more important here.
while len(self._buckets) >= self.max_buckets:
oldest_key = min(self._buckets, key=lambda k: self._buckets[k][1])
self._buckets.pop(oldest_key, None)
def allow(self, key: str) -> bool:
if self.rate <= 0:
return True
now = time.monotonic()
with self._lock:
if key not in self._buckets and len(self._buckets) >= self.max_buckets:
self._prune_locked(now)
tokens, last = self._buckets.get(key, (self.capacity, now))
tokens = min(self.capacity, tokens + (now - last) * self.rate)
if tokens < 1.0:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1.0, now)
return True
# ── bundled server security context ──────────────────────────────────────────────
@dataclass(frozen=True)
class ServeSecurity:
policy: CommandPolicy = field(default_factory=CommandPolicy.unrestricted)
key_policies: dict[str, CommandPolicy] = field(default_factory=dict)
key_names: dict[str, str] = field(default_factory=dict)
rate_limiter: RateLimiter | None = None
def effective_policy(self, pubkey: str | None) -> CommandPolicy:
"""Per-key override if one exists for this pubkey, else the server default."""
if pubkey and pubkey in self.key_policies:
return self.key_policies[pubkey]
return self.policy
def label_for(self, pubkey: str | None) -> str | None:
"""Audit label for log lines: ``<name> <short-pubkey>…`` or just the short pubkey."""
if not pubkey:
return None
short = f"{pubkey[:8]}…"
name = self.key_names.get(pubkey, "")
return f"{name} {short}".strip() if name else short
+25 -9
View File
@@ -1,17 +1,33 @@
from importlib.metadata import version as _pkg_version
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version(PYPI_PACKAGE_NAME)
except Exception:
return "0.0.0"
try:
return _pkg_version(PYPI_PACKAGE_NAME)
except Exception:
return "0.0.0"
def project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
for line in content.splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except OSError:
pass
try:
return _pkg_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
USER_AGENT = f"browser-cli/{get_installed_version()}"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.15.5",
"version": "0.16.7",
"description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"gecko": {
+19 -3
View File
@@ -17,17 +17,33 @@ function isCommandSpec(entry: CommandEntry): entry is CommandSpec {
return typeof entry !== "function";
}
// Fill each page up to a byte budget kept safely under the 1MB native-messaging
// limit (extension → host). This makes paging adaptive: many small items pack
// into one page, while a few oversized items (e.g. data-URI favicons) split
// across pages instead of overflowing the limit.
const PAGE_BYTE_BUDGET = 768 * 1024;
export function makePagedData(items: Serializable[], page: PageRequest) {
const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0);
const requestedLimit = Math.max(1, Number(page.limit) || 100);
const limit = Math.min(requestedLimit, 1000);
const end = Math.min(offset + limit, total);
const maxCount = Math.min(requestedLimit, 1000);
let end = offset;
let bytes = 0;
while (end < total && end - offset < maxCount) {
const itemBytes = JSON.stringify(items[end]).length + 1; // +1 ≈ separator
// Always include at least one item so a single oversized item still advances.
if (end > offset && bytes + itemBytes > PAGE_BYTE_BUDGET) break;
bytes += itemBytes;
end++;
}
return {
__browserCliPage: true,
items: items.slice(offset, end),
offset,
limit,
limit: maxCount,
total,
nextOffset: end < total ? end : null,
};
+10
View File
@@ -15,6 +15,11 @@ import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types';
// jobs only need to survive long enough for the CLI to poll their result.
export const MAX_FINISHED_JOBS = 20;
// Cap simultaneously running background jobs. A hung job has a watchdog, but a
// command flood could still pin many timers/results for up to JOB_TIMEOUT_MS.
// Rejecting above this bound keeps service-worker memory predictable.
export const MAX_RUNNING_JOBS = 32;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead
// of writing to api.storage.local every second forever.
@@ -77,6 +82,11 @@ export class JobManager {
}
async start(command: string, args: DispatchArgs, runner: JobRunner) {
const runningCount = [...this.jobs.values()].filter(job => job.status === "running").length;
if (runningCount >= MAX_RUNNING_JOBS) {
throw new Error(`too many background jobs running (${runningCount}); wait for jobs to finish or cancel one`);
}
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const job: Job = {
id: jobId,
+2 -2
View File
@@ -56,7 +56,7 @@ export class DomCommands extends CommandGroup {
"dom.poll": (a: DomPollArgs) => this.domPoll(a),
// ── Page ─────────────────────────────────────────────────────────────
"page.info": () => this.domOp("pageInfo", {}),
"page.info": (a: DomArgs) => this.domOp("pageInfo", a),
// ── Extract ──────────────────────────────────────────────────────────
"extract.links": (a: DomArgs) => this.domOp("extractLinks", a),
@@ -64,7 +64,7 @@ export class DomCommands extends CommandGroup {
"extract.text": (a: DomArgs) => this.domOp("extractText", a),
"extract.json": (a: DomArgs) => this.domOp("extractJson", a),
"extract.markdown": (a: DomArgs) => this.domOp("extractMarkdown", a),
"extract.html": () => fetchTabHtml(undefined),
"extract.html": (a: DomArgs = {}) => fetchTabHtml(a.tabId),
};
private async domOp(funcName: string, args: DomArgs = {}) {
+15 -11
View File
@@ -1,7 +1,7 @@
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { PerfSetProfileArgs, JobIdArgs } from '../types';
import type { Job, PerfSetProfileArgs, JobIdArgs } from '../types';
// PerfCommands also owns the jobs.* status/cancel queries: they read the same
// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated
@@ -15,6 +15,19 @@ export class PerfCommands extends CommandGroup {
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
};
private jobSummary(job: Job) {
return {
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
};
}
private async perfStatus() {
const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs();
@@ -23,16 +36,7 @@ export class PerfCommands extends CommandGroup {
performanceProfile: profile,
audible,
throttle,
jobs: this.ctx.jobs.list().map(job => ({
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
})),
jobs: this.ctx.jobs.list().map(job => this.jobSummary(job)),
};
}
}
+22 -2
View File
@@ -4,6 +4,26 @@ import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
/** Convert a shell-style glob (`*` = any run, `?` = any single char) to an
* unanchored RegExp. Every other character is matched literally. Unanchored so
* `twitch.tv/*` matches anywhere inside `https://www.twitch.tv/foo`. */
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
return new RegExp(escaped.replace(/\*/g, '.*').replace(/\?/g, '.'));
}
/**
* Match a tab URL against a pattern. Backward-compatible: a pattern with no
* glob metacharacters is a plain case-sensitive substring match (the historic
* behavior); a pattern containing `*` or `?` is treated as a glob, so
* `twitch.tv/*` matches every Twitch tab.
*/
export function urlMatchesPattern(url: string | undefined, pattern: string): boolean {
if (!url || !pattern) return false;
if (/[*?]/.test(pattern)) return globToRegExp(pattern).test(url);
return url.includes(pattern);
}
export class TabsQueryCommands extends CommandGroup {
readonly namespace = "tabs";
readonly commands: Record<string, CommandEntry> = {
@@ -50,12 +70,12 @@ export class TabsQueryCommands extends CommandGroup {
private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
return all.filter(t => urlMatchesPattern(t.url, pattern)).map(tabInfo);
}
private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
if (pattern) return all.filter(t => urlMatchesPattern(t.url, pattern)).length;
return all.length;
}
+29 -2
View File
@@ -14,9 +14,31 @@ export class WindowsCommands extends CommandGroup {
"windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a),
};
private async activeWindowIds(): Promise<Set<number>> {
const windows = await api.windows.getAll({});
return new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
}
private async pruneAliases(activeIds?: Set<number>): Promise<Record<string, string>> {
const aliases = await getAliases();
const liveIds = activeIds || await this.activeWindowIds();
const pruned: Record<string, string> = {};
let changed = false;
for (const [id, alias] of Object.entries(aliases)) {
if (liveIds.has(Number(id))) {
pruned[id] = alias;
} else {
changed = true;
}
}
if (changed) await api.storage.local.set({ windowAliases: pruned });
return pruned;
}
private async windowsList() {
const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases();
const activeIds = new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
const aliases = await this.pruneAliases(activeIds);
return windows.map(w => ({
id: w.id,
alias: aliases[w.id] || null,
@@ -27,7 +49,7 @@ export class WindowsCommands extends CommandGroup {
}
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases();
const aliases = await this.pruneAliases();
aliases[windowId] = name;
await api.storage.local.set({ windowAliases: aliases });
return { windowId, name };
@@ -35,6 +57,11 @@ export class WindowsCommands extends CommandGroup {
private async windowsClose({ windowId }: WindowsCloseArgs) {
await api.windows.remove(windowId);
const aliases = await this.pruneAliases();
if (windowId in aliases) {
delete aliases[windowId];
await api.storage.local.set({ windowAliases: aliases });
}
return { windowId };
}
+16 -1
View File
@@ -1,7 +1,7 @@
// @ts-nocheck
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, MAX_RUNNING_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
@@ -129,6 +129,21 @@ test('JobManager: a runner that settles after the watchdog cannot resurrect the
mock.timers.reset();
});
test('JobManager: rejects new background jobs above the running-job cap', async () => {
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
globalThis.chrome = makeChromeMock();
const mgr = new JobManager();
for (let i = 0; i < MAX_RUNNING_JOBS; i++) {
await mgr.start(`running${i}`, {}, () => new Promise(() => {}));
}
await assert.rejects(
() => mgr.start('overflow', {}, async () => 'nope'),
/too many background jobs running/,
);
assert.equal(mgr.list().filter(job => job.status === 'running').length, MAX_RUNNING_JOBS);
mock.timers.reset();
});
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
globalThis.chrome = makeChromeMock();
+41
View File
@@ -0,0 +1,41 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { makePagedData } from '../src/classes/CommandRegistry';
test('makePagedData packs many small items into one page up to the count cap', () => {
const items = Array.from({ length: 1500 }, (_, i) => ({ id: i, url: 'chrome://newtab/' }));
const page = makePagedData(items, { offset: 0, limit: 1000 });
assert.equal(page.items.length, 1000); // count cap, well under the byte budget
assert.equal(page.nextOffset, 1000);
assert.equal(page.total, 1500);
});
test('makePagedData splits on the byte budget for oversized items', () => {
// Each item ~200KB; only a few fit under the 768KB budget per page.
const big = 'x'.repeat(200 * 1024);
const items = Array.from({ length: 10 }, (_, i) => ({ id: i, favIconUrl: big }));
const page = makePagedData(items, { offset: 0, limit: 1000 });
assert.ok(page.items.length >= 1 && page.items.length < 10, `expected partial page, got ${page.items.length}`);
assert.equal(page.nextOffset, page.items.length);
});
test('makePagedData always advances by at least one item', () => {
// A single item larger than the whole budget must still be returned alone.
const huge = 'x'.repeat(2 * 1024 * 1024);
const items = [{ id: 0, favIconUrl: huge }, { id: 1 }];
const page = makePagedData(items, { offset: 0, limit: 1000 });
assert.equal(page.items.length, 1);
assert.equal(page.nextOffset, 1);
});
test('makePagedData reports null nextOffset on the final page', () => {
const items = [{ id: 0 }, { id: 1 }, { id: 2 }];
const page = makePagedData(items, { offset: 2, limit: 1000 });
assert.equal(page.items.length, 1);
assert.equal(page.nextOffset, null);
});
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { urlMatchesPattern } from '../src/commands/tabs-query';
const TWITCH = 'https://www.twitch.tv/somechannel';
test('plain pattern is a case-sensitive substring match (historic behavior)', () => {
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv'), true);
assert.equal(urlMatchesPattern(TWITCH, 'somechannel'), true);
assert.equal(urlMatchesPattern(TWITCH, 'Twitch.tv'), false, 'case-sensitive');
assert.equal(urlMatchesPattern(TWITCH, 'youtube.com'), false);
});
test('glob with /* matches anywhere in the URL', () => {
// The reported case: a glob, not a literal substring.
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://www.twitch.tv/', 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://twitch.tv', 'twitch.tv/*'), false, 'no slash → no match');
});
test('leading wildcard and ? wildcard work', () => {
assert.equal(urlMatchesPattern(TWITCH, '*.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://a.twitch.tv/x', 'https://?.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://ab.twitch.tv/x', 'https://?.twitch.tv/*'), false, '? is one char');
});
test('regex metacharacters in a non-glob pattern stay literal', () => {
assert.equal(urlMatchesPattern('https://x.dev/a.b', 'a.b'), true);
assert.equal(urlMatchesPattern('https://x.dev/axb', 'a.b'), false, 'plain substring is literal — "." is not a regex wildcard');
});
test('regex metacharacters next to a glob are escaped', () => {
// The "." must stay literal even when "*" promotes the pattern to a glob.
assert.equal(urlMatchesPattern('https://x.dev/foo', 'x.dev/*'), true);
assert.equal(urlMatchesPattern('https://xydev/foo', 'x.dev/*'), false, '. does not match y');
});
test('empty url or pattern never matches', () => {
assert.equal(urlMatchesPattern('', 'twitch.tv'), false);
assert.equal(urlMatchesPattern(undefined, 'twitch.tv'), false);
assert.equal(urlMatchesPattern(TWITCH, ''), false);
});
+69
View File
@@ -0,0 +1,69 @@
// @ts-nocheck
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { WindowsCommands } from '../src/commands/windows';
import { makeChromeMock } from './chrome-mock';
function makeWindowsChromeMock(windows) {
const chrome = makeChromeMock();
chrome.windows = {
getAll: async () => windows,
remove: async () => {},
create: async () => ({ id: 99 }),
};
return chrome;
}
test('windows.list prunes aliases for closed windows', async () => {
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [{ id: 10 }] },
{ id: 2, focused: false, state: 'minimized', tabs: [] },
]);
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
2: 'side',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
const result = await commands.commands['windows.list']({});
assert.deepEqual(result.map(w => [w.id, w.alias]), [[1, 'main'], [2, 'side']]);
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'side' });
});
test('windows.rename prunes stale aliases before saving the new name', async () => {
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [] },
{ id: 2, focused: false, state: 'normal', tabs: [] },
]);
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
await commands.commands['windows.rename']({ windowId: 2, name: 'work' });
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'work' });
});
test('windows.close removes the closed window alias immediately', async () => {
let removed = null;
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [] },
{ id: 2, focused: false, state: 'normal', tabs: [] },
]);
globalThis.chrome.windows.remove = async id => { removed = id; };
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
2: 'side',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
await commands.commands['windows.close']({ windowId: 2 });
assert.equal(removed, 2);
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main' });
});
+12
View File
@@ -74,6 +74,18 @@ version-check:
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
# Build into /tmp/dist-browser-cli and publish using credentials from .env
publish:
#!/usr/bin/env bash
set -euo pipefail
set -a
[ ! -f .env ] || source .env
set +a
rm -rf /tmp/dist-browser-cli
mkdir -p /tmp/dist-browser-cli
uv build --out-dir /tmp/dist-browser-cli
uv publish /tmp/dist-browser-cli/*
# ── Demos ──────────────────────────────────────────────────────────────
# Run the Python SDK demo
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
test-dist/
*.tsbuildinfo
+124
View File
@@ -0,0 +1,124 @@
# n8n-nodes-browser-cli
An [n8n](https://n8n.io) community node, published on npm as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli), that controls a **real, visible browser**
from your workflows via [browser-cli](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp).
browser-cli drives a running browser through a native-messaging host and a
browser extension — it **cannot be installed inside the n8n container**. So this
node speaks the `browser-cli serve` protocol **directly**: a length-framed TCP
connection authenticated with an Ed25519 key, with request/response bodies
encrypted end-to-end via an ML-KEM-768 (post-quantum) key exchange — the same
wire protocol the `browser-cli --remote` client uses.
```
n8n workflow ──TCP (Ed25519 + ML-KEM-768)──▶ browser-cli serve (remote host) ──▶ browser
```
Because the payloads are end-to-end encrypted, the endpoint is safe to expose on
an untrusted network without a TLS proxy in front of it.
## Remote setup (on the browser machine)
Install browser-cli, register the extension, trust your n8n key, then start
`serve` opening exactly the command tiers you need (it is **safe-only by default**):
```bash
uv tool install real-browser-cli
browser-cli install brave # one-time: register the extension/native host
# On the n8n side, generate a client key and print its public key:
browser-cli auth keygen -o n8n_key.pem
# On the browser machine, trust that public key (optionally scope its policy):
browser-cli auth trust <pubkey-hex> --allow-control
# Expose the browser. Open only what your workflow needs:
browser-cli serve --host 0.0.0.0 --port 8765 \
--authorized-keys ~/.browser_cli/authorized_keys --allow-read-page --allow-control
```
Paste the contents of `n8n_key.pem` into the n8n credential.
## n8n credential — "Browser CLI API"
| Field | Description |
|-------|-------------|
| Host | host of the `serve` endpoint, e.g. `browser-host.example` |
| Port | `serve` TCP port (default `8765`) |
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
| Browser Alias | optional `_route` target — required if the endpoint serves multiple browsers |
| Server Public Key/Fingerprint | pinned `browser-cli serve` identity (`SHA256:...` fingerprint or 64-char server public key hex) |
| Allow Unknown Server Identity | disables SSH-style server pinning; use only for loopback/dev |
| Use TLS | wrap the connection in TLS (only for a TLS-terminating proxy; the protocol is already encrypted) |
| Ignore SSL Issues | when TLS is on, accept a self-signed proxy cert |
### Server identity pinning
Recent `browser-cli serve` versions advertise a persistent Ed25519 server
identity in the challenge frame. The n8n node verifies the challenge signature
and compares the key against the credential's **Server Public Key/Fingerprint**
field, similar to SSH `known_hosts`.
On a trusted machine, pin the server once with the Python CLI and copy the
fingerprint into the n8n credential:
```bash
browser-cli remote trust-host browser-host.example:8765
browser-cli remote known-hosts
```
If the server key changes, the node fails with `REMOTE SERVER IDENTITY CHANGED`.
Only enable **Allow Unknown Server Identity** for local/dev endpoints where you
explicitly do not want pinning.
## Operations
Every operation maps to one raw browser-cli command, each subject to the server
policy tier noted below.
| Resource | Operation | Command | Server flag needed |
|----------|-----------|---------|--------------------|
| Tab | List / Query / Get / Count / Filter / Active in Window | `tabs.list` / `tabs.query` / `tabs.status` / `tabs.count` / `tabs.filter` / `tabs.active_in_window` | safe (default) |
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
| Tab | Open / Close / Activate / Move / Navigate To / Reload / Hard Reload / Back / Forward | `navigate.open` / `tabs.close` / `tabs.active` / `tabs.move` / `navigate.to` / `navigate.reload` / `navigate.hard_reload` / `navigate.back` / `navigate.forward` | `--allow-control` |
| Tab | Mute / Unmute / Pin / Unpin / Dedupe / Sort / Merge Windows | `tabs.mute` / `tabs.unmute` / `tabs.pin` / `tabs.unpin` / `tabs.dedupe` / `tabs.sort` / `tabs.merge_windows` | `--allow-control` |
| Tab | Screenshot | `tabs.screenshot` | `--allow-dangerous` |
| Page | Get Info | `page.info` | safe (default) |
| Page | Extract Text / Links / Images / HTML / Markdown / JSON | `extract.*` | `--allow-read-page` |
| DOM | Query / Text / Attribute / Exists | `dom.query` / `dom.text` / `dom.attr` / `dom.exists` | `--allow-read-page` |
| DOM | Click / Type / Select / Hover / Focus / Check / Uncheck / Clear / Submit / Scroll / Key | `dom.*` | `--allow-control` |
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
| Group | List / Query / Tabs | `group.list` / `group.query` / `group.tabs` | safe (default) |
| Group | Count / Create / Add Tab / Move / Close | `group.count` / `group.open` / `group.add_tab` / `group.move` / `group.close` | `--allow-control` |
| Window | List | `windows.list` | safe (default) |
| Window | Open / Close / Rename | `windows.open` / `windows.close` / `windows.rename` | `--allow-control` |
| Session | List / Save / Load / Remove / Export / Diff / Auto Save | `session.*` | `--allow-control` |
| Storage | Get / Set | `storage.get` / `storage.set` | `--allow-dangerous` |
| Performance | Status | `perf.status` | safe (default) |
| Extension | Info / Capabilities | `extension.info` / `extension.capabilities` | safe (default) |
| Extension | Reload | `extension.reload` | `--allow-control` |
| Client | List | `clients.list` | safe (default) |
| Command | Execute | any command name + JSON args | per command |
**Command → Execute** is the escape hatch: any command string the server policy
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
Use it for anything the typed operations don't cover.
> Note: `serve` returns the **raw** command result (no SDK post-processing).
> `extract.markdown` therefore returns the page payload as the extension hands it
> back, not the CLI's rendered Markdown. For clean text use **Extract Text**.
> **Tab → Filter / Count URL Pattern:** matched against the full tab URL. A plain
> string is a case-sensitive substring (`twitch.tv`); a pattern containing `*` or
> `?` is a glob (`twitch.tv/*`, `*.twitch.tv`). Glob needs the serve-side extension
> at **0.16.4+**; older extensions treat the whole pattern as a literal substring,
> so `twitch.tv/*` matches nothing there — use `twitch.tv` instead.
## Develop / build
```bash
cd n8n-nodes-browser-cli
npm install # add --ignore-scripts if a transitive native dep
# (isolated-vm) fails to compile on your Node version
npm test # pure unit tests: command mapping + crypto known-answer vectors
npm run build # tsc -> dist/, copies the icon
```
Install the published package in n8n as a [community node](https://docs.n8n.io/integrations/community-nodes/installation/) using the package name `n8n-nodes-browser-cli`, or symlink `dist/` into `~/.n8n/custom` for local testing.
## License
PolyForm Noncommercial License 1.0.0 — same as browser-cli.
@@ -0,0 +1,102 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
/**
* Credentials for a raw `browser-cli serve` endpoint.
*
* browser-cli cannot be installed inside n8n, so the node talks directly to a
* `serve` instance running on the machine that drives the browser. Start it
* there and trust this client's key:
*
* browser-cli auth keygen # on the n8n side, prints a PEM
* browser-cli auth trust <pubkey> --allow-control # on the serve side
* browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.browser_cli/authorized_keys
*
* The connection is authenticated with the Ed25519 private key below and the
* request/response bodies are encrypted with an ML-KEM-768 (post-quantum) key
* exchange, so it is safe to expose over an untrusted network without TLS.
* Leave the key empty only for a loopback `serve --no-auth` instance.
*/
export class BrowserCliApi implements ICredentialType {
name = 'browserCliApi';
displayName = 'Browser CLI API';
documentationUrl = 'https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp';
// The serve protocol is raw TCP, not HTTP, so the declarative HTTP test does
// not apply — testing is done by the node method of this name, which runs a
// real authenticated handshake against the endpoint.
testedBy = 'browserCliApiTest';
properties: INodeProperties[] = [
{
displayName: 'Host',
name: 'host',
type: 'string',
default: '127.0.0.1',
placeholder: 'browser-host.example',
required: true,
description: 'Host of the remote `browser-cli serve` endpoint',
},
{
displayName: 'Port',
name: 'port',
type: 'number',
default: 8765,
required: true,
description: 'TCP port the `serve` endpoint listens on',
},
{
displayName: 'Ed25519 Private Key',
name: 'privateKey',
type: 'string',
typeOptions: { password: true, rows: 4 },
default: '',
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
description:
'PKCS8 PEM Ed25519 private key (from `browser-cli auth keygen`) whose public key is trusted by the serve endpoint. Leave empty only for a loopback `serve --no-auth` instance.',
},
{
displayName: 'Browser Alias',
name: 'browser',
type: 'string',
default: '',
placeholder: 'main',
description:
'Optional browser alias to route to (the serve `_route` target). Required when the serve endpoint exposes multiple browser instances; leave empty for a single-browser serve.',
},
{
displayName: 'Server Public Key/Fingerprint',
name: 'serverIdentity',
type: 'string',
default: '',
placeholder: 'SHA256:... or 64-char Ed25519 public key hex',
description:
'Pinned browser-cli serve identity. Get it with `browser-cli remote trust-host ENDPOINT` / `browser-cli remote known-hosts`, then paste the SHA256 fingerprint or raw server public key here. Required for non-loopback endpoints.',
},
{
displayName: 'Allow Unknown Server Identity',
name: 'allowUnknownServerIdentity',
type: 'boolean',
default: false,
description:
'Whether to connect without a pinned server identity. Only use for local development/loopback; disabling pinning weakens SSH-style host verification.',
},
{
displayName: 'Use TLS',
name: 'tls',
type: 'boolean',
default: false,
description:
'Whether to wrap the connection in TLS. Only needed when `serve` sits behind a TLS-terminating proxy; the protocol is already end-to-end encrypted via post-quantum key exchange.',
},
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
default: false,
displayOptions: { show: { tls: [true] } },
description: 'Whether to connect even when the TLS certificate cannot be verified (e.g. a self-signed proxy)',
},
];
}
@@ -0,0 +1,870 @@
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
IExecuteFunctions,
IDataObject,
IDisplayOptions,
INodeCredentialTestResult,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { buildCommand, type CommandParams } from './request';
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
/** Only show a property for the given resource/operation combinations. */
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
return { resource: [resource], operation: operations };
}
export class BrowserCli implements INodeType {
description: INodeTypeDescription = {
displayName: 'Browser CLI',
name: 'browserCli',
icon: 'file:browserCli.svg',
group: ['transform'],
version: 1,
subtitle: '={{({ tab: "Tab", page: "Page", dom: "DOM", group: "Group", window: "Window", session: "Session", storage: "Storage", perf: "Perf", extension: "Extension", client: "Client", command: "Command" }[$parameter["resource"]] || $parameter["resource"]) + ": " + $parameter["operation"]}}',
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
defaults: { name: 'Browser CLI' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,
credentials: [{ name: 'browserCliApi', required: true }],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Tab', value: 'tab' },
{ name: 'Page', value: 'page' },
{ name: 'DOM', value: 'dom' },
{ name: 'Group', value: 'group' },
{ name: 'Window', value: 'window' },
{ name: 'Session', value: 'session' },
{ name: 'Storage', value: 'storage' },
{ name: 'Performance', value: 'perf' },
{ name: 'Extension', value: 'extension' },
{ name: 'Client', value: 'client' },
{ name: 'Command', value: 'command' },
],
default: 'tab',
},
// --- Tab operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['tab'] } },
options: [
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search tabs by text', description: 'tabs.query (safe)' },
{ name: 'Get', value: 'get', action: 'Get a tab status', description: 'tabs.status (safe)' },
{ name: 'Count', value: 'count', action: 'Count open tabs', description: 'tabs.count (safe)' },
{ name: 'Filter', value: 'filter', action: 'Filter tabs by URL pattern', description: 'tabs.filter (safe)' },
{ name: 'Active in Window', value: 'activeInWindow', action: 'Get active tab in a window', description: 'tabs.active_in_window (safe)' },
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
{ name: 'Activate', value: 'activate', action: 'Switch focus to a tab', description: 'tabs.active (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a tab', description: 'tabs.move (needs --allow-control)' },
{ name: 'Navigate To', value: 'navigateTo', action: 'Navigate a tab to a URL', description: 'navigate.to (needs --allow-control)' },
{ name: 'Reload', value: 'reload', action: 'Reload a tab', description: 'navigate.reload (needs --allow-control)' },
{ name: 'Hard Reload', value: 'hardReload', action: 'Hard reload a tab', description: 'navigate.hard_reload (needs --allow-control)' },
{ name: 'Back', value: 'back', action: 'Go back in history', description: 'navigate.back (needs --allow-control)' },
{ name: 'Forward', value: 'forward', action: 'Go forward in history', description: 'navigate.forward (needs --allow-control)' },
{ name: 'Mute', value: 'mute', action: 'Mute a tab', description: 'tabs.mute (needs --allow-control)' },
{ name: 'Unmute', value: 'unmute', action: 'Unmute a tab', description: 'tabs.unmute (needs --allow-control)' },
{ name: 'Pin', value: 'pin', action: 'Pin a tab', description: 'tabs.pin (needs --allow-control)' },
{ name: 'Unpin', value: 'unpin', action: 'Unpin a tab', description: 'tabs.unpin (needs --allow-control)' },
{ name: 'Dedupe', value: 'dedupe', action: 'Close duplicate tabs', description: 'tabs.dedupe (needs --allow-control)' },
{ name: 'Sort', value: 'sort', action: 'Sort tabs within windows', description: 'tabs.sort (needs --allow-control)' },
{ name: 'Merge Windows', value: 'mergeWindows', action: 'Merge all tabs into one window', description: 'tabs.merge_windows (needs --allow-control)' },
{ name: 'Screenshot', value: 'screenshot', action: 'Capture a tab screenshot', description: 'tabs.screenshot (needs --allow-dangerous)' },
],
default: 'list',
},
// --- Page operations --------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['page'] } },
options: [
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
{ name: 'Extract JSON', value: 'extractJson', action: 'Extract JSON-LD / structured data', description: 'extract.json (needs --allow-read-page)' },
],
default: 'extractText',
},
// --- DOM operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['dom'] } },
options: [
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
{ name: 'Text', value: 'text', action: 'Get element text', description: 'dom.text (needs --allow-read-page)' },
{ name: 'Attribute', value: 'attr', action: 'Get an element attribute', description: 'dom.attr (needs --allow-read-page)' },
{ name: 'Exists', value: 'exists', action: 'Check if an element exists', description: 'dom.exists (needs --allow-read-page)' },
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
{ name: 'Select', value: 'select', action: 'Select a dropdown option', description: 'dom.select (needs --allow-control)' },
{ name: 'Hover', value: 'hover', action: 'Hover over an element', description: 'dom.hover (needs --allow-control)' },
{ name: 'Focus', value: 'focus', action: 'Focus an element', description: 'dom.focus (needs --allow-control)' },
{ name: 'Check', value: 'check', action: 'Check a checkbox', description: 'dom.check (needs --allow-control)' },
{ name: 'Uncheck', value: 'uncheck', action: 'Uncheck a checkbox', description: 'dom.uncheck (needs --allow-control)' },
{ name: 'Clear', value: 'clear', action: 'Clear an input', description: 'dom.clear (needs --allow-control)' },
{ name: 'Submit', value: 'submit', action: 'Submit a form', description: 'dom.submit (needs --allow-control)' },
{ name: 'Scroll', value: 'scroll', action: 'Scroll to an element or position', description: 'dom.scroll (needs --allow-control)' },
{ name: 'Key', value: 'key', action: 'Send a keyboard key', description: 'dom.key (needs --allow-control)' },
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
],
default: 'query',
},
// --- Group operations -------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['group'] } },
options: [
{ name: 'List', value: 'list', action: 'List tab groups', description: 'group.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search groups by name', description: 'group.query (safe)' },
{ name: 'Tabs', value: 'tabs', action: 'List tabs in a group', description: 'group.tabs (safe)' },
{ name: 'Count', value: 'count', action: 'Count tab groups', description: 'group.count (needs --allow-control)' },
{ name: 'Create', value: 'create', action: 'Create a tab group', description: 'group.open (needs --allow-control)' },
{ name: 'Add Tab', value: 'addTab', action: 'Add a tab to a group', description: 'group.add_tab (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a group forward/backward', description: 'group.move (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a tab group', description: 'group.close (needs --allow-control)' },
],
default: 'list',
},
// --- Window operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['window'] } },
options: [
{ name: 'List', value: 'list', action: 'List browser windows', description: 'windows.list (safe)' },
{ name: 'Open', value: 'open', action: 'Open a new window', description: 'windows.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a window', description: 'windows.close (needs --allow-control)' },
{ name: 'Rename', value: 'rename', action: 'Rename a window', description: 'windows.rename (needs --allow-control)' },
],
default: 'list',
},
// --- Session operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['session'] } },
options: [
{ name: 'List', value: 'list', action: 'List saved sessions', description: 'session.list (needs --allow-control)' },
{ name: 'Save', value: 'save', action: 'Save the current session', description: 'session.save (needs --allow-control)' },
{ name: 'Load', value: 'load', action: 'Load a saved session', description: 'session.load (needs --allow-control)' },
{ name: 'Remove', value: 'remove', action: 'Delete a saved session', description: 'session.remove (needs --allow-control)' },
{ name: 'Export', value: 'export', action: 'Export a session as JSON', description: 'session.export (needs --allow-control)' },
{ name: 'Diff', value: 'diff', action: 'Diff two sessions', description: 'session.diff (needs --allow-control)' },
{ name: 'Auto Save', value: 'autoSave', action: 'Toggle session auto-save', description: 'session.auto_save (needs --allow-control)' },
],
default: 'list',
},
// --- Storage operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['storage'] } },
options: [
{ name: 'Get', value: 'get', action: 'Read localStorage / sessionStorage', description: 'storage.get (needs --allow-dangerous)' },
{ name: 'Set', value: 'set', action: 'Write localStorage / sessionStorage', description: 'storage.set (needs --allow-dangerous)' },
],
default: 'get',
},
// --- Performance operations -------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['perf'] } },
options: [
{ name: 'Status', value: 'status', action: 'Get performance status', description: 'perf.status (safe)' },
],
default: 'status',
},
// --- Extension operations ---------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['extension'] } },
options: [
{ name: 'Info', value: 'info', action: 'Get extension info', description: 'extension.info (safe)' },
{ name: 'Capabilities', value: 'capabilities', action: 'List extension capabilities', description: 'extension.capabilities (safe)' },
{ name: 'Reload', value: 'reload', action: 'Reload the extension', description: 'extension.reload (needs --allow-control)' },
],
default: 'info',
},
// --- Client operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['client'] } },
options: [
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
],
default: 'list',
},
// --- Command operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['command'] } },
options: [
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
],
default: 'execute',
},
// --- Shared parameter fields -----------------------------------------
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
required: true,
placeholder: 'https://example.com',
displayOptions: { show: showFor('tab', ['open', 'navigateTo']) },
},
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://example.com',
description: 'Optional URL to open. Leave empty for a blank window/tab.',
displayOptions: { show: { resource: ['window', 'group'], operation: ['open', 'addTab'] } },
},
{
displayName: 'Focus Tab',
name: 'focus',
type: 'boolean',
default: false,
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
displayOptions: { show: showFor('tab', ['open']) },
},
{
displayName: 'Close By',
name: 'mode',
type: 'options',
default: 'ids',
options: [
{ name: 'Tab IDs', value: 'ids' },
{ name: 'Inactive Tabs', value: 'inactive' },
{ name: 'Duplicate Tabs', value: 'duplicates' },
],
displayOptions: { show: showFor('tab', ['close']) },
},
{
displayName: 'Tab IDs',
name: 'tabIds',
type: 'string',
default: '',
placeholder: '123, 456',
description: 'Comma/space separated tab IDs, or a JSON array',
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
},