Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b32410575
|
||
|
|
581cd73cac
|
||
|
|
bd2a18baba
|
||
|
|
7b4d96845d
|
||
|
|
937c6a1ce0
|
||
|
|
6270d8c956
|
||
|
|
1ae9c33f00
|
||
|
|
b91b29d516
|
||
|
|
2c38cc8874
|
||
|
|
cea8a7e994
|
||
|
|
7fe0e27fec
|
@@ -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
|
||||
|
||||
@@ -37,7 +37,6 @@ terminal / python script / remote client
|
||||
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": {} }
|
||||
@@ -50,7 +49,6 @@ 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).
|
||||
@@ -72,6 +70,11 @@ 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
|
||||
@@ -109,7 +112,6 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
browser-cli/
|
||||
├── browser_cli/
|
||||
@@ -144,8 +146,90 @@ browser-cli/
|
||||
|
||||
---
|
||||
|
||||
## CLI reference
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
@@ -153,7 +237,6 @@ If exactly one browser instance is connected, commands auto-target it. Use `--br
|
||||
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
|
||||
@@ -175,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
|
||||
@@ -199,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
|
||||
@@ -226,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
|
||||
@@ -249,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
|
||||
@@ -259,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
|
||||
@@ -272,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)
|
||||
@@ -284,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
|
||||
@@ -298,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
|
||||
@@ -309,7 +389,6 @@ 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
|
||||
@@ -334,22 +413,24 @@ 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. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
|
||||
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`, 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`). 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 …` (works locally and over `--remote`); `auth keys` shows each key's policy.
|
||||
- **`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), but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there. If you must expose it beyond loopback, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted.
|
||||
- **`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
|
||||
|
||||
@@ -485,7 +566,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
|
||||
```
|
||||
|
||||
**Error handling**
|
||||
|
||||
```python
|
||||
from browser_cli import BrowserCLI, BrowserNotConnected
|
||||
|
||||
@@ -517,7 +597,6 @@ if isinstance(counts, BrowserCounts):
|
||||
---
|
||||
|
||||
## Example scripts
|
||||
|
||||
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
|
||||
|
||||
```sh
|
||||
@@ -528,7 +607,6 @@ bash examples/demo.sh
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check:extension # type-check, build extension bundles, syntax-check bundle
|
||||
@@ -569,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.
|
||||
@@ -578,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.
|
||||
|
||||
@@ -24,6 +24,7 @@ from browser_cli.auth.keys import (
|
||||
load_authorized_keys_with_policies,
|
||||
load_private_key,
|
||||
public_key_hex,
|
||||
set_authorized_key_policy,
|
||||
)
|
||||
from browser_cli.auth.pq import (
|
||||
new_nonce,
|
||||
@@ -66,6 +67,7 @@ __all__ = [
|
||||
"pq_kex_server_decapsulate",
|
||||
"pq_kex_server_keypair",
|
||||
"public_key_hex",
|
||||
"set_authorized_key_policy",
|
||||
"sign",
|
||||
"verify",
|
||||
]
|
||||
|
||||
@@ -89,3 +89,37 @@ def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: lis
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -151,21 +151,25 @@ 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) -> dict | None:
|
||||
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.
|
||||
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
|
||||
return {
|
||||
"profile": target.display_name,
|
||||
"profileGroup": target.display_group,
|
||||
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 = []
|
||||
@@ -243,6 +247,34 @@ def collect_browser_clients(
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
|
||||
KEY_COMMANDS = {
|
||||
"browser-cli.auth.keys",
|
||||
"browser-cli.auth.trust",
|
||||
"browser-cli.auth.policy",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -98,6 +98,88 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control,
|
||||
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",
|
||||
@@ -170,11 +252,164 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
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", ""), _policy_label(entry.get("allow")))
|
||||
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:
|
||||
@@ -182,3 +417,20 @@ def _policy_label(categories) -> str:
|
||||
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)
|
||||
|
||||
@@ -1,18 +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.commands.rendering import print_browser_grouped_table_rows
|
||||
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
||||
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."""
|
||||
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
|
||||
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")
|
||||
@click.argument("key_spec")
|
||||
@@ -50,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")
|
||||
|
||||
@@ -11,9 +11,13 @@ 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"}
|
||||
|
||||
@@ -21,6 +25,7 @@ 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:
|
||||
@@ -37,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)
|
||||
@@ -48,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":
|
||||
@@ -64,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:
|
||||
@@ -90,21 +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("--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, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
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 = 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]")
|
||||
|
||||
+13
-27
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ DEFAULT_TRANSPORT_THRESHOLD = 512
|
||||
# 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 = {
|
||||
|
||||
@@ -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"" if alt else f""
|
||||
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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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(
|
||||
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."""
|
||||
return structured(_client(client_factory, browser, remote, key).page.info())
|
||||
|
||||
@mcp.tool(name=tool_name("extract_text", prefix))
|
||||
def extract_text(
|
||||
browser: str | None = None,
|
||||
remote: str | None = None,
|
||||
key: str | None = None,
|
||||
) -> str:
|
||||
"""Extract plain text from the active page."""
|
||||
return _client(client_factory, browser, remote, key).extract.text()
|
||||
|
||||
@mcp.tool(name=tool_name("extract_markdown", prefix))
|
||||
def extract_markdown(
|
||||
selector: str | None = None,
|
||||
browser: str | None = None,
|
||||
remote: str | None = None,
|
||||
key: str | None = None,
|
||||
) -> str:
|
||||
"""Extract clean Markdown from the active page or an optional CSS selector."""
|
||||
return _client(client_factory, browser, remote, key).extract.markdown(selector)
|
||||
|
||||
@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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
|
||||
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.
|
||||
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
|
||||
|
||||
sock = _connect_socket(endpoint)
|
||||
try:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
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")))
|
||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
except BaseException:
|
||||
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,9 @@ class ServeControlMixin:
|
||||
|
||||
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:
|
||||
@@ -62,7 +65,6 @@ class ServeControlMixin:
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
return True
|
||||
from browser_cli.auth import add_authorized_key
|
||||
from browser_cli.serve.security import policy_from_categories
|
||||
args = msg.get("args") or {}
|
||||
pubkey = str(args.get("pubkey") or "")
|
||||
name = str(args.get("name") or "")
|
||||
@@ -71,18 +73,54 @@ class ServeControlMixin:
|
||||
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
|
||||
return True
|
||||
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", 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:
|
||||
if 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 True
|
||||
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 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", identity=self.auth_label)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.6",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['tab', 'dom'],
|
||||
operation: ['getHtml', 'get', 'reload', 'hardReload', 'back', 'forward', 'mute', 'unmute', 'pin', 'unpin', 'screenshot', 'eval'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
required: true,
|
||||
description: 'Target tab ID (required — no active-tab fallback)',
|
||||
displayOptions: { show: showFor('tab', ['activate', 'move', 'navigateTo']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Search',
|
||||
name: 'search',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'github',
|
||||
description: 'Substring to match against tab/group titles and URLs',
|
||||
displayOptions: { show: { resource: ['tab', 'group'], operation: ['query'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'URL Pattern',
|
||||
name: 'pattern',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'twitch.tv/* or twitch.tv',
|
||||
description: 'Matched against the full tab URL. A plain string is a case-sensitive substring match ("twitch.tv"); a pattern with "*" 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. Required for Filter; optional for Count (omit to count all).',
|
||||
displayOptions: { show: showFor('tab', ['filter', 'count']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Window ID',
|
||||
name: 'windowId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
required: true,
|
||||
displayOptions: { show: showFor('tab', ['activeInWindow']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Window ID',
|
||||
name: 'windowId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
required: true,
|
||||
displayOptions: { show: showFor('window', ['close', 'rename']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Window ID',
|
||||
name: 'windowId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Move the tab to this window. Leave 0 to keep it in the current window.',
|
||||
displayOptions: { show: showFor('tab', ['move']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Index',
|
||||
name: 'index',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target position within the window (0-based)',
|
||||
displayOptions: { show: showFor('tab', ['move']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Format',
|
||||
name: 'format',
|
||||
type: 'options',
|
||||
default: 'png',
|
||||
options: [
|
||||
{ name: 'PNG', value: 'png' },
|
||||
{ name: 'JPEG', value: 'jpeg' },
|
||||
],
|
||||
displayOptions: { show: showFor('tab', ['screenshot']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'quality',
|
||||
type: 'number',
|
||||
default: 80,
|
||||
description: 'JPEG quality 0-100 (ignored for PNG)',
|
||||
displayOptions: { show: { resource: ['tab'], operation: ['screenshot'], format: ['jpeg'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Gentle Mode',
|
||||
name: 'gentleMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'How aggressively to rearrange tabs',
|
||||
options: [
|
||||
{ name: 'Auto', value: 'auto' },
|
||||
{ name: 'On', value: 'on' },
|
||||
{ name: 'Off', value: 'off' },
|
||||
],
|
||||
displayOptions: { show: showFor('tab', ['dedupe', 'sort', 'mergeWindows']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Gentle Mode',
|
||||
name: 'gentleMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{ name: 'Auto', value: 'auto' },
|
||||
{ name: 'On', value: 'on' },
|
||||
{ name: 'Off', value: 'off' },
|
||||
],
|
||||
displayOptions: { show: showFor('group', ['close']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Sort By',
|
||||
name: 'by',
|
||||
type: 'options',
|
||||
default: 'domain',
|
||||
options: [
|
||||
{ name: 'Domain', value: 'domain' },
|
||||
{ name: 'Title', value: 'title' },
|
||||
{ name: 'Time', value: 'time' },
|
||||
],
|
||||
displayOptions: { show: showFor('tab', ['sort']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Selector',
|
||||
name: 'selector',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '#main, .content',
|
||||
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['dom', 'page'],
|
||||
operation: [
|
||||
'query', 'text', 'attr', 'exists', 'click', 'type', 'select', 'hover', 'focus',
|
||||
'check', 'uncheck', 'clear', 'submit', 'scroll', 'key',
|
||||
'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown', 'extractJson',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('dom', ['type']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Attribute',
|
||||
name: 'attr',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'href',
|
||||
description: 'Attribute name to read from the matched element',
|
||||
displayOptions: { show: showFor('dom', ['attr']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Option value to select in the dropdown',
|
||||
displayOptions: { show: showFor('dom', ['select']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Key',
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'Enter',
|
||||
description: 'Keyboard key to send, e.g. Enter, Escape, ArrowDown',
|
||||
displayOptions: { show: showFor('dom', ['key']) },
|
||||
},
|
||||
{
|
||||
displayName: 'X',
|
||||
name: 'x',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Horizontal scroll position (used when no selector is given)',
|
||||
displayOptions: { show: showFor('dom', ['scroll']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Y',
|
||||
name: 'y',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Vertical scroll position (used when no selector is given)',
|
||||
displayOptions: { show: showFor('dom', ['scroll']) },
|
||||
},
|
||||
{
|
||||
displayName: 'JavaScript',
|
||||
name: 'code',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'return document.title',
|
||||
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
|
||||
displayOptions: { show: showFor('dom', ['eval']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Group ID',
|
||||
name: 'groupId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
required: true,
|
||||
displayOptions: { show: showFor('group', ['tabs', 'close']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Group',
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'Research or 12',
|
||||
description: 'Target group by name or numeric ID',
|
||||
displayOptions: { show: showFor('group', ['addTab', 'move']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Direction',
|
||||
name: 'direction',
|
||||
type: 'options',
|
||||
default: 'forward',
|
||||
options: [
|
||||
{ name: 'Forward', value: 'forward' },
|
||||
{ name: 'Backward', value: 'backward' },
|
||||
],
|
||||
displayOptions: { show: showFor('group', ['move']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Name for the group/window/session. Required for all but Export (which dumps the active session when empty).',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['group', 'window', 'session'],
|
||||
operation: ['create', 'rename', 'save', 'load', 'remove', 'export'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Session A',
|
||||
name: 'nameA',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('session', ['diff']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Session B',
|
||||
name: 'nameB',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('session', ['diff']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Enabled',
|
||||
name: 'enabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to turn session auto-save on',
|
||||
displayOptions: { show: showFor('session', ['autoSave']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Storage Key',
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Storage key. Required for Set; on Get, omit to dump all keys.',
|
||||
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Storage Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('storage', ['set']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Storage Type',
|
||||
name: 'storeType',
|
||||
type: 'options',
|
||||
default: 'local',
|
||||
options: [
|
||||
{ name: 'localStorage', value: 'local' },
|
||||
{ name: 'sessionStorage', value: 'session' },
|
||||
],
|
||||
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Command',
|
||||
name: 'command',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'tabs.list',
|
||||
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Arguments',
|
||||
name: 'args',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
description: 'JSON object of command arguments',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
// Runs a real authenticated handshake so "Test" in the credential UI
|
||||
// reflects the actual serve protocol, not a stand-in HTTP request.
|
||||
async browserCliApiTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
|
||||
options.timeoutMs = 10_000;
|
||||
try {
|
||||
const response = await sendServeCommand(options, 'clients.list', {});
|
||||
if (response && response.success === false) {
|
||||
const message = String(response.error ?? 'serve returned an error');
|
||||
// A rejected key is a real credential failure; any other server-side
|
||||
// message means the handshake + auth already succeeded.
|
||||
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
|
||||
return { status: 'Error', message };
|
||||
}
|
||||
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
|
||||
}
|
||||
return { status: 'OK', message: 'Connected to browser-cli serve' };
|
||||
} catch (error) {
|
||||
return { status: 'Error', message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const connectOptions = connectOptionsFromCredentials(
|
||||
(await this.getCredentials('browserCliApi')) as IDataObject,
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', i) as string;
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
|
||||
const params = collectParams(this, resource, operation, i);
|
||||
const { command, args } = buildCommand(resource, operation, params);
|
||||
|
||||
const response = await sendServeCommand(connectOptions, command, args);
|
||||
if (response && response.success === false) {
|
||||
throw new Error(String(response.error || 'serve command failed'));
|
||||
}
|
||||
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
for (const row of rows) {
|
||||
returnData.push({
|
||||
json: isObject(row) ? (row as IDataObject) : { result: row },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Map decrypted credential fields to serve connection options. */
|
||||
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
|
||||
return {
|
||||
host: String(creds.host || '127.0.0.1'),
|
||||
port: Number(creds.port || 8765),
|
||||
tls: Boolean(creds.tls),
|
||||
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
||||
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
||||
route: creds.browser ? String(creds.browser) : null,
|
||||
serverIdentity: creds.serverIdentity ? String(creds.serverIdentity) : null,
|
||||
allowUnknownServerIdentity: Boolean(creds.allowUnknownServerIdentity),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the UI fields relevant to this operation into a plain params object. */
|
||||
function collectParams(
|
||||
ctx: IExecuteFunctions,
|
||||
resource: string,
|
||||
operation: string,
|
||||
i: number,
|
||||
): CommandParams {
|
||||
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
|
||||
const key = `${resource}:${operation}`;
|
||||
|
||||
switch (key) {
|
||||
case 'command:execute': {
|
||||
const raw = get('args', {});
|
||||
let args: Record<string, unknown> = {};
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
args = trimmed ? JSON.parse(trimmed) : {};
|
||||
} else if (isObject(raw)) {
|
||||
args = raw as Record<string, unknown>;
|
||||
}
|
||||
return { command: get('command'), args };
|
||||
}
|
||||
// --- Tabs -------------------------------------------------------------
|
||||
case 'tab:open':
|
||||
return { url: get('url'), focus: get('focus', false) };
|
||||
case 'tab:navigateTo':
|
||||
return { tabId: get('tabId', 0), url: get('url') };
|
||||
case 'tab:close':
|
||||
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
||||
case 'tab:query':
|
||||
return { search: get('search', '') };
|
||||
case 'tab:filter':
|
||||
case 'tab:count':
|
||||
return { pattern: get('pattern', '') };
|
||||
case 'tab:activeInWindow':
|
||||
return { windowId: get('windowId', 0) };
|
||||
case 'tab:activate':
|
||||
return { tabId: get('tabId', 0) };
|
||||
case 'tab:move':
|
||||
return { tabId: get('tabId', 0), windowId: get('windowId', 0), index: get('index', '') };
|
||||
case 'tab:get':
|
||||
case 'tab:getHtml':
|
||||
case 'tab:reload':
|
||||
case 'tab:hardReload':
|
||||
case 'tab:back':
|
||||
case 'tab:forward':
|
||||
case 'tab:mute':
|
||||
case 'tab:unmute':
|
||||
case 'tab:pin':
|
||||
case 'tab:unpin':
|
||||
return { tabId: get('tabId', 0) };
|
||||
case 'tab:dedupe':
|
||||
case 'tab:mergeWindows':
|
||||
return { gentleMode: get('gentleMode', 'auto') };
|
||||
case 'tab:sort':
|
||||
return { by: get('by', 'domain'), gentleMode: get('gentleMode', 'auto') };
|
||||
case 'tab:screenshot':
|
||||
return { tabId: get('tabId', 0), format: get('format', 'png'), quality: get('quality', '') };
|
||||
|
||||
// --- DOM --------------------------------------------------------------
|
||||
case 'dom:query':
|
||||
case 'dom:text':
|
||||
case 'dom:exists':
|
||||
case 'dom:click':
|
||||
case 'dom:hover':
|
||||
case 'dom:focus':
|
||||
case 'dom:check':
|
||||
case 'dom:uncheck':
|
||||
case 'dom:clear':
|
||||
case 'dom:submit':
|
||||
return { selector: get('selector', '') };
|
||||
case 'dom:type':
|
||||
return { selector: get('selector', ''), text: get('text', '') };
|
||||
case 'dom:attr':
|
||||
return { selector: get('selector', ''), attr: get('attr', '') };
|
||||
case 'dom:select':
|
||||
return { selector: get('selector', ''), value: get('value', '') };
|
||||
case 'dom:key':
|
||||
return { selector: get('selector', ''), key: get('key', '') };
|
||||
case 'dom:scroll':
|
||||
return { selector: get('selector', ''), x: get('x', ''), y: get('y', '') };
|
||||
case 'dom:eval':
|
||||
return { code: get('code', ''), tabId: get('tabId', 0) };
|
||||
|
||||
// --- Page / extraction ------------------------------------------------
|
||||
case 'page:extractText':
|
||||
case 'page:extractLinks':
|
||||
case 'page:extractImages':
|
||||
case 'page:extractHtml':
|
||||
case 'page:extractMarkdown':
|
||||
case 'page:extractJson':
|
||||
return { selector: get('selector', '') };
|
||||
|
||||
// --- Groups -----------------------------------------------------------
|
||||
case 'group:query':
|
||||
return { search: get('search', '') };
|
||||
case 'group:tabs':
|
||||
return { groupId: get('groupId', 0) };
|
||||
case 'group:close':
|
||||
return { groupId: get('groupId', 0), gentleMode: get('gentleMode', 'auto') };
|
||||
case 'group:create':
|
||||
return { name: get('name', '') };
|
||||
case 'group:addTab':
|
||||
return { group: get('group', ''), url: get('url', '') };
|
||||
case 'group:move':
|
||||
return { group: get('group', ''), direction: get('direction', 'forward') };
|
||||
|
||||
// --- Windows ----------------------------------------------------------
|
||||
case 'window:open':
|
||||
return { url: get('url', '') };
|
||||
case 'window:close':
|
||||
return { windowId: get('windowId', 0) };
|
||||
case 'window:rename':
|
||||
return { windowId: get('windowId', 0), name: get('name', '') };
|
||||
|
||||
// --- Sessions ---------------------------------------------------------
|
||||
case 'session:save':
|
||||
case 'session:load':
|
||||
case 'session:remove':
|
||||
case 'session:export':
|
||||
return { name: get('name', '') };
|
||||
case 'session:diff':
|
||||
return { nameA: get('nameA', ''), nameB: get('nameB', '') };
|
||||
case 'session:autoSave':
|
||||
return { enabled: get('enabled', true) };
|
||||
|
||||
// --- Storage ----------------------------------------------------------
|
||||
case 'storage:get':
|
||||
return { key: get('key', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
|
||||
case 'storage:set':
|
||||
return { key: get('key', ''), value: get('value', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||
<title>browser-cli icon</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#0f172a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f8fafc" />
|
||||
<stop offset="1" stop-color="#cbd5e1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
|
||||
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
|
||||
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
|
||||
|
||||
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
|
||||
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
|
||||
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
|
||||
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
|
||||
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
|
||||
|
||||
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
|
||||
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
|
||||
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
|
||||
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
|
||||
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Pure protocol + crypto for talking to a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* This module imports nothing from n8n and only touches Node's `crypto` plus a
|
||||
* lazily-loaded ML-KEM implementation, so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The socket mechanics live in `serveClient.ts`.
|
||||
*
|
||||
* The wire protocol mirrors the Python client in `browser_cli/remote` and
|
||||
* `browser_cli/auth`:
|
||||
*
|
||||
* 1. Server sends a framed `challenge` JSON: {nonce, min_client_version, pq_kex?}.
|
||||
* 2. Client replies with one framed message. With a private key this is an
|
||||
* Ed25519 signature over `nonce + sha256(canonical_json(msg))`, optionally
|
||||
* bound to an ML-KEM-768 shared secret. When the server offers `pq_kex`
|
||||
* (it always does once authorized_keys is set) the request body is also
|
||||
* ChaCha20-Poly1305 encrypted under that secret.
|
||||
* 3. Server replies with one framed payload, encrypted the same way.
|
||||
*
|
||||
* Every value the client signs must serialize byte-for-byte like Python's
|
||||
* `json.dumps(sort_keys=True, separators=(",", ":"))` with `ensure_ascii=True`
|
||||
* or the signature is rejected — see `canonicalJson` and `protocol.test.ts`.
|
||||
*/
|
||||
import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createHash,
|
||||
verify as nodeVerify,
|
||||
createHmac,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
sign as nodeSign,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
|
||||
export const PQ_KEX_ALG = 'ML-KEM-768';
|
||||
export const PQ_TRANSPORT_ALG = 'ML-KEM-768+ChaCha20Poly1305';
|
||||
|
||||
/** Auth-protocol fields that are never part of the signed canonical payload. */
|
||||
const AUTH_FIELDS = new Set(['pubkey', 'sig', 'pq_kex', 'encrypted']);
|
||||
|
||||
// --- Framing ---------------------------------------------------------------
|
||||
|
||||
/** Prefix `payload` with browser-cli's 4-byte little-endian length header. */
|
||||
export function frame(payload: Buffer): Buffer {
|
||||
const header = Buffer.allocUnsafe(4);
|
||||
header.writeUInt32LE(payload.length, 0);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// --- Canonical JSON (matches Python json.dumps sort_keys + ensure_ascii) ----
|
||||
|
||||
/** Deterministic JSON string identical to the Python signing canonicalization. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
function encode(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
const type = typeof value;
|
||||
if (type === 'string') return encodeString(value as string);
|
||||
if (type === 'boolean') return value ? 'true' : 'false';
|
||||
if (type === 'number') {
|
||||
const n = value as number;
|
||||
if (!Number.isFinite(n)) throw new Error('cannot encode non-finite number');
|
||||
// Integers match Python exactly; non-integers are rare in args and fall
|
||||
// back to JS formatting (documented limitation, see protocol.test.ts).
|
||||
return Number.isInteger(n) ? String(n) : JSON.stringify(n);
|
||||
}
|
||||
if (Array.isArray(value)) return '[' + value.map(encode).join(',') + ']';
|
||||
if (type === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj)
|
||||
.filter((key) => obj[key] !== undefined)
|
||||
.sort();
|
||||
return '{' + keys.map((key) => encodeString(key) + ':' + encode(obj[key])).join(',') + '}';
|
||||
}
|
||||
throw new Error(`cannot encode value of type ${type} in canonical JSON`);
|
||||
}
|
||||
|
||||
/** JSON-encode a string and escape every non-ASCII char as \uXXXX, like Python. */
|
||||
function encodeString(str: string): string {
|
||||
return JSON.stringify(str).replace(/[-]/g, (char) =>
|
||||
'\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ed25519 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruct a clean PEM from a value mangled by a credential field:
|
||||
* surrounding whitespace, newlines escaped to literal `\n`, or — common with
|
||||
* single-line/password inputs — the internal line breaks stripped entirely so
|
||||
* the base64 body and the BEGIN/END markers run together.
|
||||
*/
|
||||
export function normalizePem(input: string): string {
|
||||
let pem = (input || '').trim().replace(/\\n/g, '\n');
|
||||
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+?)-----([\s\S]*?)-----END \1-----/);
|
||||
if (match) {
|
||||
const label = match[1].trim();
|
||||
const body = (match[2].match(/[A-Za-z0-9+/=]+/g) || []).join('');
|
||||
const wrapped = body.match(/.{1,64}/g) || [];
|
||||
pem = `-----BEGIN ${label}-----\n${wrapped.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
return pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PKCS8 PEM Ed25519 private key, tolerating the ways credential fields
|
||||
* mangle multi-line secrets (see {@link normalizePem}).
|
||||
*/
|
||||
export function loadPrivateKey(privatePem: string): KeyObject {
|
||||
const pem = normalizePem(privatePem);
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Invalid Ed25519 private key: expected a PKCS8 PEM block ' +
|
||||
'("-----BEGIN PRIVATE KEY-----"), e.g. the file from `browser-cli auth keygen`. ' +
|
||||
`(${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key (hex) derived from a PKCS8 PEM private key. */
|
||||
export function ed25519PublicKeyHex(privatePem: string): string {
|
||||
const publicKey = createPublicKey(loadPrivateKey(privatePem));
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
|
||||
if (!jwk.x) throw new Error('private key is not an Ed25519 key');
|
||||
return Buffer.from(jwk.x, 'base64url').toString('hex');
|
||||
}
|
||||
|
||||
/** Bytes signed for auth: nonce + sha256(canonical) [+ sha256(label + secret)]. */
|
||||
export function authMessage(nonceHex: string, msg: Record<string, unknown>, pqSecret: Buffer | null): Buffer {
|
||||
const nonce = Buffer.from(nonceHex, 'hex');
|
||||
const canonical = createHash('sha256').update(canonicalJson(stripAuthFields(msg)), 'utf8').digest();
|
||||
let data = Buffer.concat([nonce, canonical]);
|
||||
if (pqSecret) {
|
||||
const bound = createHash('sha256')
|
||||
.update(Buffer.concat([Buffer.from('browser-cli ml-kem-768 v1', 'ascii'), pqSecret]))
|
||||
.digest();
|
||||
data = Buffer.concat([data, bound]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Ed25519 signature (hex) over the canonical auth payload. */
|
||||
export function signAuth(
|
||||
privatePem: string,
|
||||
nonceHex: string,
|
||||
msg: Record<string, unknown>,
|
||||
pqSecret: Buffer | null,
|
||||
): string {
|
||||
return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex');
|
||||
}
|
||||
|
||||
function stripAuthFields(msg: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(msg)) {
|
||||
if (!AUTH_FIELDS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- ML-KEM-768 transport encryption ---------------------------------------
|
||||
|
||||
// Node has no native ML-KEM, so load @noble/post-quantum at runtime. The
|
||||
// indirection through `Function` keeps a real dynamic `import()` even after
|
||||
// TypeScript downlevels this module to CommonJS (a plain `import()` would be
|
||||
// rewritten to `require()` and fail on the ESM-only package).
|
||||
const importEsm = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
|
||||
let mlkemPromise: Promise<any> | null = null;
|
||||
|
||||
async function mlKem768(): Promise<any> {
|
||||
if (!mlkemPromise) {
|
||||
mlkemPromise = importEsm('@noble/post-quantum/ml-kem.js').then((mod) => mod.ml_kem768);
|
||||
}
|
||||
return mlkemPromise;
|
||||
}
|
||||
|
||||
/** Encapsulate to the server's ML-KEM public key. Returns (ciphertext hex, secret). */
|
||||
export async function pqEncapsulate(serverPublicKeyHex: string): Promise<{ ciphertextHex: string; secret: Buffer }> {
|
||||
const ml = await mlKem768();
|
||||
const { cipherText, sharedSecret } = ml.encapsulate(Buffer.from(serverPublicKeyHex, 'hex'));
|
||||
return { ciphertextHex: Buffer.from(cipherText).toString('hex'), secret: Buffer.from(sharedSecret) };
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 with a 32-zero-byte salt (Python `salt=None`) and the given info. */
|
||||
export function pqTransportKey(secret: Buffer, direction: string): Buffer {
|
||||
const salt = Buffer.alloc(32, 0);
|
||||
const prk = createHmac('sha256', salt).update(secret).digest();
|
||||
const info = Buffer.concat([Buffer.from(`browser-cli pq transport v1 ${direction}`, 'ascii'), Buffer.from([1])]);
|
||||
return createHmac('sha256', prk).update(info).digest().subarray(0, 32);
|
||||
}
|
||||
|
||||
export interface PqEnvelope {
|
||||
alg?: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
/** ChaCha20-Poly1305 encrypt an app-layer frame (ciphertext field is ct||tag). */
|
||||
export function pqEncrypt(secret: Buffer, direction: string, plaintext: Buffer): PqEnvelope {
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
alg: PQ_TRANSPORT_ALG,
|
||||
nonce: nonce.toString('hex'),
|
||||
ciphertext: Buffer.concat([body, tag]).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Inverse of {@link pqEncrypt}. */
|
||||
export function pqDecrypt(secret: Buffer, direction: string, envelope: PqEnvelope): Buffer {
|
||||
if (!envelope || envelope.alg !== PQ_TRANSPORT_ALG) {
|
||||
throw new Error('unsupported encrypted transport envelope');
|
||||
}
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = Buffer.from(envelope.nonce, 'hex');
|
||||
const blob = Buffer.from(envelope.ciphertext, 'hex');
|
||||
const tag = blob.subarray(blob.length - 16);
|
||||
const body = blob.subarray(0, blob.length - 16);
|
||||
const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]);
|
||||
}
|
||||
|
||||
// --- Handshake payload + response decoding ---------------------------------
|
||||
|
||||
export interface Challenge {
|
||||
type?: string;
|
||||
nonce?: string;
|
||||
min_client_version?: string;
|
||||
pq_kex?: { alg?: string; public_key?: string };
|
||||
server_pubkey?: string;
|
||||
server_sig?: string;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
payload: Record<string, unknown>;
|
||||
pqSecret: Buffer | null;
|
||||
}
|
||||
|
||||
function pqPublicKey(challenge: Challenge): string | null {
|
||||
const kex = challenge.pq_kex;
|
||||
if (kex && kex.alg === PQ_KEX_ALG && kex.public_key) return String(kex.public_key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function ed25519PublicKeyFromHex(pubkeyHex: string): KeyObject {
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(pubkeyHex)) throw new Error('server public key must be 32-byte hex');
|
||||
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: base64Url(Buffer.from(pubkeyHex, 'hex')) }, format: 'jwk' });
|
||||
}
|
||||
|
||||
function signedChallenge(challenge: Challenge): Record<string, unknown> {
|
||||
const { server_sig: _serverSig, ...rest } = challenge;
|
||||
return rest as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function serverFingerprint(pubkeyHex: string): string {
|
||||
return 'SHA256:' + createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('base64').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function verifyServerChallengeSignature(challenge: Challenge): boolean {
|
||||
const pubkey = challenge.server_pubkey;
|
||||
const sig = challenge.server_sig;
|
||||
if (!pubkey || !sig) return false;
|
||||
try {
|
||||
return nodeVerify(
|
||||
null,
|
||||
Buffer.from(canonicalJson(signedChallenge(challenge)), 'utf8'),
|
||||
ed25519PublicKeyFromHex(pubkey),
|
||||
Buffer.from(sig, 'hex'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyServerIdentity(
|
||||
challenge: Challenge,
|
||||
expectedServerIdentity: string | null | undefined,
|
||||
endpoint: string,
|
||||
allowUnknown: boolean,
|
||||
): void {
|
||||
const pubkey = challenge.server_pubkey;
|
||||
const expected = (expectedServerIdentity || '').trim();
|
||||
|
||||
if (!pubkey) {
|
||||
if (expected) throw new Error(`server ${endpoint} did not advertise a server identity key`);
|
||||
return;
|
||||
}
|
||||
if (!verifyServerChallengeSignature(challenge)) {
|
||||
throw new Error(`server ${endpoint} identity signature is invalid`);
|
||||
}
|
||||
|
||||
const seenFingerprint = serverFingerprint(pubkey);
|
||||
if (!expected) {
|
||||
if (allowUnknown) return;
|
||||
throw new Error(
|
||||
`Unknown browser-cli server identity for ${endpoint} (${seenFingerprint}). ` +
|
||||
'Set the expected Server Public Key/Fingerprint in the Browser CLI credential.',
|
||||
);
|
||||
}
|
||||
|
||||
if (expected.startsWith('SHA256:')) {
|
||||
if (expected !== seenFingerprint) {
|
||||
throw new Error(`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${expected}, seen ${seenFingerprint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedExpected = expected.toLowerCase();
|
||||
if (normalizedExpected !== pubkey.toLowerCase()) {
|
||||
throw new Error(
|
||||
`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${serverFingerprint(normalizedExpected)}, seen ${seenFingerprint}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single framed message a client sends in response to the challenge.
|
||||
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
|
||||
*/
|
||||
export async function buildAuthPayload(
|
||||
baseMsg: Record<string, unknown>,
|
||||
challenge: Challenge,
|
||||
privatePem: string | null,
|
||||
): Promise<AuthPayload> {
|
||||
const nonceHex = challenge.type === 'challenge' ? challenge.nonce : undefined;
|
||||
if (!nonceHex || !privatePem) {
|
||||
// No-auth endpoint (loopback `serve --no-auth`): send the bare message.
|
||||
return { payload: baseMsg, pqSecret: null };
|
||||
}
|
||||
|
||||
const clean = stripAuthFields(baseMsg);
|
||||
let secret: Buffer | null = null;
|
||||
const serverPub = pqPublicKey(challenge);
|
||||
if (serverPub) {
|
||||
const enc = await pqEncapsulate(serverPub);
|
||||
secret = enc.secret;
|
||||
clean.pq_kex = { alg: PQ_KEX_ALG, ciphertext: enc.ciphertextHex };
|
||||
}
|
||||
|
||||
const sig = signAuth(privatePem, nonceHex, clean, secret);
|
||||
const pubkey = ed25519PublicKeyHex(privatePem);
|
||||
|
||||
if (!secret) {
|
||||
return { payload: { ...clean, pubkey, sig }, pqSecret: null };
|
||||
}
|
||||
|
||||
const encrypted = pqEncrypt(secret, 'request', Buffer.from(JSON.stringify(clean), 'utf8'));
|
||||
return {
|
||||
payload: {
|
||||
id: clean.id,
|
||||
user_agent: clean.user_agent,
|
||||
pubkey,
|
||||
sig,
|
||||
pq_kex: clean.pq_kex,
|
||||
encrypted,
|
||||
},
|
||||
pqSecret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode a framed server response, decrypting the PQ envelope when present. */
|
||||
export function decodeResponse(raw: Buffer, pqSecret: Buffer | null): any {
|
||||
const outer = JSON.parse(raw.toString('utf8'));
|
||||
if (pqSecret && outer && typeof outer === 'object' && 'encrypted' in outer) {
|
||||
return JSON.parse(pqDecrypt(pqSecret, 'response', outer.encrypted).toString('utf8'));
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Pure (resource, operation) -> browser-cli command mapping.
|
||||
*
|
||||
* This module imports nothing from n8n so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The node layer collects UI parameters into a plain
|
||||
* object and asks here for the command + args to run over the `serve` socket
|
||||
* (see `serveClient.ts`). Every operation maps to one raw extension command;
|
||||
* what the server returns is the *raw* command result (no SDK-side rendering),
|
||||
* still subject to the server's --allow-* policy noted per operation.
|
||||
*
|
||||
* Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*)
|
||||
* and the server-side policy in browser_cli/command_security.py. Gating per
|
||||
* operation is documented in BrowserCli.node.ts next to each operation.
|
||||
*/
|
||||
|
||||
export type CommandParams = Record<string, unknown>;
|
||||
|
||||
export interface BrowserCommand {
|
||||
/** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */
|
||||
command: string;
|
||||
/** Argument object forwarded to the command. */
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function str(params: CommandParams, key: string): string {
|
||||
const value = params[key];
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** Drop keys whose value is undefined/null/"" so we don't send empty args. */
|
||||
function compact(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value !== undefined && value !== null && value !== '') out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a (resource, operation) pair plus collected parameters to a single raw
|
||||
* browser-cli command. Throws on an unknown pairing so the node fails loudly
|
||||
* rather than silently issuing a wrong call.
|
||||
*/
|
||||
export function buildCommand(
|
||||
resource: string,
|
||||
operation: string,
|
||||
params: CommandParams,
|
||||
): BrowserCommand {
|
||||
const key = `${resource}:${operation}`;
|
||||
switch (key) {
|
||||
// --- Raw escape hatch -------------------------------------------------
|
||||
case 'command:execute':
|
||||
return { command: str(params, 'command'), args: (params.args as Record<string, unknown>) ?? {} };
|
||||
|
||||
// --- Tabs -------------------------------------------------------------
|
||||
case 'tab:list':
|
||||
return { command: 'tabs.list', args: {} };
|
||||
case 'tab:query':
|
||||
return { command: 'tabs.query', args: { search: str(params, 'search') } };
|
||||
case 'tab:get':
|
||||
return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:count':
|
||||
return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) };
|
||||
case 'tab:filter':
|
||||
return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } };
|
||||
case 'tab:activeInWindow':
|
||||
return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } };
|
||||
case 'tab:open': {
|
||||
const focus = Boolean(params.focus);
|
||||
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
|
||||
}
|
||||
case 'tab:close': {
|
||||
const mode = str(params, 'mode') || 'ids';
|
||||
if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } };
|
||||
if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } };
|
||||
return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } };
|
||||
}
|
||||
case 'tab:getHtml':
|
||||
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:activate':
|
||||
return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } };
|
||||
case 'tab:move':
|
||||
return {
|
||||
command: 'tabs.move',
|
||||
args: compact({
|
||||
tabId: numArg(params.tabId),
|
||||
windowId: tabIdArg(params.windowId),
|
||||
index: indexArg(params.index),
|
||||
}),
|
||||
};
|
||||
case 'tab:reload':
|
||||
return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:hardReload':
|
||||
return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:back':
|
||||
return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:forward':
|
||||
return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:navigateTo':
|
||||
return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } };
|
||||
case 'tab:mute':
|
||||
return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:unmute':
|
||||
return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:pin':
|
||||
return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:unpin':
|
||||
return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
case 'tab:dedupe':
|
||||
return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
|
||||
case 'tab:sort':
|
||||
return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } };
|
||||
case 'tab:mergeWindows':
|
||||
return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
|
||||
case 'tab:screenshot':
|
||||
return {
|
||||
command: 'tabs.screenshot',
|
||||
args: compact({
|
||||
tabId: tabIdArg(params.tabId),
|
||||
format: str(params, 'format') || 'png',
|
||||
quality: indexArg(params.quality),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Page / extraction ------------------------------------------------
|
||||
case 'page:info':
|
||||
return { command: 'page.info', args: {} };
|
||||
case 'page:extractText':
|
||||
return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractLinks':
|
||||
return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractImages':
|
||||
return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractHtml':
|
||||
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractMarkdown':
|
||||
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractJson':
|
||||
return { command: 'extract.json', args: { selector: str(params, 'selector') } };
|
||||
|
||||
// --- DOM --------------------------------------------------------------
|
||||
case 'dom:query':
|
||||
return { command: 'dom.query', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:click':
|
||||
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:type':
|
||||
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
|
||||
case 'dom:attr':
|
||||
return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } };
|
||||
case 'dom:text':
|
||||
return { command: 'dom.text', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:exists':
|
||||
return { command: 'dom.exists', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:scroll':
|
||||
return {
|
||||
command: 'dom.scroll',
|
||||
args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }),
|
||||
};
|
||||
case 'dom:select':
|
||||
return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } };
|
||||
case 'dom:hover':
|
||||
return { command: 'dom.hover', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:check':
|
||||
return { command: 'dom.check', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:uncheck':
|
||||
return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:clear':
|
||||
return { command: 'dom.clear', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:focus':
|
||||
return { command: 'dom.focus', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:submit':
|
||||
return { command: 'dom.submit', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:key':
|
||||
return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) };
|
||||
case 'dom:eval':
|
||||
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
|
||||
|
||||
// --- Groups -----------------------------------------------------------
|
||||
case 'group:list':
|
||||
return { command: 'group.list', args: {} };
|
||||
case 'group:query':
|
||||
return { command: 'group.query', args: { search: str(params, 'search') } };
|
||||
case 'group:tabs':
|
||||
return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } };
|
||||
case 'group:count':
|
||||
return { command: 'group.count', args: {} };
|
||||
case 'group:create':
|
||||
return { command: 'group.open', args: { name: str(params, 'name') } };
|
||||
case 'group:addTab':
|
||||
return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) };
|
||||
case 'group:move': {
|
||||
const direction = str(params, 'direction');
|
||||
return {
|
||||
command: 'group.move',
|
||||
args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' },
|
||||
};
|
||||
}
|
||||
case 'group:close':
|
||||
return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } };
|
||||
|
||||
// --- Windows ----------------------------------------------------------
|
||||
case 'window:list':
|
||||
return { command: 'windows.list', args: {} };
|
||||
case 'window:open':
|
||||
return { command: 'windows.open', args: compact({ url: str(params, 'url') }) };
|
||||
case 'window:close':
|
||||
return { command: 'windows.close', args: { windowId: numArg(params.windowId) } };
|
||||
case 'window:rename':
|
||||
return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } };
|
||||
|
||||
// --- Sessions ---------------------------------------------------------
|
||||
case 'session:list':
|
||||
return { command: 'session.list', args: {} };
|
||||
case 'session:save':
|
||||
return { command: 'session.save', args: { name: str(params, 'name') } };
|
||||
case 'session:load':
|
||||
return { command: 'session.load', args: { name: str(params, 'name') } };
|
||||
case 'session:remove':
|
||||
return { command: 'session.remove', args: { name: str(params, 'name') } };
|
||||
case 'session:export':
|
||||
return { command: 'session.export', args: compact({ name: str(params, 'name') }) };
|
||||
case 'session:diff':
|
||||
return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } };
|
||||
case 'session:autoSave':
|
||||
return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } };
|
||||
|
||||
// --- Storage ----------------------------------------------------------
|
||||
case 'storage:get':
|
||||
return {
|
||||
command: 'storage.get',
|
||||
args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }),
|
||||
};
|
||||
case 'storage:set':
|
||||
return {
|
||||
command: 'storage.set',
|
||||
args: compact({
|
||||
key: str(params, 'key'),
|
||||
value: str(params, 'value'),
|
||||
type: str(params, 'storeType') || 'local',
|
||||
tabId: tabIdArg(params.tabId),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Performance ------------------------------------------------------
|
||||
case 'perf:status':
|
||||
return { command: 'perf.status', args: {} };
|
||||
|
||||
// --- Extension --------------------------------------------------------
|
||||
case 'extension:info':
|
||||
return { command: 'extension.info', args: {} };
|
||||
case 'extension:capabilities':
|
||||
return { command: 'extension.capabilities', args: {} };
|
||||
case 'extension:reload':
|
||||
return { command: 'extension.reload', args: {} };
|
||||
|
||||
// --- Clients ----------------------------------------------------------
|
||||
case 'client:list':
|
||||
return { command: 'clients.list', args: {} };
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */
|
||||
function tabIdArg(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** A required numeric arg; non-finite values fall through as 0. */
|
||||
function numArg(value: unknown): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */
|
||||
function indexArg(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Accept an array, a JSON array string, or a comma/space separated list. */
|
||||
export function parseTabIds(value: unknown): number[] {
|
||||
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
|
||||
if (typeof value === 'number') return [value];
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return [];
|
||||
if (raw.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite);
|
||||
} catch {
|
||||
/* fall through to split */
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part))
|
||||
.filter(Number.isFinite);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Socket client for a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* One command per connection: connect, read the challenge frame, send the
|
||||
* authenticated (and PQ-encrypted) request frame, read the response frame,
|
||||
* close. The crypto and payload shapes live in `protocol.ts`.
|
||||
*/
|
||||
import { connect as netConnect, isIP } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import type { Socket } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { buildAuthPayload, decodeResponse, frame, verifyServerIdentity, type Challenge } from './protocol';
|
||||
|
||||
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
|
||||
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
||||
* client implements. */
|
||||
const CLIENT_VERSION = '0.16.0';
|
||||
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
||||
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
||||
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
||||
const MAX_MSG_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface ServeConnectOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
|
||||
tls?: boolean;
|
||||
/** Reject self-signed / invalid certs when `tls` is on. */
|
||||
rejectUnauthorized?: boolean;
|
||||
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
|
||||
privateKeyPem?: string | null;
|
||||
/** Optional `_route` target for a multi-browser serve. */
|
||||
route?: string | null;
|
||||
/** Expected server identity: raw Ed25519 public key hex or SHA256 fingerprint. */
|
||||
serverIdentity?: string | null;
|
||||
/** Allow unknown server identities (TOFU disabled). Intended for loopback/dev only. */
|
||||
allowUnknownServerIdentity?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
|
||||
class FrameReader {
|
||||
private buffer = Buffer.alloc(0);
|
||||
private waiters: Array<(frame: Buffer) => void> = [];
|
||||
private failed: Error | null = null;
|
||||
|
||||
constructor(socket: Socket) {
|
||||
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer): void {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length >= 4) {
|
||||
const length = this.buffer.readUInt32LE(0);
|
||||
if (length > MAX_MSG_BYTES) {
|
||||
this.fail(new Error(`serve frame too large (${length} bytes)`));
|
||||
return;
|
||||
}
|
||||
if (this.buffer.length < 4 + length) break;
|
||||
const payload = this.buffer.subarray(4, 4 + length);
|
||||
this.buffer = this.buffer.subarray(4 + length);
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) waiter(Buffer.from(payload));
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.failed = error;
|
||||
}
|
||||
|
||||
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
|
||||
next(): Promise<Buffer> {
|
||||
if (this.failed) return Promise.reject(this.failed);
|
||||
return new Promise((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket(opts: ServeConnectOptions): Socket {
|
||||
if (opts.tls) {
|
||||
return tlsConnect({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
rejectUnauthorized: opts.rejectUnauthorized !== false,
|
||||
// SNI must be a hostname; Node rejects an IP literal as servername.
|
||||
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
||||
});
|
||||
}
|
||||
return netConnect({ host: opts.host, port: opts.port });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single browser-cli command against a `serve` endpoint and return the
|
||||
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
|
||||
*/
|
||||
export async function sendServeCommand(
|
||||
opts: ServeConnectOptions,
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<any> {
|
||||
const socket = openSocket(opts);
|
||||
socket.setNoDelay(true);
|
||||
const reader = new FrameReader(socket);
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
socket.on('error', (err) => finish(() => reject(err)));
|
||||
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
|
||||
|
||||
const run = async () => {
|
||||
const challengeRaw = await reader.next();
|
||||
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
||||
verifyServerIdentity(
|
||||
challenge,
|
||||
opts.serverIdentity,
|
||||
`${opts.host}:${opts.port}`,
|
||||
Boolean(opts.allowUnknownServerIdentity),
|
||||
);
|
||||
|
||||
const baseMsg: Record<string, unknown> = {
|
||||
id: randomUUID(),
|
||||
command,
|
||||
args: args ?? {},
|
||||
user_agent: USER_AGENT,
|
||||
accept_encoding: ACCEPT_ENCODING,
|
||||
};
|
||||
if (opts.route) baseMsg._route = opts.route;
|
||||
|
||||
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
|
||||
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
|
||||
|
||||
const responseRaw = await reader.next();
|
||||
const response = decodeResponse(responseRaw, pqSecret);
|
||||
finish(() => resolve(response));
|
||||
};
|
||||
|
||||
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
|
||||
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
|
||||
run().catch((err) => finish(() => reject(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Generated
+1825
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "n8n-nodes-browser-cli",
|
||||
"version": "0.3.1",
|
||||
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
|
||||
"keywords": [
|
||||
"n8n-community-node-package",
|
||||
"browser-cli",
|
||||
"browser",
|
||||
"automation",
|
||||
"n8n"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp",
|
||||
"author": "Daniel Dolezal",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-assets.mjs",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"dist/credentials/BrowserCliApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/BrowserCli/BrowserCli.node.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"n8n-workflow": "*",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/post-quantum": "^0.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copy node icons next to their compiled .node.js files. n8n loads the icon
|
||||
// from a path relative to the node file in dist/, so the SVG must travel along.
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const assets = [
|
||||
['nodes/BrowserCli/browserCli.svg', 'dist/nodes/BrowserCli/browserCli.svg'],
|
||||
];
|
||||
|
||||
for (const [from, to] of assets) {
|
||||
await mkdir(dirname(join(root, to)), { recursive: true });
|
||||
await cp(join(root, from), join(root, to));
|
||||
console.log(`copied ${from} -> ${to}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Build the pure-logic tests to ESM and run them with node --test. request.ts
|
||||
// and protocol.ts import nothing from n8n, so this needs only esbuild + node.
|
||||
// node_modules stay external (--packages=external) so the @noble/post-quantum
|
||||
// dynamic import in protocol.ts resolves at runtime instead of being bundled.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const run = (cmd, args) => spawnSync(cmd, args, { cwd: root, stdio: 'inherit' });
|
||||
|
||||
const suites = ['request', 'protocol'];
|
||||
|
||||
const build = run('npx', [
|
||||
'esbuild',
|
||||
...suites.map((name) => `test/${name}.test.ts`),
|
||||
'--bundle',
|
||||
'--format=esm',
|
||||
'--platform=node',
|
||||
'--packages=external',
|
||||
'--outdir=test-dist',
|
||||
'--out-extension:.js=.mjs',
|
||||
]);
|
||||
if (build.status !== 0) process.exit(build.status ?? 1);
|
||||
|
||||
const test = run('node', ['--test', ...suites.map((name) => `test-dist/${name}.test.mjs`)]);
|
||||
process.exit(test.status ?? 1);
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
authMessage,
|
||||
buildAuthPayload,
|
||||
canonicalJson,
|
||||
decodeResponse,
|
||||
ed25519PublicKeyHex,
|
||||
frame,
|
||||
pqDecrypt,
|
||||
pqEncrypt,
|
||||
pqTransportKey,
|
||||
serverFingerprint,
|
||||
signAuth,
|
||||
verifyServerChallengeSignature,
|
||||
verifyServerIdentity,
|
||||
} from '../nodes/BrowserCli/protocol';
|
||||
|
||||
// Known-answer vectors produced by the real Python implementation
|
||||
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
|
||||
// Regenerate with the snippet in the PR description if the protocol changes.
|
||||
const PEM =
|
||||
'-----BEGIN PRIVATE KEY-----\n' +
|
||||
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
|
||||
'-----END PRIVATE KEY-----\n';
|
||||
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
|
||||
const MSG = {
|
||||
id: 'abc-123',
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'héllo ☃ "x"' },
|
||||
user_agent: 'browser-cli/0.15.4',
|
||||
accept_encoding: { ser: ['json'], comp: [] },
|
||||
};
|
||||
const CANON =
|
||||
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
|
||||
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
|
||||
'"user_agent":"browser-cli/0.15.4"}';
|
||||
const NONCE_HEX = '11'.repeat(32);
|
||||
const SIG_NOPQ =
|
||||
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
|
||||
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
|
||||
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
|
||||
const SIG_PQ =
|
||||
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
|
||||
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
|
||||
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
|
||||
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
|
||||
const DECRYPT_ENV = {
|
||||
alg: 'ML-KEM-768+ChaCha20Poly1305',
|
||||
nonce: 'a77e8a72e13f49aab713e0dc',
|
||||
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
||||
};
|
||||
const DECRYPT_PLAIN = 'hello world payload';
|
||||
const SERVER_PUB_HEX = '982c13bda72ef7b2bf4a8cd9756e4f283faaf4a34f9dec8e6c65585b64d9a902';
|
||||
const SERVER_SIG =
|
||||
'fa6897bb7f00f711ee8af151384eda736a503008f8b8e11b972cfea46a0366d5' +
|
||||
'3127c2f1e9ba01e72805b267d023c552756645ae7d95fd00fa277ed20a43ee09';
|
||||
const SERVER_FP = 'SHA256:wsYDqD4OnF/Sfvr3RKvVCOW8ET802H2qHSvWfnQwQrs';
|
||||
const SERVER_CHALLENGE = {
|
||||
type: 'challenge',
|
||||
nonce: NONCE_HEX,
|
||||
server_version: '0.16.4',
|
||||
min_client_version: '0.9.0',
|
||||
server_pubkey: SERVER_PUB_HEX,
|
||||
server_sig: SERVER_SIG,
|
||||
};
|
||||
|
||||
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
|
||||
assert.equal(canonicalJson(MSG), CANON);
|
||||
});
|
||||
|
||||
test('canonicalJson sorts nested keys and omits undefined', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
|
||||
// What a single-line/password credential field does to a multi-line PEM.
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
|
||||
});
|
||||
|
||||
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
|
||||
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature without PQ', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
|
||||
});
|
||||
|
||||
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
|
||||
});
|
||||
|
||||
test('pqTransportKey matches Python HKDF for both directions', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
|
||||
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
|
||||
});
|
||||
|
||||
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
|
||||
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
|
||||
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
|
||||
});
|
||||
|
||||
test('pqEncrypt/pqDecrypt round-trip', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
|
||||
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
|
||||
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
|
||||
});
|
||||
|
||||
test('pqDecrypt rejects an unknown envelope', () => {
|
||||
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
|
||||
});
|
||||
|
||||
test('frame prepends a 4-byte little-endian length', () => {
|
||||
const out = frame(Buffer.from('abc'));
|
||||
assert.equal(out.readUInt32LE(0), 3);
|
||||
assert.equal(out.subarray(4).toString(), 'abc');
|
||||
});
|
||||
|
||||
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
|
||||
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal((payload as any).pubkey, PUB_HEX);
|
||||
assert.equal((payload as any).sig, SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
|
||||
const base = { ...MSG };
|
||||
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal(payload, base);
|
||||
});
|
||||
|
||||
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
|
||||
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
|
||||
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
|
||||
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
|
||||
});
|
||||
|
||||
test('server identity fingerprint and signature match Python challenge signing', () => {
|
||||
assert.equal(serverFingerprint(SERVER_PUB_HEX), SERVER_FP);
|
||||
assert.equal(verifyServerChallengeSignature(SERVER_CHALLENGE), true);
|
||||
assert.equal(verifyServerChallengeSignature({ ...SERVER_CHALLENGE, nonce: '22'.repeat(32) }), false);
|
||||
});
|
||||
|
||||
test('verifyServerIdentity accepts pinned pubkey or fingerprint', () => {
|
||||
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_PUB_HEX, 'browser-host.example:8765', false));
|
||||
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_FP, 'browser-host.example:8765', false));
|
||||
});
|
||||
|
||||
test('verifyServerIdentity rejects unknown and changed identities', () => {
|
||||
assert.throws(
|
||||
() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', false),
|
||||
/Unknown browser-cli server identity/,
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyServerIdentity(SERVER_CHALLENGE, '00'.repeat(32), 'browser-host.example:8765', false),
|
||||
/REMOTE SERVER IDENTITY CHANGED/,
|
||||
);
|
||||
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', true));
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { buildCommand, parseTabIds } from '../nodes/BrowserCli/request';
|
||||
|
||||
test('tab:list maps to tabs.list', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'list', {}), { command: 'tabs.list', args: {} });
|
||||
});
|
||||
|
||||
test('client:list maps to clients.list', () => {
|
||||
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
|
||||
});
|
||||
|
||||
test('tab:open sends navigate.open with background derived from focus', () => {
|
||||
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
|
||||
assert.deepEqual(bg, {
|
||||
command: 'navigate.open',
|
||||
args: { url: 'https://example.com', focus: false, background: true },
|
||||
});
|
||||
|
||||
const fg = buildCommand('tab', 'open', { url: 'https://example.com', focus: true });
|
||||
assert.equal(fg.args.focus, true);
|
||||
assert.equal(fg.args.background, false, 'focused open sends background:false (matches SDK)');
|
||||
});
|
||||
|
||||
test('tab:close supports ids, inactive, duplicates', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'ids', tabIds: '12, 34' }), {
|
||||
command: 'tabs.close',
|
||||
args: { tabIds: [12, 34] },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'inactive' }).args, { inactive: true });
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'duplicates' }).args, { duplicates: true });
|
||||
});
|
||||
|
||||
test('dom:type sends dom.type with selector + text', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'type', { selector: '#q', text: 'hello' }), {
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
test('dom:eval includes tabId only when set', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 0 }).args, { code: 'return 1' });
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 7 }).args, { code: 'return 1', tabId: 7 });
|
||||
});
|
||||
|
||||
test('page:extractMarkdown omits empty selector', () => {
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: '' }).args, {});
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: 'main' }).args, { selector: 'main' });
|
||||
});
|
||||
|
||||
test('command:execute passes command and args through', () => {
|
||||
assert.deepEqual(buildCommand('command', 'execute', { command: 'tabs.query', args: { search: 'docs' } }), {
|
||||
command: 'tabs.query',
|
||||
args: { search: 'docs' },
|
||||
});
|
||||
});
|
||||
|
||||
test('tab read ops map to safe commands', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'query', { search: 'docs' }), { command: 'tabs.query', args: { search: 'docs' } });
|
||||
assert.deepEqual(buildCommand('tab', 'filter', { pattern: '*.dev/*' }), { command: 'tabs.filter', args: { pattern: '*.dev/*' } });
|
||||
assert.deepEqual(buildCommand('tab', 'count', { pattern: '' }).args, {}, 'empty pattern is dropped');
|
||||
assert.deepEqual(buildCommand('tab', 'get', { tabId: 0 }).args, {}, 'tabId 0 means active tab');
|
||||
assert.deepEqual(buildCommand('tab', 'get', { tabId: 5 }), { command: 'tabs.status', args: { tabId: 5 } });
|
||||
assert.deepEqual(buildCommand('tab', 'activeInWindow', { windowId: 3 }), {
|
||||
command: 'tabs.active_in_window',
|
||||
args: { windowId: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
test('tab control ops map to navigate/tabs commands', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'activate', { tabId: 7 }), { command: 'tabs.active', args: { tabId: 7 } });
|
||||
assert.deepEqual(buildCommand('tab', 'navigateTo', { tabId: 7, url: 'https://x.dev' }), {
|
||||
command: 'navigate.to',
|
||||
args: { tabId: 7, url: 'https://x.dev' },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'reload', { tabId: 0 }), { command: 'navigate.reload', args: {} });
|
||||
assert.deepEqual(buildCommand('tab', 'back', { tabId: 0 }).command, 'navigate.back');
|
||||
assert.deepEqual(buildCommand('tab', 'mute', { tabId: 2 }), { command: 'tabs.mute', args: { tabId: 2 } });
|
||||
assert.deepEqual(buildCommand('tab', 'pin', { tabId: 0 }), { command: 'tabs.pin', args: {} });
|
||||
});
|
||||
|
||||
test('tab move keeps index 0 but drops active tabId fallback for window', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 0, index: 0 }), {
|
||||
command: 'tabs.move',
|
||||
args: { tabId: 4, index: 0 },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 9, index: '' }).args, { tabId: 4, windowId: 9 });
|
||||
});
|
||||
|
||||
test('tab rearrange ops default gentleMode and sort key', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'dedupe', {}), { command: 'tabs.dedupe', args: { gentleMode: 'auto' } });
|
||||
assert.deepEqual(buildCommand('tab', 'sort', { by: 'title' }), {
|
||||
command: 'tabs.sort',
|
||||
args: { by: 'title', gentleMode: 'auto' },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'mergeWindows', {}).command, 'tabs.merge_windows');
|
||||
});
|
||||
|
||||
test('tab screenshot includes quality only when set', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 0, format: 'png', quality: '' }).args, { format: 'png' });
|
||||
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 1, format: 'jpeg', quality: 80 }).args, {
|
||||
tabId: 1,
|
||||
format: 'jpeg',
|
||||
quality: 80,
|
||||
});
|
||||
});
|
||||
|
||||
test('dom interaction ops map to dom.* commands', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'attr', { selector: 'a', attr: 'href' }), {
|
||||
command: 'dom.attr',
|
||||
args: { selector: 'a', attr: 'href' },
|
||||
});
|
||||
assert.deepEqual(buildCommand('dom', 'select', { selector: '#s', value: 'v' }), {
|
||||
command: 'dom.select',
|
||||
args: { selector: '#s', value: 'v' },
|
||||
});
|
||||
assert.deepEqual(buildCommand('dom', 'key', { key: 'Enter', selector: '' }).args, { key: 'Enter' });
|
||||
assert.deepEqual(buildCommand('dom', 'scroll', { selector: '', x: '', y: 500 }).args, { y: 500 });
|
||||
assert.deepEqual(buildCommand('dom', 'exists', { selector: '#x' }), { command: 'dom.exists', args: { selector: '#x' } });
|
||||
});
|
||||
|
||||
test('page extractJson sends selector', () => {
|
||||
assert.deepEqual(buildCommand('page', 'extractJson', { selector: 'script' }), {
|
||||
command: 'extract.json',
|
||||
args: { selector: 'script' },
|
||||
});
|
||||
});
|
||||
|
||||
test('group ops map to group.* commands', () => {
|
||||
assert.deepEqual(buildCommand('group', 'create', { name: 'Research' }), { command: 'group.open', args: { name: 'Research' } });
|
||||
assert.deepEqual(buildCommand('group', 'tabs', { groupId: 3 }), { command: 'group.tabs', args: { groupId: 3 } });
|
||||
assert.deepEqual(buildCommand('group', 'addTab', { group: 'Research', url: '' }).args, { group: 'Research' });
|
||||
assert.deepEqual(buildCommand('group', 'move', { group: '5', direction: 'backward' }), {
|
||||
command: 'group.move',
|
||||
args: { group: '5', forward: false, backward: true },
|
||||
});
|
||||
assert.deepEqual(buildCommand('group', 'close', { groupId: 2, gentleMode: 'off' }).args, { groupId: 2, gentleMode: 'off' });
|
||||
});
|
||||
|
||||
test('window ops map to windows.* commands', () => {
|
||||
assert.deepEqual(buildCommand('window', 'open', { url: '' }), { command: 'windows.open', args: {} });
|
||||
assert.deepEqual(buildCommand('window', 'rename', { windowId: 1, name: 'Work' }), {
|
||||
command: 'windows.rename',
|
||||
args: { windowId: 1, name: 'Work' },
|
||||
});
|
||||
});
|
||||
|
||||
test('session ops map to session.* commands', () => {
|
||||
assert.deepEqual(buildCommand('session', 'save', { name: 'morning' }), { command: 'session.save', args: { name: 'morning' } });
|
||||
assert.deepEqual(buildCommand('session', 'export', { name: '' }), { command: 'session.export', args: {} });
|
||||
assert.deepEqual(buildCommand('session', 'diff', { nameA: 'a', nameB: 'b' }).args, { nameA: 'a', nameB: 'b' });
|
||||
assert.deepEqual(buildCommand('session', 'autoSave', { enabled: false }), {
|
||||
command: 'session.auto_save',
|
||||
args: { enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
test('storage ops default to local and drop active tabId', () => {
|
||||
assert.deepEqual(buildCommand('storage', 'get', { key: 'token', storeType: 'local', tabId: 0 }), {
|
||||
command: 'storage.get',
|
||||
args: { key: 'token', type: 'local' },
|
||||
});
|
||||
assert.deepEqual(buildCommand('storage', 'set', { key: 'k', value: 'v', storeType: 'session', tabId: 4 }), {
|
||||
command: 'storage.set',
|
||||
args: { key: 'k', value: 'v', type: 'session', tabId: 4 },
|
||||
});
|
||||
assert.deepEqual(buildCommand('storage', 'get', { key: '', storeType: 'local', tabId: 0 }).args, { type: 'local' });
|
||||
});
|
||||
|
||||
test('perf and extension ops map to safe/control commands', () => {
|
||||
assert.deepEqual(buildCommand('perf', 'status', {}), { command: 'perf.status', args: {} });
|
||||
assert.deepEqual(buildCommand('extension', 'capabilities', {}), { command: 'extension.capabilities', args: {} });
|
||||
assert.deepEqual(buildCommand('extension', 'reload', {}), { command: 'extension.reload', args: {} });
|
||||
});
|
||||
|
||||
test('unknown operation throws', () => {
|
||||
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
|
||||
assert.throws(() => buildCommand('connection', 'health', {}), /Unsupported operation/);
|
||||
assert.throws(() => buildCommand('gateway', 'health', {}), /Unsupported operation/);
|
||||
});
|
||||
|
||||
test('parseTabIds accepts array, json, and delimited strings', () => {
|
||||
assert.deepEqual(parseTabIds([1, 2, 3]), [1, 2, 3]);
|
||||
assert.deepEqual(parseTabIds('[4, 5]'), [4, 5]);
|
||||
assert.deepEqual(parseTabIds('6, 7 8'), [6, 7, 8]);
|
||||
assert.deepEqual(parseTabIds(''), []);
|
||||
assert.deepEqual(parseTabIds(9), [9]);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["credentials/**/*.ts", "nodes/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
Generated
+107
-107
@@ -13,9 +13,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -30,9 +30,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -64,9 +64,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -81,9 +81,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -98,9 +98,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -115,9 +115,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -132,9 +132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -149,9 +149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -166,9 +166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -183,9 +183,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -200,9 +200,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -217,9 +217,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -234,9 +234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -251,9 +251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -268,9 +268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -285,9 +285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -302,9 +302,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -319,9 +319,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -336,9 +336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -353,9 +353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -370,9 +370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -387,9 +387,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -404,9 +404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -421,9 +421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -438,9 +438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -497,9 +497,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -510,32 +510,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "real-browser-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.16.6"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"cryptography>=48",
|
||||
"rich>=13",
|
||||
"msgpack>=1",
|
||||
"questionary>=2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -21,9 +22,13 @@ Issues = "https://git.yiprawr.dev/Automatisation/browser-cli/issues"
|
||||
[project.optional-dependencies]
|
||||
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
|
||||
fast = ["zstandard>=0.22"]
|
||||
mcp = [
|
||||
"mcp>=2,<3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
browser-cli = "browser_cli.cli:main"
|
||||
browser-cli-mcp = "browser_cli.mcp.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -12,6 +12,18 @@ from browser_cli.remote import pool as _remote_pool
|
||||
|
||||
TEST_BROWSER_PROFILE = "testing"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_remote_registry(monkeypatch, tmp_path):
|
||||
"""Point the remembered-remote registry at an empty throwaway file.
|
||||
|
||||
Endpoint resolution consults remembered remotes, so without this a developer
|
||||
who has remembered ``host:8765`` sees different results than CI for the same
|
||||
code. Tests that need remembered entries still monkeypatch the path themselves.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "empty-remotes.json"
|
||||
)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_remote_pool():
|
||||
"""Close any pooled remote connections between tests so a connection opened
|
||||
|
||||
+34
-11
@@ -294,29 +294,52 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
assert "0.8.2" in result.output
|
||||
|
||||
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile is None
|
||||
assert remote == "127.0.0.1:8765"
|
||||
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
|
||||
target = BrowserTarget(
|
||||
profile="work",
|
||||
display_name="127.0.0.1:work",
|
||||
socket_path="",
|
||||
remote="127.0.0.1:8765",
|
||||
browser_name="Chrome",
|
||||
display_group="127.0.0.1",
|
||||
version="1",
|
||||
extension_version="2.3.4",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
|
||||
), patch("browser_cli.client.core.remote_browser_targets", return_value=[target]), patch(
|
||||
"browser_cli.client.core.send_command"
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once()
|
||||
assert "remote" in result.output
|
||||
send_command.assert_not_called()
|
||||
assert "work" in result.output
|
||||
assert "127.0.0.1" not in result.output
|
||||
assert "Chrome" in result.output
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
def test_clients_remote_respects_global_browser_route():
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.client.core.send_command", return_value=[]) as send_command:
|
||||
target = BrowserTarget(
|
||||
profile="work",
|
||||
display_name="127.0.0.1:work",
|
||||
socket_path="",
|
||||
remote="127.0.0.1:8765",
|
||||
browser_name="Chrome",
|
||||
display_group="127.0.0.1",
|
||||
version="1",
|
||||
extension_version="2.3.4",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.client.core.remote_browser_targets", return_value=[target]
|
||||
), patch("browser_cli.client.core.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_not_called()
|
||||
assert "work" in result.output
|
||||
assert "127.0.0.1" not in result.output
|
||||
|
||||
def test_clients_browser_alias_resolves_to_remote():
|
||||
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
|
||||
|
||||
+70
-23
@@ -356,7 +356,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].display_group == "browser-host.example"
|
||||
|
||||
def test_looks_like_domain():
|
||||
assert _looks_like_domain("browsercli.yiprawr.dev") is True
|
||||
assert _looks_like_domain("browser-host.example") is True
|
||||
assert _looks_like_domain("browser-host.example") is True
|
||||
assert _looks_like_domain("sub.domain.org") is True
|
||||
assert _looks_like_domain("localhost") is False
|
||||
@@ -365,17 +365,17 @@ def test_looks_like_domain():
|
||||
assert _looks_like_domain("host") is False # no dot
|
||||
|
||||
def test_normalize_endpoint_strips_443_for_domains():
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("browser-host.example:443") == "browser-host.example"
|
||||
assert _normalize_endpoint("browser-host.example") == "browser-host.example"
|
||||
assert _normalize_endpoint("203.0.113.1:443") == "203.0.113.1:443" # IP: keep port
|
||||
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
|
||||
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
assert _normalize_endpoint("browser-host.example:8765") == "browser-host.example:8765"
|
||||
|
||||
def test_resolve_connect_endpoint_adds_443_for_domain():
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
assert _resolve_connect_endpoint("browser-host.example") == "browser-host.example:443"
|
||||
assert _resolve_connect_endpoint("browser-host.example:443") == "browser-host.example:443"
|
||||
assert _resolve_connect_endpoint("browser-host.example:8765") == "browser-host.example:8765"
|
||||
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
||||
|
||||
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
|
||||
@@ -399,9 +399,9 @@ def test_send_command_normalizes_domain_port_443(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443")
|
||||
result = send_command("tabs.list", remote="browser-host.example:443")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port
|
||||
assert sent_to["endpoint"] == "browser-host.example" # stored/routed without port
|
||||
|
||||
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
"""--remote domain (no port) is treated as :443."""
|
||||
@@ -420,14 +420,14 @@ def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev")
|
||||
result = send_command("tabs.list", remote="browser-host.example")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev"
|
||||
assert sent_to["endpoint"] == "browser-host.example"
|
||||
|
||||
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev"
|
||||
endpoint = "browser-host.example"
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
@@ -440,13 +440,13 @@ def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
assert targets[0].display_name == "browser-host.example:automatisation"
|
||||
assert targets[0].remote == endpoint
|
||||
|
||||
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
||||
"""Old remotes.json with :443 still displays cleanly without the port."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev:443" # old format
|
||||
endpoint = "browser-host.example:443" # old format
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
@@ -459,10 +459,10 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
assert targets[0].display_name == "browser-host.example:automatisation"
|
||||
|
||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
||||
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
||||
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
|
||||
"""--key is a one-shot override; use `browser-cli remote trust` to remember it."""
|
||||
import json as _json
|
||||
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
@@ -491,16 +491,12 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
||||
|
||||
# First call with explicit --key agent
|
||||
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
||||
assert used_keys[-1] == "agent"
|
||||
assert key_for_remote("host:8765") is None
|
||||
|
||||
# Key must be persisted now
|
||||
assert key_for_remote("host:8765") == "agent"
|
||||
|
||||
# Second call without --key — should reuse saved "agent"
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
assert used_keys[-1] == "agent"
|
||||
assert used_keys[-1] is None
|
||||
|
||||
# ── async command transport ──────────────────────────────────────────────────
|
||||
|
||||
@@ -653,6 +649,57 @@ def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_pat
|
||||
"extensionVersion": "0.15.6",
|
||||
}]
|
||||
|
||||
def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypatch, tmp_path):
|
||||
"""`browser-cli --remote host clients` should list all profiles, not auto-route and fail as ambiguous."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
targets = [
|
||||
BrowserTarget(
|
||||
profile="main",
|
||||
display_name="browser-host.example:main",
|
||||
socket_path="",
|
||||
remote="browser-host.example:8765",
|
||||
browser_name="Chrome",
|
||||
display_group="browser-host.example",
|
||||
version="149.0.0.0",
|
||||
extension_version="0.16.4",
|
||||
),
|
||||
BrowserTarget(
|
||||
profile="work",
|
||||
display_name="browser-host.example:work",
|
||||
socket_path="",
|
||||
remote="browser-host.example:8765",
|
||||
browser_name="Firefox",
|
||||
display_group="browser-host.example",
|
||||
version="151.0",
|
||||
extension_version="0.16.4",
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
||||
monkeypatch.setattr(core, "send_command", lambda *a, **k: pytest.fail("clients.list must not auto-route for cached targets"))
|
||||
|
||||
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert [row["profile"] for row in rows] == ["main", "work"]
|
||||
assert [row.get("profileGroup") for row in rows] == [None, None]
|
||||
assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
|
||||
|
||||
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", version="1"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", version="1"),
|
||||
]
|
||||
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
||||
|
||||
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert [row["profile"] for row in rows] == ["work"]
|
||||
assert rows[0].get("profileGroup") is None
|
||||
|
||||
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
|
||||
"""An older remote (no advertised version) still triggers a clients.list query."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Compat shim framework.
|
||||
|
||||
The registries are empty today (no legacy-client shim has been needed since the
|
||||
first public release, 0.14.1), so every adapter must be a verbatim pass-through
|
||||
regardless of client version. These tests lock that in and exercise the
|
||||
empty-registry short-circuit so the seam can't silently start mutating traffic.
|
||||
"""
|
||||
import browser_cli.compat as compat
|
||||
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
|
||||
|
||||
def test_registries_are_empty():
|
||||
assert compat.commands._COMPAT == []
|
||||
assert compat.auth._AUTH_COMPAT == []
|
||||
|
||||
def test_adapt_auth_is_passthrough_for_any_version():
|
||||
msg = {"id": "1", "command": "tabs.list", "pubkey": "ABCdef", "args": {"x": 1}}
|
||||
for version in ("0.9.0", "0.14.1", "0.16.4", "99.0.0"):
|
||||
out = adapt_auth(msg, version)
|
||||
assert out == msg
|
||||
# pubkey casing is NOT normalized anymore (the old <0.9.3 shim is gone)
|
||||
assert out["pubkey"] == "ABCdef"
|
||||
|
||||
def test_adapt_request_is_passthrough():
|
||||
msg = {"command": "tabs.query", "args": {"search": "docs"}}
|
||||
assert adapt_request(msg, "0.9.0") == msg
|
||||
assert adapt_request(msg, "0.16.4") == msg
|
||||
|
||||
def test_adapt_response_is_passthrough():
|
||||
resp = b'{"id":"1","success":true,"data":[]}'
|
||||
assert adapt_response(resp, "tabs.list", "0.9.0") == resp
|
||||
assert adapt_response(resp, "tabs.list", "0.16.4") == resp
|
||||
|
||||
def test_empty_guard_skips_version_parsing(monkeypatch):
|
||||
"""With empty registries the adapters return before parse_version runs."""
|
||||
called = False
|
||||
|
||||
def _boom(_v):
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("parse_version should not be called on an empty registry")
|
||||
|
||||
monkeypatch.setattr(compat.auth, "parse_version", _boom)
|
||||
monkeypatch.setattr(compat.commands, "parse_version", _boom)
|
||||
|
||||
assert adapt_auth({"a": 1}, "0.9.0") == {"a": 1}
|
||||
assert adapt_request({"a": 1}, "0.9.0") == {"a": 1}
|
||||
assert adapt_response(b"x", "cmd", "0.9.0") == b"x"
|
||||
assert called is False
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge, verify_challenge_signature
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.remote import known_hosts
|
||||
|
||||
def _challenge(tmp_path):
|
||||
key = load_or_create_server_identity(tmp_path / "server.pem")
|
||||
msg = {
|
||||
"type": "challenge",
|
||||
"nonce": "00" * 32,
|
||||
"server_version": "0.16.4",
|
||||
"min_client_version": "0.9.0",
|
||||
"server_pubkey": public_key_hex(key),
|
||||
}
|
||||
msg["server_sig"] = sign_challenge(msg, key)
|
||||
return msg
|
||||
|
||||
def test_challenge_signature_verifies(tmp_path):
|
||||
challenge = _challenge(tmp_path)
|
||||
|
||||
assert verify_challenge_signature(challenge) is True
|
||||
|
||||
challenge["nonce"] = "11" * 32
|
||||
assert verify_challenge_signature(challenge) is False
|
||||
|
||||
def test_known_host_mismatch_is_rejected(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"browser-host.example": "00" * 32}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="REMOTE SERVER IDENTITY CHANGED"):
|
||||
known_hosts.verify_known_host("browser-host.example", challenge)
|
||||
|
||||
def test_unknown_non_interactive_host_is_rejected(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Unknown remote server identity"):
|
||||
known_hosts.verify_known_host("browser-host.example", challenge)
|
||||
|
||||
def test_loopback_unknown_host_is_allowed(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
known_hosts.verify_known_host("127.0.0.1:8765", challenge)
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
def test_save_and_remove_known_host(tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
known_hosts.save_known_host("browser-host.example:443", "11" * 32, path)
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": "11" * 32}
|
||||
assert known_hosts.remove_known_host("browser-host.example", path) is True
|
||||
assert known_hosts.load_known_hosts(path) == {}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Security/robustness tests for the HTML→Markdown converter on hostile page content."""
|
||||
from browser_cli.markdown.html import _MAX_TREE_DEPTH, convert_html_to_markdown
|
||||
from browser_cli.markdown.render import render_markdown
|
||||
|
||||
def _identity(markdown):
|
||||
return markdown
|
||||
|
||||
# ── depth-bounded recursion (Finding 4: HIGH/DoS) ─────────────────────────────────
|
||||
|
||||
def test_deeply_nested_html_does_not_crash():
|
||||
"""Thousands of nested elements must not raise RecursionError."""
|
||||
depth = 5000
|
||||
html = "<div>" * depth + "deep content" + "</div>" * depth
|
||||
out = convert_html_to_markdown(html, _identity)
|
||||
assert "deep content" in out # text preserved despite flattening
|
||||
|
||||
def test_deeply_nested_via_render_markdown_entrypoint():
|
||||
html = "<div>" * 3000 + "x" + "</div>" * 3000
|
||||
out = render_markdown(html) # routes HTML through the converter
|
||||
assert "x" in out
|
||||
|
||||
def test_nesting_within_cap_is_preserved_structurally():
|
||||
# A modest list nesting (well under the cap) still renders as a list.
|
||||
html = "<ul><li>a<ul><li>b</li></ul></li></ul>"
|
||||
out = convert_html_to_markdown(html, _identity)
|
||||
assert "- a" in out
|
||||
assert "b" in out
|
||||
|
||||
def test_max_tree_depth_is_sane():
|
||||
# Cap must be high enough for real documents, low enough to stay under the
|
||||
# interpreter recursion limit with a few frames per level.
|
||||
assert 50 <= _MAX_TREE_DEPTH <= 400
|
||||
|
||||
# ── unsafe URL schemes (Finding 5: LOW) ───────────────────────────────────────────
|
||||
|
||||
def test_javascript_url_in_link_is_neutralised():
|
||||
# Anchors render their href only in inline context (inside a block like <p>).
|
||||
out = convert_html_to_markdown('<p><a href="javascript:alert(1)">click</a></p>', _identity)
|
||||
assert "javascript:" not in out
|
||||
assert "click" in out # link text kept, dangerous href dropped
|
||||
|
||||
def test_data_and_vbscript_urls_dropped():
|
||||
assert "vbscript:" not in convert_html_to_markdown('<p><a href="vbscript:x">y</a></p>', _identity)
|
||||
assert "data:" not in convert_html_to_markdown('<img src="data:text/html,<script>">', _identity)
|
||||
|
||||
def test_normal_urls_pass_through():
|
||||
out = convert_html_to_markdown('<p><a href="https://example.com">site</a></p>', _identity)
|
||||
assert "(https://example.com)" in out
|
||||
img = convert_html_to_markdown('<img src="https://example.com/x.png" alt="pic">', _identity)
|
||||
assert "(https://example.com/x.png)" in img
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for the optional stateless MCP adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp")
|
||||
|
||||
from mcp import Client
|
||||
from mcp.types import ImageContent
|
||||
|
||||
from browser_cli.mcp.serialization import structured
|
||||
from browser_cli.mcp.naming import resolve_tool_prefix
|
||||
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
|
||||
from browser_cli.models import Tab
|
||||
|
||||
class FakeClient:
|
||||
instances: list["FakeClient"] = []
|
||||
|
||||
def __init__(self, browser=None, remote=None, key=None):
|
||||
self.target = (browser, remote, key)
|
||||
self.calls: list[tuple] = []
|
||||
self.tabs = SimpleNamespace(
|
||||
list=self.tabs_list,
|
||||
open=self.tabs_open,
|
||||
close=self.tabs_close,
|
||||
active=self.tabs_active,
|
||||
status=self.tabs_status,
|
||||
screenshot=self.tabs_screenshot,
|
||||
)
|
||||
self.nav = SimpleNamespace(to=self.navigate_to)
|
||||
self.page = SimpleNamespace(info=self.page_info)
|
||||
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
|
||||
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type)
|
||||
self.instances.append(self)
|
||||
|
||||
def tabs_list(self):
|
||||
return [{"id": 7, "title": "Example", "url": "https://example.com"}]
|
||||
|
||||
def tabs_open(self, url, **kwargs):
|
||||
self.calls.append(("open", url, kwargs))
|
||||
return {"id": 8, "title": "Opened", "url": url}
|
||||
|
||||
def tabs_close(self, tab_id):
|
||||
self.calls.append(("close", tab_id))
|
||||
return 1
|
||||
|
||||
def tabs_active(self):
|
||||
self.calls.append(("active",))
|
||||
return SimpleNamespace(id=7)
|
||||
|
||||
def tabs_status(self, tab_id):
|
||||
self.calls.append(("status", tab_id))
|
||||
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
|
||||
|
||||
def tabs_screenshot(self, tab_id, **kwargs):
|
||||
self.calls.append(("screenshot", tab_id, kwargs))
|
||||
return "data:image/png;base64," + base64.b64encode(b"png-data").decode()
|
||||
|
||||
def navigate_to(self, tab_id, url):
|
||||
self.calls.append(("navigate", tab_id, url))
|
||||
|
||||
def page_info(self):
|
||||
return {"title": "Example", "url": "https://example.com"}
|
||||
|
||||
def extract_text(self):
|
||||
return "Page text"
|
||||
|
||||
def extract_markdown(self, selector=None):
|
||||
return f"# Page {selector or ''}".rstrip()
|
||||
|
||||
def dom_query(self, selector):
|
||||
return [{"tag": "button", "selector": selector}]
|
||||
|
||||
def dom_click(self, selector):
|
||||
self.calls.append(("click", selector))
|
||||
|
||||
def dom_type(self, selector, text):
|
||||
self.calls.append(("type", selector, text))
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_instances():
|
||||
FakeClient.instances.clear()
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
server = create_server(client_factory=FakeClient)
|
||||
async with Client(server, raise_exceptions=True) as connected:
|
||||
yield connected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
first = await client.call_tool("browser_tabs_list", {
|
||||
"browser": "work",
|
||||
"remote": "browser-host.example:443",
|
||||
"key": "agent",
|
||||
})
|
||||
second = await client.call_tool("browser_page_info", {})
|
||||
|
||||
assert first.structured_content == {
|
||||
"result": [{"id": 7, "title": "Example", "url": "https://example.com"}]
|
||||
}
|
||||
assert second.structured_content == {
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
assert len(FakeClient.instances) == 2
|
||||
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
|
||||
assert FakeClient.instances[1].target == (None, None, None)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
|
||||
await client.call_tool("browser_tabs_list", {})
|
||||
|
||||
assert FakeClient.instances[-1].target == ("testing", None, None)
|
||||
|
||||
def test_tool_prefix_defaults_to_browser_and_is_configurable():
|
||||
assert resolve_tool_prefix({}) == "browser_"
|
||||
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
|
||||
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web"}) == "web_"
|
||||
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web_"}) == "web_"
|
||||
with pytest.raises(ValueError):
|
||||
resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "9-bad prefix"})
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hosts_that_namespace_tools_can_drop_the_builtin_prefix():
|
||||
server = create_server(client_factory=FakeClient, tool_prefix="")
|
||||
async with Client(server, raise_exceptions=True) as connected:
|
||||
names = {tool.name for tool in (await connected.list_tools()).tools}
|
||||
|
||||
assert "tabs_list" in names
|
||||
assert not any(name.startswith("browser_") for name in names)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
|
||||
await client.call_tool("browser_tabs_list", {"browser": "main"})
|
||||
|
||||
assert FakeClient.instances[-1].target == ("main", None, None)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
|
||||
opened = await client.call_tool("browser_tabs_open", {
|
||||
"url": "https://example.com",
|
||||
"wait": True,
|
||||
"focus": True,
|
||||
})
|
||||
navigated = await client.call_tool("browser_navigate", {
|
||||
"tab_id": 8,
|
||||
"url": "https://example.com/next",
|
||||
})
|
||||
clicked = await client.call_tool("browser_dom_click", {"selector": "#submit"})
|
||||
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
|
||||
|
||||
assert opened.structured_content == {
|
||||
"id": 8, "title": "Opened", "url": "https://example.com"
|
||||
}
|
||||
assert navigated.structured_content["id"] == 8
|
||||
assert clicked.structured_content["url"] == "https://example.com"
|
||||
assert typed.structured_content == {"typed": True}
|
||||
assert FakeClient.instances[0].calls == [
|
||||
("open", "https://example.com", {
|
||||
"wait": True, "timeout": 30.0, "background": False, "focus": True
|
||||
})
|
||||
]
|
||||
assert FakeClient.instances[1].calls == [
|
||||
("navigate", 8, "https://example.com/next"), ("status", 8)
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tab_tools_default_to_the_active_tab(client):
|
||||
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
|
||||
closed = await client.call_tool("browser_tabs_close", {})
|
||||
|
||||
assert navigated.structured_content["id"] == 7
|
||||
assert closed.structured_content == {"closed": 1, "tab_id": 7}
|
||||
assert FakeClient.instances[0].calls == [
|
||||
("active",), ("navigate", 7, "https://example.com/next"), ("status", 7)
|
||||
]
|
||||
assert FakeClient.instances[1].calls == [("active",), ("close", 7)]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_screenshot_returns_image_content(client):
|
||||
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
|
||||
|
||||
assert result.structured_content is None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], ImageContent)
|
||||
assert result.content[0].data == base64.b64encode(b"png-data").decode()
|
||||
assert result.content[0].mime_type == "image/png"
|
||||
|
||||
def test_screenshot_decoder_rejects_non_data_url():
|
||||
with pytest.raises(ValueError, match="invalid screenshot"):
|
||||
_screenshot_bytes("not-an-image")
|
||||
|
||||
def test_sdk_dataclass_serialization_does_not_traverse_bound_client():
|
||||
tab = Tab(id=7, window_id=1, active=True, title="Example")
|
||||
tab._browser = SimpleNamespace(secret="must not be serialized")
|
||||
|
||||
result = structured(tab)
|
||||
|
||||
assert result["id"] == 7
|
||||
assert result["window_id"] == 1
|
||||
assert "_browser" not in result
|
||||
|
||||
def test_http_server_refuses_non_local_bind(monkeypatch):
|
||||
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: None))
|
||||
|
||||
with pytest.raises(SystemExit, match="Refusing to expose"):
|
||||
main(["--transport", "streamable-http", "--host", "0.0.0.0"])
|
||||
|
||||
def test_http_server_enables_stateless_json_transport(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: calls.append(kwargs)))
|
||||
|
||||
main(["--transport", "streamable-http", "--port", "9000", "--path", "/browser"])
|
||||
|
||||
assert calls == [{
|
||||
"transport": "streamable-http",
|
||||
"host": "127.0.0.1",
|
||||
"port": 9000,
|
||||
"streamable_http_path": "/browser",
|
||||
"stateless_http": True,
|
||||
"json_response": True,
|
||||
}]
|
||||
@@ -237,9 +237,77 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
|
||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
assert "Description" in result.output
|
||||
assert "read-page" in result.output
|
||||
assert "all" in result.output
|
||||
assert "server default" in result.output
|
||||
assert "read page content" in result.output
|
||||
assert "Full access" in result.output
|
||||
|
||||
def test_auth_policy_updates_existing_key_policy(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
keys.write_text(f"{pub} laptop allow:all\n")
|
||||
|
||||
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
|
||||
assert safe_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop allow:"
|
||||
|
||||
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
|
||||
assert default_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop"
|
||||
|
||||
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose a policy mode" in result.output
|
||||
|
||||
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose exactly one policy mode" in result.output
|
||||
|
||||
def test_parse_interactive_policy_selection():
|
||||
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
|
||||
|
||||
assert _parse_policy_selection("1,2") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("all") == ["all"]
|
||||
assert _parse_policy_selection("safe") == []
|
||||
assert _parse_policy_selection("default") is None
|
||||
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
|
||||
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
|
||||
assert _parse_checkbox_policy_selection(["__safe__"]) == []
|
||||
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
|
||||
|
||||
def test_auth_policy_without_identifier_requires_interactive_picker():
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Missing key identifier" in result.output
|
||||
|
||||
def test_auth_policy_remote_sends_policy_command():
|
||||
pub = "c" * 64
|
||||
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
|
||||
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
|
||||
assert result.exit_code == 0
|
||||
send.assert_called_once()
|
||||
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_keys_remote_unreachable_clean_error():
|
||||
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
||||
@@ -289,6 +357,48 @@ def test_serve_http_uses_compare_digest():
|
||||
assert "compare_digest" in src
|
||||
assert "== f\"Bearer" not in src
|
||||
|
||||
def test_serve_http_rate_limiter_blocks_when_exhausted():
|
||||
"""A burst-1 limiter lets the first request through, then sends 429."""
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
from browser_cli.serve.security import RateLimiter
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.client_address = ("203.0.113.5", 5000)
|
||||
handler.rate_limiter = RateLimiter(rate=0.001, burst=1)
|
||||
sent = []
|
||||
handler._send = lambda status, payload: sent.append((status, payload))
|
||||
|
||||
assert handler._within_rate_limit() is True
|
||||
assert handler._within_rate_limit() is False
|
||||
assert sent and sent[-1][0] == 429
|
||||
|
||||
def test_serve_http_rate_limiter_none_never_limits():
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.client_address = ("203.0.113.5", 5000)
|
||||
handler.rate_limiter = None
|
||||
assert all(handler._within_rate_limit() for _ in range(100))
|
||||
|
||||
def test_serve_http_default_rate_limit_active():
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
|
||||
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
|
||||
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
|
||||
CliRunner().invoke(main, ["serve-http", "--no-auth"])
|
||||
# The handler class passed to the server carries an active RateLimiter by default.
|
||||
handler_cls = server_cls.call_args.args[1]
|
||||
assert handler_cls.rate_limiter is not None
|
||||
assert handler_cls.rate_limiter.rate == 100.0
|
||||
|
||||
def test_serve_http_non_loopback_warns_about_cleartext():
|
||||
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
|
||||
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
|
||||
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
|
||||
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--token", "x"])
|
||||
assert "clear text" in result.output
|
||||
|
||||
def test_command_policy_allow_all_grants_everything():
|
||||
policy = command_policy_from_options(
|
||||
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from browser_cli.commands.remote import remote_group
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
def test_save_remote_persists_endpoint_without_key(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
|
||||
remote_registry.save_remote("browser-host.example:443")
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": {}}
|
||||
|
||||
def test_save_remote_with_key_and_remove(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
|
||||
remote_registry.save_remote("browser-host.example", "agent")
|
||||
|
||||
assert remote_registry.load_remotes() == {"browser-host.example": {"key": "agent"}}
|
||||
assert remote_registry.remove_remote("browser-host.example:443") is True
|
||||
assert remote_registry.load_remotes() == {}
|
||||
|
||||
def test_resolve_remote_endpoint_prefers_remembered_explicit_port(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
path.write_text(json.dumps({"browser-host.example": {}, "browser-host.example:8765": {}}), encoding="utf-8")
|
||||
|
||||
assert remote_registry.resolve_remote_endpoint("browser-host.example") == "browser-host.example:8765"
|
||||
|
||||
def test_resolve_remote_endpoint_keeps_bare_domain_without_unique_port_match(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
path.write_text(
|
||||
json.dumps({"browser-host.example:8765": {}, "browser-host.example:9000": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert remote_registry.resolve_remote_endpoint("browser-host.example") == "browser-host.example"
|
||||
|
||||
def test_remote_add_list_remove_cli(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
runner = CliRunner()
|
||||
|
||||
add_result = runner.invoke(remote_group, ["add", "browser-host.example", "--key", "agent"])
|
||||
list_result = runner.invoke(remote_group, ["list"])
|
||||
remove_result = runner.invoke(remote_group, ["remove", "browser-host.example"])
|
||||
|
||||
assert add_result.exit_code == 0
|
||||
assert "Added remote browser-host.example with key agent" in add_result.output
|
||||
assert list_result.exit_code == 0
|
||||
assert "browser-host.example" in list_result.output
|
||||
assert "agent" in list_result.output
|
||||
assert remove_result.exit_code == 0
|
||||
assert "Removed browser-host.example" in remove_result.output
|
||||
assert remote_registry.load_remotes() == {}
|
||||
+42
-27
@@ -228,33 +228,6 @@ class TestAuthSuccess:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
||||
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair() # pub is lowercase hex
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
# old client sends uppercase pubkey
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
@@ -462,6 +435,48 @@ class TestPerKeyPolicy:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def _trust_and_query_keys(self, tmp_path, monkeypatch, server_policy):
|
||||
"""Authenticate, then send browser-cli.auth.keys; return the response dict."""
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + " mykey\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path, ServeSecurity(policy=server_policy))
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "browser-cli.auth.keys", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
return resp
|
||||
|
||||
def test_key_management_blocked_without_keys_grant(self, tmp_path, monkeypatch):
|
||||
"""Even full control+dangerous can't list keys — the control command is gated."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
resp = self._trust_and_query_keys(
|
||||
tmp_path, monkeypatch,
|
||||
CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True),
|
||||
)
|
||||
assert resp["success"] is False
|
||||
assert "blocked" in resp["error"].lower() and "keys" in resp["error"].lower()
|
||||
|
||||
def test_key_management_allowed_with_keys_grant(self, tmp_path, monkeypatch):
|
||||
"""With allow_keys, the control command runs and returns the trusted-key list."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
resp = self._trust_and_query_keys(tmp_path, monkeypatch, CommandPolicy(allow_keys=True))
|
||||
assert resp["success"] is True
|
||||
assert resp["data"][0]["name"] == "mykey"
|
||||
|
||||
class TestRateLimit:
|
||||
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
|
||||
"""A burst-1 limiter shared across connections allows the first command, denies the next."""
|
||||
|
||||
@@ -6,6 +6,7 @@ from browser_cli.auth.keys import (
|
||||
format_authorized_line,
|
||||
load_authorized_keys_with_names,
|
||||
load_authorized_keys_with_policies,
|
||||
set_authorized_key_policy,
|
||||
)
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.serve.security import (
|
||||
@@ -42,10 +43,11 @@ def test_key_commands_are_keys_category():
|
||||
from browser_cli.command_security import command_category
|
||||
assert command_category("browser-cli.auth.keys") == "keys"
|
||||
assert command_category("browser-cli.auth.trust") == "keys"
|
||||
assert command_category("browser-cli.auth.policy") == "keys"
|
||||
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
||||
|
||||
def test_key_commands_blocked_without_allow_keys():
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"):
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
||||
@@ -57,6 +59,50 @@ def test_full_control_still_cannot_manage_keys():
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("browser-cli.auth.trust", policy)
|
||||
|
||||
# ── set_authorized_key_policy ────────────────────────────────────────────────────
|
||||
|
||||
def test_set_policy_updates_by_pubkey(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
path.write_text(f"{pub} laptop\n")
|
||||
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
|
||||
|
||||
def test_set_policy_by_name_and_remove_with_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
path.write_text(f"{pub} ci-bot allow:all\n")
|
||||
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
|
||||
|
||||
def test_set_policy_safe_only_writes_empty_token(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "c" * 64
|
||||
path.write_text(f"{pub} reader\n")
|
||||
set_authorized_key_policy(path, pub, [])
|
||||
assert path.read_text().strip() == f"{pub} reader allow:"
|
||||
|
||||
def test_set_policy_not_found_returns_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} laptop\n")
|
||||
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
|
||||
|
||||
def test_set_policy_ambiguous_name_raises(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
set_authorized_key_policy(path, "dup", ["control"])
|
||||
|
||||
def test_set_policy_preserves_other_lines(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
a, b = "a" * 64, "b" * 64
|
||||
path.write_text(f"{a} first\n{b} second allow:read-page\n")
|
||||
set_authorized_key_policy(path, a, ["control"])
|
||||
assert load_authorized_keys_with_policies(path) == [
|
||||
(a, "first", ["control"]),
|
||||
(b, "second", ["read-page"]), # untouched
|
||||
]
|
||||
|
||||
# ── authorized_keys line parsing ─────────────────────────────────────────────────
|
||||
|
||||
def test_parse_line_pubkey_only():
|
||||
|
||||
Reference in New Issue
Block a user