feat: add n8n serve node and harden remote access
- Add the n8n community node package with credentials, command mapping, direct serve TCP client, and browser-cli protocol crypto helpers. - Cover Ed25519 signing, canonical JSON, PQ transport encryption, request mapping, and security behavior with unit tests. - Harden serve-http with per-address rate limiting, an 8 MB request body cap, and clear warnings when binding plain HTTP beyond loopback. - Stop one-shot --key overrides from being persisted automatically; document explicit remote trust and keep key-management behind the keys policy tier. - Make HTML-to-Markdown conversion safer by bounding tree depth and dropping unsafe link/image URL schemes. - Bump package and extension release metadata to 0.16.3.
This commit is contained in:
@@ -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`.
|
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**
|
**Message format**
|
||||||
|
|
||||||
Every command is a JSON object:
|
Every command is a JSON object:
|
||||||
```json
|
```json
|
||||||
{ "id": "uuid", "command": "tabs.list", "args": {} }
|
{ "id": "uuid", "command": "tabs.list", "args": {} }
|
||||||
@@ -50,7 +49,6 @@ Every response:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
**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).
|
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
|
||||||
@@ -109,7 +107,6 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
browser-cli/
|
browser-cli/
|
||||||
├── browser_cli/
|
├── browser_cli/
|
||||||
@@ -145,7 +142,6 @@ browser-cli/
|
|||||||
---
|
---
|
||||||
|
|
||||||
## CLI reference
|
## 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`.
|
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.
|
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 +149,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.
|
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`)
|
### Navigation (`nav`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Open a URL (no focus stealing by default)
|
# Open a URL (no focus stealing by default)
|
||||||
browser-cli nav open https://example.com
|
browser-cli nav open https://example.com
|
||||||
@@ -175,7 +170,6 @@ browser-cli nav focus github # focuses first tab whose URL contains "
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
Each search command opens the search results in your browser using the same flags as `nav open`.
|
Each search command opens the search results in your browser using the same flags as `nav open`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -199,7 +193,6 @@ browser-cli search so click choices
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli tabs list # list all open tabs (all windows)
|
browser-cli tabs list # list all open tabs (all windows)
|
||||||
browser-cli tabs count # count all tabs
|
browser-cli tabs count # count all tabs
|
||||||
@@ -227,7 +220,6 @@ browser-cli tabs merge-windows # pull all tabs into the current wi
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Tab groups
|
### Tab groups
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli groups list # list all tab groups
|
browser-cli groups list # list all tab groups
|
||||||
browser-cli groups count # count groups
|
browser-cli groups count # count groups
|
||||||
@@ -249,7 +241,6 @@ browser-cli groups move 42 -l # short left alias
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli windows list # list all windows
|
browser-cli windows list # list all windows
|
||||||
browser-cli windows open # open a new window
|
browser-cli windows open # open a new window
|
||||||
@@ -259,7 +250,6 @@ browser-cli windows close 1 # close a window
|
|||||||
```
|
```
|
||||||
|
|
||||||
### DOM
|
### 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`.
|
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
|
```sh
|
||||||
@@ -272,7 +262,6 @@ browser-cli dom type "#search" "hello" # type text into an input
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Extract
|
### Extract
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli extract links # all <a href> links on the page
|
browser-cli extract links # all <a href> links on the page
|
||||||
browser-cli extract images # all <img> tags (src + alt)
|
browser-cli extract images # all <img> tags (src + alt)
|
||||||
@@ -284,7 +273,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sessions
|
### 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.
|
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
|
```sh
|
||||||
@@ -298,7 +286,6 @@ browser-cli session auto-save off
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Misc
|
### Misc
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli clients # show connected browser info from the registry
|
browser-cli clients # show connected browser info from the registry
|
||||||
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
|
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
|
||||||
@@ -309,7 +296,6 @@ browser-cli completion zsh --script # output raw completion script
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Remote control, auth, and gateways
|
### Remote control, auth, and gateways
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# On the machine with the browser
|
# On the machine with the browser
|
||||||
browser-cli auth keygen --output ~/.config/browser-cli/client.key
|
browser-cli auth keygen --output ~/.config/browser-cli/client.key
|
||||||
@@ -334,22 +320,24 @@ browser-cli serve-http --port 8766
|
|||||||
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
|
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
|
||||||
|
browser-cli can't be installed inside an n8n container, so the [`n8n-nodes-browser-cli`](n8n-nodes-browser-cli/) community node talks to a remote `serve-http` gateway over HTTP(S). Run the gateway on the browser machine (behind TLS), drop its token into the node's 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
|
#### Security model
|
||||||
|
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
|
||||||
- **`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`, `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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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 SDK
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from browser_cli import AsyncBrowserCLI, BrowserCLI
|
from browser_cli import AsyncBrowserCLI, BrowserCLI
|
||||||
|
|
||||||
@@ -485,7 +473,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Error handling**
|
**Error handling**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from browser_cli import BrowserCLI, BrowserNotConnected
|
from browser_cli import BrowserCLI, BrowserNotConnected
|
||||||
|
|
||||||
@@ -517,7 +504,6 @@ if isinstance(counts, BrowserCounts):
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Example scripts
|
## Example scripts
|
||||||
|
|
||||||
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
|
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -528,7 +514,6 @@ bash examples/demo.sh
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run check:extension # type-check, build extension bundles, syntax-check bundle
|
npm run check:extension # type-check, build extension bundles, syntax-check bundle
|
||||||
@@ -569,7 +554,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
- **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>`.
|
- **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.
|
- **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 +562,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
|
|||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
PolyForm Noncommercial License 1.0.0. See [LICENSE](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.
|
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
|
|||||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||||
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
|
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
|
||||||
private_key = load_private_key(key_spec)
|
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
|
route_profile = requested_profile
|
||||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
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()
|
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)
|
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)
|
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
|
route_profile = requested_profile
|
||||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ from rich.console import Console
|
|||||||
from browser_cli import BrowserCLI
|
from browser_cli import BrowserCLI
|
||||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
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.commands import command_policy_from_options, command_policy_options
|
||||||
|
from browser_cli.serve.security import RateLimiter
|
||||||
|
|
||||||
console = Console()
|
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:
|
def _is_loopback(host: str) -> bool:
|
||||||
return host in {"127.0.0.1", "localhost", "::1"}
|
return host in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
|
||||||
@@ -21,6 +25,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
client: BrowserCLI
|
client: BrowserCLI
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
policy: CommandPolicy = CommandPolicy()
|
policy: CommandPolicy = CommandPolicy()
|
||||||
|
rate_limiter: RateLimiter | None = None
|
||||||
|
|
||||||
def _authorized(self) -> bool:
|
def _authorized(self) -> bool:
|
||||||
if self.token is None:
|
if self.token is None:
|
||||||
@@ -37,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(401, {"error": "missing or invalid token"})
|
self._send(401, {"error": "missing or invalid token"})
|
||||||
return False
|
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):
|
def _send(self, status: int, payload):
|
||||||
raw = json.dumps(payload, default=str).encode("utf-8")
|
raw = json.dumps(payload, default=str).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@@ -48,8 +59,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
try:
|
try:
|
||||||
if path != "/health" and not self._require_auth():
|
if path != "/health":
|
||||||
return
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
if not self._within_rate_limit():
|
||||||
|
return
|
||||||
if path == "/tabs":
|
if path == "/tabs":
|
||||||
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
|
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
|
||||||
elif path == "/clients":
|
elif path == "/clients":
|
||||||
@@ -64,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
if path != "/command":
|
||||||
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:
|
|
||||||
self._send(404, {"error": "not found"})
|
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:
|
except PermissionError as exc:
|
||||||
self._send(403, {"error": str(exc)})
|
self._send(403, {"error": str(exc)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -90,21 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
@click.option("--key", default=None, help="Remote auth key spec")
|
@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("--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("--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
|
@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).
|
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
|
||||||
|
|
||||||
Auth is enabled by default. Pass the printed token as either
|
Auth is enabled by default. Pass the printed token as either
|
||||||
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
|
``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):
|
if no_auth and not _is_loopback(host):
|
||||||
raise click.ClickException("--no-auth is only allowed on loopback hosts")
|
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))
|
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)
|
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(
|
handler = type(
|
||||||
"BrowserCLIHTTPHandler",
|
"BrowserCLIHTTPHandler",
|
||||||
(_Handler,),
|
(_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)
|
server = ThreadingHTTPServer((host, port), handler)
|
||||||
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
|
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ class _HtmlNode:
|
|||||||
self.text = text
|
self.text = text
|
||||||
self.children = []
|
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):
|
class _HtmlTreeBuilder(HTMLParser):
|
||||||
_VOID_TAGS = {"br", "hr", "img"}
|
_VOID_TAGS = {"br", "hr", "img"}
|
||||||
|
|
||||||
@@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
|
|||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||||
self._stack[-1].children.append(node)
|
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)
|
self._stack.append(node)
|
||||||
|
|
||||||
def handle_startendtag(self, tag, attrs):
|
def handle_startendtag(self, tag, attrs):
|
||||||
@@ -57,6 +66,14 @@ def _collapse_blank_lines(value):
|
|||||||
def _escape_markdown(text):
|
def _escape_markdown(text):
|
||||||
return re.sub(r"([\\`[\]])", r"\\\1", 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):
|
def _escape_table_cell(text):
|
||||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
return text.replace("|", r"\|").replace("\n", " ").strip()
|
||||||
|
|
||||||
@@ -86,14 +103,14 @@ def _inline_text(node):
|
|||||||
if tag == "br":
|
if tag == "br":
|
||||||
return "\n"
|
return "\n"
|
||||||
if tag == "img":
|
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 "")
|
alt = _normalize_text(node.attrs.get("alt") or "")
|
||||||
if not src:
|
if not src:
|
||||||
return ""
|
return ""
|
||||||
return f"" if alt else f""
|
return f"" if alt else f""
|
||||||
if tag == "a":
|
if tag == "a":
|
||||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
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
|
return f"[{text or href}]({href})" if href else text
|
||||||
if tag == "code":
|
if tag == "code":
|
||||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
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):
|
def convert_html_to_markdown(html, clean_markdown_output):
|
||||||
parser = _HtmlTreeBuilder()
|
parser = _HtmlTreeBuilder()
|
||||||
parser.feed(html or "")
|
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)
|
return clean_markdown_output(markdown)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.16.0",
|
"version": "0.16.3",
|
||||||
"description": "Control your browser from the terminal or Python SDK",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-dist/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# n8n-nodes-browser-cli
|
||||||
|
An [n8n](https://n8n.io) community node 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## 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 | `tabs.list` | safe (default) |
|
||||||
|
| Tab | Open | `navigate.open` | `--allow-control` |
|
||||||
|
| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` |
|
||||||
|
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
|
||||||
|
| Page | Get Info | `page.info` | safe (default) |
|
||||||
|
| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` |
|
||||||
|
| DOM | Query | `dom.query` | `--allow-read-page` |
|
||||||
|
| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` |
|
||||||
|
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
|
||||||
|
| Client | List | `clients.list` | safe (default) |
|
||||||
|
| Command | Execute | any command name + JSON args | per command |
|
||||||
|
| Gateway | Health | pings with `tabs.list` | safe (default) |
|
||||||
|
|
||||||
|
**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**.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install into n8n as a [community node](https://docs.n8n.io/integrations/community-nodes/installation/)
|
||||||
|
(`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,85 @@
|
|||||||
|
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: '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,382 @@
|
|||||||
|
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: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||||
|
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: 'Client', value: 'client' },
|
||||||
|
{ name: 'Command', value: 'command' },
|
||||||
|
{ name: 'Gateway', value: 'gateway' },
|
||||||
|
],
|
||||||
|
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: '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)' },
|
||||||
|
],
|
||||||
|
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)' },
|
||||||
|
],
|
||||||
|
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: '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: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
|
||||||
|
],
|
||||||
|
default: 'query',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 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',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Gateway operations -----------------------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['gateway'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' },
|
||||||
|
],
|
||||||
|
default: 'health',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Shared parameter fields -----------------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'URL',
|
||||||
|
name: 'url',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
displayOptions: { show: showFor('tab', ['open']) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'], operation: ['getHtml'] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Text',
|
||||||
|
name: 'text',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: { show: showFor('dom', ['type']) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'Tab ID',
|
||||||
|
name: 'tabId',
|
||||||
|
type: 'number',
|
||||||
|
default: 0,
|
||||||
|
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||||
|
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
|
case 'tab:open':
|
||||||
|
return { url: get('url'), focus: get('focus', false) };
|
||||||
|
case 'tab:close':
|
||||||
|
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
||||||
|
case 'tab:getHtml':
|
||||||
|
return { tabId: get('tabId', 0) };
|
||||||
|
case 'dom:query':
|
||||||
|
case 'dom:click':
|
||||||
|
return { selector: get('selector', '') };
|
||||||
|
case 'dom:type':
|
||||||
|
return { selector: get('selector', ''), text: get('text', '') };
|
||||||
|
case 'dom:eval':
|
||||||
|
return { code: get('code', ''), tabId: get('tabId', 0) };
|
||||||
|
case 'page:extractText':
|
||||||
|
case 'page:extractLinks':
|
||||||
|
case 'page:extractImages':
|
||||||
|
case 'page:extractHtml':
|
||||||
|
case 'page:extractMarkdown':
|
||||||
|
return { selector: get('selector', '') };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" 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,302 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,129 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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: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) }) };
|
||||||
|
|
||||||
|
// --- 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') }) };
|
||||||
|
|
||||||
|
// --- 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:eval':
|
||||||
|
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
|
||||||
|
|
||||||
|
// --- Clients ----------------------------------------------------------
|
||||||
|
case 'client:list':
|
||||||
|
return { command: 'clients.list', args: {} };
|
||||||
|
|
||||||
|
// --- Gateway: serve has no health route, so ping with a safe command --
|
||||||
|
case 'gateway:health':
|
||||||
|
return { command: 'tabs.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,147 @@
|
|||||||
|
/**
|
||||||
|
* 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, 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.15.4';
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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.2.4",
|
||||||
|
"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,141 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
authMessage,
|
||||||
|
buildAuthPayload,
|
||||||
|
canonicalJson,
|
||||||
|
decodeResponse,
|
||||||
|
ed25519PublicKeyHex,
|
||||||
|
frame,
|
||||||
|
pqDecrypt,
|
||||||
|
pqEncrypt,
|
||||||
|
pqTransportKey,
|
||||||
|
signAuth,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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('gateway:health pings with tabs.list (serve has no health route)', () => {
|
||||||
|
assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.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('unknown operation throws', () => {
|
||||||
|
assert.throws(() => buildCommand('tab', 'nope', {}), /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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -98,9 +98,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -115,9 +115,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -132,9 +132,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -149,9 +149,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -166,9 +166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -183,9 +183,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -200,9 +200,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -217,9 +217,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -234,9 +234,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -251,9 +251,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -268,9 +268,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -285,9 +285,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -302,9 +302,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -319,9 +319,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -336,9 +336,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -353,9 +353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -370,9 +370,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -387,9 +387,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -404,9 +404,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -421,9 +421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -438,9 +438,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -497,9 +497,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -510,32 +510,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.28.0",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.28.0",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.28.0",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.28.0",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.28.0",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.28.0",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.28.0",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.28.0",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.28.0",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.28.0",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.28.0",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.28.0",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.28.0",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.28.0",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.28.0",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.28.0",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.28.0",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.28.0",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.28.0",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.28.0",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.28.0",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.28.0",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.28.0",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.28.0",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.28.0",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.28.0"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.16.2"
|
version = "0.16.3"
|
||||||
description = "Control your real running browser from the terminal or Python SDK"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -461,8 +461,8 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
|
|||||||
assert len(targets) == 1
|
assert len(targets) == 1
|
||||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||||
|
|
||||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
|
||||||
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
"""--key is a one-shot override; use `browser-cli remote trust` to remember it."""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
remotes_path = tmp_path / "remotes.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)
|
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"))
|
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
||||||
assert used_keys[-1] == "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")
|
send_command("tabs.list", remote="host:8765")
|
||||||
assert used_keys[-1] == "agent"
|
assert used_keys[-1] is None
|
||||||
|
|
||||||
# ── async command transport ──────────────────────────────────────────────────
|
# ── async command transport ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -357,6 +357,48 @@ def test_serve_http_uses_compare_digest():
|
|||||||
assert "compare_digest" in src
|
assert "compare_digest" in src
|
||||||
assert "== f\"Bearer" not 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():
|
def test_command_policy_allow_all_grants_everything():
|
||||||
policy = command_policy_from_options(
|
policy = command_policy_from_options(
|
||||||
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
|
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
|
||||||
|
|||||||
@@ -462,6 +462,48 @@ class TestPerKeyPolicy:
|
|||||||
client.close()
|
client.close()
|
||||||
t.join(timeout=2)
|
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:
|
class TestRateLimit:
|
||||||
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
|
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
|
||||||
"""A burst-1 limiter shared across connections allows the first command, denies the next."""
|
"""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,
|
format_authorized_line,
|
||||||
load_authorized_keys_with_names,
|
load_authorized_keys_with_names,
|
||||||
load_authorized_keys_with_policies,
|
load_authorized_keys_with_policies,
|
||||||
|
set_authorized_key_policy,
|
||||||
)
|
)
|
||||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||||
from browser_cli.serve.security import (
|
from browser_cli.serve.security import (
|
||||||
@@ -58,6 +59,50 @@ def test_full_control_still_cannot_manage_keys():
|
|||||||
with pytest.raises(PermissionError):
|
with pytest.raises(PermissionError):
|
||||||
assert_command_allowed("browser-cli.auth.trust", policy)
|
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 ─────────────────────────────────────────────────
|
# ── authorized_keys line parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_parse_line_pubkey_only():
|
def test_parse_line_pubkey_only():
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.16.2"
|
version = "0.16.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user