From cea8a7e9944fc375cd2a92caa814277a17c06084 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Fri, 19 Jun 2026 10:00:23 +0200 Subject: [PATCH] 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. --- README.md | 33 +- browser_cli/client/auth.py | 4 - browser_cli/commands/serve_http.py | 56 +- browser_cli/markdown/html.py | 30 +- extension/manifest.json | 2 +- n8n-nodes-browser-cli/.gitignore | 4 + n8n-nodes-browser-cli/README.md | 90 + .../credentials/BrowserCliApi.credentials.ts | 85 + .../nodes/BrowserCli/BrowserCli.node.ts | 382 ++++ .../nodes/BrowserCli/browserCli.svg | 31 + .../nodes/BrowserCli/protocol.ts | 302 +++ .../nodes/BrowserCli/request.ts | 129 ++ .../nodes/BrowserCli/serveClient.ts | 147 ++ n8n-nodes-browser-cli/package-lock.json | 1825 +++++++++++++++++ n8n-nodes-browser-cli/package.json | 45 + n8n-nodes-browser-cli/scripts/copy-assets.mjs | 17 + n8n-nodes-browser-cli/scripts/run-tests.mjs | 27 + n8n-nodes-browser-cli/test/protocol.test.ts | 141 ++ n8n-nodes-browser-cli/test/request.test.ts | 73 + n8n-nodes-browser-cli/tsconfig.json | 19 + package-lock.json | 214 +- pyproject.toml | 2 +- tests/test_client.py | 12 +- tests/test_markdown_security.py | 50 + tests/test_new_feature_commands.py | 42 + tests/test_serve.py | 42 + tests/test_serve_security.py | 45 + uv.lock | 2 +- 28 files changed, 3687 insertions(+), 164 deletions(-) create mode 100644 n8n-nodes-browser-cli/.gitignore create mode 100644 n8n-nodes-browser-cli/README.md create mode 100644 n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts create mode 100644 n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts create mode 100644 n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg create mode 100644 n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts create mode 100644 n8n-nodes-browser-cli/nodes/BrowserCli/request.ts create mode 100644 n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts create mode 100644 n8n-nodes-browser-cli/package-lock.json create mode 100644 n8n-nodes-browser-cli/package.json create mode 100644 n8n-nodes-browser-cli/scripts/copy-assets.mjs create mode 100644 n8n-nodes-browser-cli/scripts/run-tests.mjs create mode 100644 n8n-nodes-browser-cli/test/protocol.test.ts create mode 100644 n8n-nodes-browser-cli/test/request.test.ts create mode 100644 n8n-nodes-browser-cli/tsconfig.json create mode 100644 tests/test_markdown_security.py diff --git a/README.md b/README.md index 3bf521f..d12f6eb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ terminal / python script / remote client No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`. **Message format** - Every command is a JSON object: ```json { "id": "uuid", "command": "tabs.list", "args": {} } @@ -50,7 +49,6 @@ Every response: --- ## Installation - **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores). @@ -109,7 +107,6 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches --- ## Project structure - ```text browser-cli/ ├── browser_cli/ @@ -145,7 +142,6 @@ browser-cli/ --- ## CLI reference - During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] `. 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 `. 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. ### Navigation (`nav`) - ```sh # Open a URL (no focus stealing by default) browser-cli nav open https://example.com @@ -175,7 +170,6 @@ browser-cli nav focus github # focuses first tab whose URL contains " ``` ### Search - Each search command opens the search results in your browser using the same flags as `nav open`. ```sh @@ -199,7 +193,6 @@ browser-cli search so click choices ``` ### Tabs - ```sh browser-cli tabs list # list all open tabs (all windows) browser-cli tabs count # count all tabs @@ -227,7 +220,6 @@ browser-cli tabs merge-windows # pull all tabs into the current wi ``` ### Tab groups - ```sh browser-cli groups list # list all tab groups browser-cli groups count # count groups @@ -249,7 +241,6 @@ browser-cli groups move 42 -l # short left alias ``` ### Windows - ```sh browser-cli windows list # list all windows browser-cli windows open # open a new window @@ -259,7 +250,6 @@ browser-cli windows close 1 # close a window ``` ### DOM - These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`. ```sh @@ -272,7 +262,6 @@ browser-cli dom type "#search" "hello" # type text into an input ``` ### Extract - ```sh browser-cli extract links # all links on the page browser-cli extract images # all tags (src + alt) @@ -284,7 +273,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma ``` ### Sessions - A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared. ```sh @@ -298,7 +286,6 @@ browser-cli session auto-save off ``` ### Misc - ```sh browser-cli clients # show connected browser info from the registry browser-cli clients rename --browser abcd1234 work # rename one connected browser instance @@ -309,7 +296,6 @@ browser-cli completion zsh --script # output raw completion script ``` ### Remote control, auth, and gateways - ```sh # On the machine with the browser browser-cli auth keygen --output ~/.config/browser-cli/client.key @@ -334,22 +320,24 @@ browser-cli serve-http --port 8766 curl -H "Authorization: Bearer " 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 - -- **`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 (` 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 --allow-control …` (works locally and over `--remote`); `auth keys` shows each key's policy. +- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts. + - **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (` 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 --allow-control …` when adding a key, or change it later with `auth policy …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy. + - **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own. - **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser. - **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address. -- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default), but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there. If you must expose it beyond loopback, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted. +- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use. For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection. --- ## Python SDK - ```python from browser_cli import AsyncBrowserCLI, BrowserCLI @@ -485,7 +473,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com ``` **Error handling** - ```python from browser_cli import BrowserCLI, BrowserNotConnected @@ -517,7 +504,6 @@ if isinstance(counts, BrowserCounts): --- ## Example scripts - See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management. ```sh @@ -528,7 +514,6 @@ bash examples/demo.sh --- ## Development - ```sh npm ci 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 - - **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 `. - **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 - PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE). Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer. diff --git a/browser_cli/client/auth.py b/browser_cli/client/auth.py index 9d612c7..ff237b8 100644 --- a/browser_cli/client/auth.py +++ b/browser_cli/client/auth.py @@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non msg["accept_encoding"] = transport.client_accept_encoding() key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint) private_key = load_private_key(key_spec) - if key is not None: - remote_registry.save_remote_key(remote_endpoint, str(key)) route_profile = requested_profile if not route_profile and command not in NO_ROUTE_COMMANDS: @@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil msg["accept_encoding"] = transport.client_accept_encoding() key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint) private_key = await asyncio.to_thread(load_private_key, key_spec) - if key is not None: - await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key)) route_profile = requested_profile if not route_profile and command not in NO_ROUTE_COMMANDS: diff --git a/browser_cli/commands/serve_http.py b/browser_cli/commands/serve_http.py index 62eba2e..e978e50 100644 --- a/browser_cli/commands/serve_http.py +++ b/browser_cli/commands/serve_http.py @@ -11,9 +11,13 @@ from rich.console import Console from browser_cli import BrowserCLI from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.commands import command_policy_from_options, command_policy_options +from browser_cli.serve.security import RateLimiter console = Console() +# Hard cap on request body size so a bogus Content-Length can't exhaust memory. +MAX_BODY_BYTES = 8 * 1024 * 1024 + def _is_loopback(host: str) -> bool: return host in {"127.0.0.1", "localhost", "::1"} @@ -21,6 +25,7 @@ class _Handler(BaseHTTPRequestHandler): client: BrowserCLI token: str | None = None policy: CommandPolicy = CommandPolicy() + rate_limiter: RateLimiter | None = None def _authorized(self) -> bool: if self.token is None: @@ -37,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler): self._send(401, {"error": "missing or invalid token"}) return False + def _within_rate_limit(self) -> bool: + if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]): + return True + self._send(429, {"error": "rate limit exceeded; slow down and retry"}) + return False + def _send(self, status: int, payload): raw = json.dumps(payload, default=str).encode("utf-8") self.send_response(status) @@ -48,8 +59,11 @@ class _Handler(BaseHTTPRequestHandler): def do_GET(self): path = urlparse(self.path).path try: - if path != "/health" and not self._require_auth(): - return + if path != "/health": + if not self._require_auth(): + return + if not self._within_rate_limit(): + return if path == "/tabs": self._send(200, [t.__dict__ for t in self.client.tabs.list()]) elif path == "/clients": @@ -64,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler): def do_POST(self): path = urlparse(self.path).path try: - length = int(self.headers.get("Content-Length", "0")) - body = json.loads(self.rfile.read(length) or b"{}") - if path == "/command": - if not self._require_auth(): - return - command = body.get("command") - assert_command_allowed(command, self.policy) - self._send(200, {"result": self.client.command(command, body.get("args") or {})}) - else: + if path != "/command": self._send(404, {"error": "not found"}) + return + if not self._require_auth(): + return + if not self._within_rate_limit(): + return + length = int(self.headers.get("Content-Length", "0")) + if length > MAX_BODY_BYTES: + self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"}) + return + body = json.loads(self.rfile.read(length) or b"{}") + command = body.get("command") + assert_command_allowed(command, self.policy) + self._send(200, {"result": self.client.command(command, body.get("args") or {})}) except PermissionError as exc: self._send(403, {"error": str(exc)}) except Exception as exc: @@ -90,21 +109,32 @@ class _Handler(BaseHTTPRequestHandler): @click.option("--key", default=None, help="Remote auth key spec") @click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)") @click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)") +@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)") @command_policy_options -def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all): +def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all): """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command). Auth is enabled by default. Pass the printed token as either ``Authorization: Bearer `` or ``X-Browser-CLI-Token: ``. + + This gateway speaks plain HTTP — the token is sent in clear text. Keep it on + loopback, or put a TLS-terminating reverse proxy in front before exposing it. """ if no_auth and not _is_loopback(host): raise click.ClickException("--no-auth is only allowed on loopback hosts") + if not _is_loopback(host): + console.print( + "[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the " + "token travels in clear text. Put a TLS-terminating reverse proxy in front, or use " + "[bold]browser-cli serve[/bold] (encrypted) instead." + ) auth_token = None if no_auth else (token or secrets.token_urlsafe(32)) policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all) + rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None handler = type( "BrowserCLIHTTPHandler", (_Handler,), - {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy}, + {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter}, ) server = ThreadingHTTPServer((host, port), handler) console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]") diff --git a/browser_cli/markdown/html.py b/browser_cli/markdown/html.py index 39b99ce..feaeacb 100644 --- a/browser_cli/markdown/html.py +++ b/browser_cli/markdown/html.py @@ -11,6 +11,13 @@ class _HtmlNode: self.text = text self.children = [] +# Cap how deep the parsed tree may nest. Hostile page content (thousands of +# nested elements) would otherwise blow Python's recursion limit in the +# depth-first render walkers below. Bounding here protects every walker at once. +# 200 levels is far beyond any real document; deeper content is flattened, not +# dropped (its text still reaches the output). +_MAX_TREE_DEPTH = 200 + class _HtmlTreeBuilder(HTMLParser): _VOID_TAGS = {"br", "hr", "img"} @@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser): def handle_starttag(self, tag, attrs): node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs)) self._stack[-1].children.append(node) - if node.tag not in self._VOID_TAGS: + # Only descend while under the depth cap; beyond it, children of this node + # attach to the current (capped) parent — flattened but preserved. + if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH: self._stack.append(node) def handle_startendtag(self, tag, attrs): @@ -57,6 +66,14 @@ def _collapse_blank_lines(value): def _escape_markdown(text): return re.sub(r"([\\`[\]])", r"\\\1", text) +# Schemes that are dangerous if the produced markdown is later rendered as HTML +# by a downstream consumer. The output is plain text here, but neutralising them +# keeps the converter from laundering an XSS payload through to such a consumer. +_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE) + +def _safe_url(url): + return "" if _UNSAFE_URL_SCHEME.match(url or "") else url + def _escape_table_cell(text): return text.replace("|", r"\|").replace("\n", " ").strip() @@ -86,14 +103,14 @@ def _inline_text(node): if tag == "br": return "\n" if tag == "img": - src = node.attrs.get("src") or "" + src = _safe_url(node.attrs.get("src") or "") alt = _normalize_text(node.attrs.get("alt") or "") if not src: return "" return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})" if tag == "a": text = _normalize_inline("".join(_inline_text(child) for child in node.children)) - href = node.attrs.get("href") or "" + href = _safe_url(node.attrs.get("href") or "") return f"[{text or href}]({href})" if href else text if tag == "code": text = _normalize_inline("".join(_inline_text(child) for child in node.children)) @@ -235,5 +252,10 @@ def _block_to_markdown(node): def convert_html_to_markdown(html, clean_markdown_output): parser = _HtmlTreeBuilder() parser.feed(html or "") - markdown = _block_to_markdown(parser.root) + try: + markdown = _block_to_markdown(parser.root) + except RecursionError: + # The depth cap should prevent this, but never let hostile page content + # crash the caller: fall back to a flat, tag-stripped text extraction. + markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or "")) return clean_markdown_output(markdown) diff --git a/extension/manifest.json b/extension/manifest.json index 4ed41b7..338e575 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.16.0", + "version": "0.16.3", "description": "Control your browser from the terminal or Python SDK", "browser_specific_settings": { "gecko": { diff --git a/n8n-nodes-browser-cli/.gitignore b/n8n-nodes-browser-cli/.gitignore new file mode 100644 index 0000000..52feb64 --- /dev/null +++ b/n8n-nodes-browser-cli/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +test-dist/ +*.tsbuildinfo diff --git a/n8n-nodes-browser-cli/README.md b/n8n-nodes-browser-cli/README.md new file mode 100644 index 0000000..047cba6 --- /dev/null +++ b/n8n-nodes-browser-cli/README.md @@ -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 --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. diff --git a/n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts b/n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts new file mode 100644 index 0000000..60af4d2 --- /dev/null +++ b/n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts @@ -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 --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)', + }, + ]; +} diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts new file mode 100644 index 0000000..c3ab748 --- /dev/null +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts @@ -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 { + 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 { + 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 { + 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 = {}; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + args = trimmed ? JSON.parse(trimmed) : {}; + } else if (isObject(raw)) { + args = raw as Record; + } + 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 {}; + } +} diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg b/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg new file mode 100644 index 0000000..76705f9 --- /dev/null +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg @@ -0,0 +1,31 @@ + + browser-cli icon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts new file mode 100644 index 0000000..4745805 --- /dev/null +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts @@ -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; + 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, 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, + pqSecret: Buffer | null, +): string { + return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex'); +} + +function stripAuthFields(msg: Record): Record { + const out: Record = {}; + 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; +let mlkemPromise: Promise | null = null; + +async function mlKem768(): Promise { + 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; + 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, + challenge: Challenge, + privatePem: string | null, +): Promise { + 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; +} diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts new file mode 100644 index 0000000..94323fc --- /dev/null +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts @@ -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; + +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; +} + +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): Record { + const out: Record = {}; + 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) ?? {} }; + + // --- 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); +} diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts new file mode 100644 index 0000000..9f5989f --- /dev/null +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts @@ -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 { + 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, +): Promise { + const socket = openSocket(opts); + socket.setNoDelay(true); + const reader = new FrameReader(socket); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return new Promise((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 = { + 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))); + }); + }); +} diff --git a/n8n-nodes-browser-cli/package-lock.json b/n8n-nodes-browser-cli/package-lock.json new file mode 100644 index 0000000..6d80206 --- /dev/null +++ b/n8n-nodes-browser-cli/package-lock.json @@ -0,0 +1,1825 @@ +{ + "name": "n8n-nodes-browser-cli", + "version": "0.2.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "n8n-nodes-browser-cli", + "version": "0.2.4", + "license": "PolyForm-Noncommercial-1.0.0", + "dependencies": { + "@noble/post-quantum": "^0.6.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.28.0", + "n8n-workflow": "*", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "n8n-workflow": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@n8n_io/riot-tmpl": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@n8n_io/riot-tmpl/-/riot-tmpl-4.0.1.tgz", + "integrity": "sha512-/zdRbEfTFjsm1NqnpPQHgZTkTdbp5v3VUxGeMA9098sps8jRCTraQkc3AQstJgHUm7ylBXJcIVhnVeLUMWAfwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-riot": "^1.0.0" + } + }, + "node_modules/@n8n/errors": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@n8n/errors/-/errors-0.6.0.tgz", + "integrity": "sha512-oVJ0lgRYJY6/aPOW2h37ea5T+nX7/wULRn5FymwYeaiYlsLdqwIQEtGwZrajpzxJB0Os74u4lSH3WWQgZCkgxQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "callsites": "3.1.0" + } + }, + "node_modules/@n8n/expression-runtime": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@n8n/expression-runtime/-/expression-runtime-0.8.0.tgz", + "integrity": "sha512-SnBLoLsrGUCS9cVrF20UuSyKcMv+y6Clc4+EA9zLjX85S0GPwOAdApUVSsi81ejPAqMlm42TW1L6r7NpcK/ztQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/tournament": "1.0.6", + "isolated-vm": "^6.0.2", + "js-base64": "3.7.2", + "jssha": "3.3.1", + "lodash": "4.17.23", + "luxon": "3.7.2", + "md5": "2.3.0", + "title-case": "3.0.3", + "transliteration": "2.3.5" + } + }, + "node_modules/@n8n/tournament": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.0.6.tgz", + "integrity": "sha512-UGSxYXXVuOX0yL6HTLBStKYwLIa0+JmRKiSZSCMcM2s2Wax984KWT6XIA1TR/27i7yYpDk1MY14KsTPnuEp27A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@n8n_io/riot-tmpl": "^4.0.1", + "ast-types": "^0.16.1", + "esprima-next": "^5.8.4", + "recast": "^0.22.0" + }, + "engines": { + "node": ">=20.15", + "pnpm": ">=9.5" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-config-riot": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-1.0.0.tgz", + "integrity": "sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esprima-next": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/esprima-next/-/esprima-next-5.8.4.tgz", + "integrity": "sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isolated-vm": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.1.2.tgz", + "integrity": "sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-base64": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", + "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/jsonrepair": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", + "dev": true, + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/n8n-workflow": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-2.16.0.tgz", + "integrity": "sha512-JoDvHLvV4QZQBCDVQl5xkrGOmPmsacLO4TkJkErCzMmiEqIej+h14J6eCUY7gbxBj8V+GIBlpqyO63akJbXRQg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/errors": "0.6.0", + "@n8n/expression-runtime": "0.8.0", + "@n8n/tournament": "1.0.6", + "ast-types": "0.16.1", + "callsites": "3.1.0", + "esprima-next": "5.8.4", + "form-data": "4.0.4", + "jmespath": "0.16.0", + "js-base64": "3.7.2", + "jsonrepair": "3.13.2", + "jssha": "3.3.1", + "lodash": "4.17.23", + "luxon": "3.7.2", + "md5": "2.3.0", + "recast": "0.22.0", + "title-case": "3.0.3", + "transliteration": "2.3.5", + "uuid": "10.0.0", + "xml2js": "0.6.2", + "zod": "3.25.67" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/recast": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.22.0.tgz", + "integrity": "sha512-5AAx+mujtXijsEavc5lWXBPQqrM4+Dl5qNH96N2aNeuJFUzpiiToKPsxQD/zAIJHspz7zz0maX0PCtCTFVlixQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "ast-types": "0.15.2", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", + "integrity": "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/transliteration": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.3.5.tgz", + "integrity": "sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yargs": "^17.5.1" + }, + "bin": { + "slugify": "dist/bin/slugify", + "transliterate": "dist/bin/transliterate" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/n8n-nodes-browser-cli/package.json b/n8n-nodes-browser-cli/package.json new file mode 100644 index 0000000..95f8db5 --- /dev/null +++ b/n8n-nodes-browser-cli/package.json @@ -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" + } +} diff --git a/n8n-nodes-browser-cli/scripts/copy-assets.mjs b/n8n-nodes-browser-cli/scripts/copy-assets.mjs new file mode 100644 index 0000000..7c49824 --- /dev/null +++ b/n8n-nodes-browser-cli/scripts/copy-assets.mjs @@ -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}`); +} diff --git a/n8n-nodes-browser-cli/scripts/run-tests.mjs b/n8n-nodes-browser-cli/scripts/run-tests.mjs new file mode 100644 index 0000000..e0e6ad4 --- /dev/null +++ b/n8n-nodes-browser-cli/scripts/run-tests.mjs @@ -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); diff --git a/n8n-nodes-browser-cli/test/protocol.test.ts b/n8n-nodes-browser-cli/test/protocol.test.ts new file mode 100644 index 0000000..46c703d --- /dev/null +++ b/n8n-nodes-browser-cli/test/protocol.test.ts @@ -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 }); +}); diff --git a/n8n-nodes-browser-cli/test/request.test.ts b/n8n-nodes-browser-cli/test/request.test.ts new file mode 100644 index 0000000..92f1fd0 --- /dev/null +++ b/n8n-nodes-browser-cli/test/request.test.ts @@ -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]); +}); diff --git a/n8n-nodes-browser-cli/tsconfig.json b/n8n-nodes-browser-cli/tsconfig.json new file mode 100644 index 0000000..acf246d --- /dev/null +++ b/n8n-nodes-browser-cli/tsconfig.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index a0b7192..d6f35f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -30,9 +30,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -47,9 +47,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -64,9 +64,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -81,9 +81,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -149,9 +149,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -166,9 +166,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -183,9 +183,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -200,9 +200,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -234,9 +234,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -251,9 +251,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -268,9 +268,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -285,9 +285,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -302,9 +302,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -353,9 +353,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -370,9 +370,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -387,9 +387,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -404,9 +404,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -421,9 +421,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -438,9 +438,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -497,9 +497,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -510,32 +510,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/typescript": { diff --git a/pyproject.toml b/pyproject.toml index 3a3c245..89ccaf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "real-browser-cli" -version = "0.16.2" +version = "0.16.3" description = "Control your real running browser from the terminal or Python SDK" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_client.py b/tests/test_client.py index b2271b8..5fc5160 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -461,8 +461,8 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa assert len(targets) == 1 assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" -def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path): - """--key agent is saved on first use; omitting --key on subsequent calls reuses it.""" +def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path): + """--key is a one-shot override; use `browser-cli remote trust` to remember it.""" import json as _json remotes_path = tmp_path / "remotes.json" @@ -491,16 +491,12 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote) - # First call with explicit --key agent send_command("tabs.list", remote="host:8765", key=_Path("agent")) assert used_keys[-1] == "agent" + assert key_for_remote("host:8765") is None - # Key must be persisted now - assert key_for_remote("host:8765") == "agent" - - # Second call without --key — should reuse saved "agent" send_command("tabs.list", remote="host:8765") - assert used_keys[-1] == "agent" + assert used_keys[-1] is None # ── async command transport ────────────────────────────────────────────────── diff --git a/tests/test_markdown_security.py b/tests/test_markdown_security.py new file mode 100644 index 0000000..001590d --- /dev/null +++ b/tests/test_markdown_security.py @@ -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 = "
" * depth + "deep content" + "
" * 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 = "
" * 3000 + "x" + "
" * 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 = "
  • a
    • b
" + 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

). + out = convert_html_to_markdown('

click

', _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('

y

', _identity) + assert "data:" not in convert_html_to_markdown('', _identity) + +def test_normal_urls_pass_through(): + out = convert_html_to_markdown('

site

', _identity) + assert "(https://example.com)" in out + img = convert_html_to_markdown('pic', _identity) + assert "(https://example.com/x.png)" in img diff --git a/tests/test_new_feature_commands.py b/tests/test_new_feature_commands.py index 438b6a2..21446f4 100644 --- a/tests/test_new_feature_commands.py +++ b/tests/test_new_feature_commands.py @@ -357,6 +357,48 @@ def test_serve_http_uses_compare_digest(): assert "compare_digest" in src assert "== f\"Bearer" not in src +def test_serve_http_rate_limiter_blocks_when_exhausted(): + """A burst-1 limiter lets the first request through, then sends 429.""" + from browser_cli.commands.serve_http import _Handler + from browser_cli.serve.security import RateLimiter + + handler = _Handler.__new__(_Handler) + handler.client_address = ("203.0.113.5", 5000) + handler.rate_limiter = RateLimiter(rate=0.001, burst=1) + sent = [] + handler._send = lambda status, payload: sent.append((status, payload)) + + assert handler._within_rate_limit() is True + assert handler._within_rate_limit() is False + assert sent and sent[-1][0] == 429 + +def test_serve_http_rate_limiter_none_never_limits(): + from browser_cli.commands.serve_http import _Handler + + handler = _Handler.__new__(_Handler) + handler.client_address = ("203.0.113.5", 5000) + handler.rate_limiter = None + assert all(handler._within_rate_limit() for _ in range(100)) + +def test_serve_http_default_rate_limit_active(): + from browser_cli.commands.serve_http import _Handler + + with patch("browser_cli.commands.serve_http.BrowserCLI"), \ + patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls: + server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt + CliRunner().invoke(main, ["serve-http", "--no-auth"]) + # The handler class passed to the server carries an active RateLimiter by default. + handler_cls = server_cls.call_args.args[1] + assert handler_cls.rate_limiter is not None + assert handler_cls.rate_limiter.rate == 100.0 + +def test_serve_http_non_loopback_warns_about_cleartext(): + with patch("browser_cli.commands.serve_http.BrowserCLI"), \ + patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls: + server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt + result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--token", "x"]) + assert "clear text" in result.output + def test_command_policy_allow_all_grants_everything(): policy = command_policy_from_options( allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True diff --git a/tests/test_serve.py b/tests/test_serve.py index 825e35a..b2afc28 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -462,6 +462,48 @@ class TestPerKeyPolicy: client.close() t.join(timeout=2) + def _trust_and_query_keys(self, tmp_path, monkeypatch, server_policy): + """Authenticate, then send browser-cli.auth.keys; return the response dict.""" + from browser_cli.serve.security import ServeSecurity + + path = tmp_path / "authorized_keys" + pem, pub = generate_keypair() + path.write_text(pub + " mykey\n") + key_path = tmp_path / "client.key.pem" + key_path.write_bytes(pem) + priv = load_private_key(key_path) + + client, server = _pair() + t = _spawn(server, path, ServeSecurity(policy=server_policy)) + challenge = _recv_framed(client) + nonce = bytes.fromhex(challenge["nonce"]) + msg = {"id": "x", "command": "browser-cli.auth.keys", "args": {}, "user_agent": FAKE_UA, "pubkey": pub} + msg["sig"] = sign(priv, nonce, msg).hex() + _send_framed(client, json.dumps(msg).encode()) + resp = _recv_framed(client) + client.close() + t.join(timeout=2) + return resp + + def test_key_management_blocked_without_keys_grant(self, tmp_path, monkeypatch): + """Even full control+dangerous can't list keys — the control command is gated.""" + from browser_cli.command_security import CommandPolicy + + resp = self._trust_and_query_keys( + tmp_path, monkeypatch, + CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True), + ) + assert resp["success"] is False + assert "blocked" in resp["error"].lower() and "keys" in resp["error"].lower() + + def test_key_management_allowed_with_keys_grant(self, tmp_path, monkeypatch): + """With allow_keys, the control command runs and returns the trusted-key list.""" + from browser_cli.command_security import CommandPolicy + + resp = self._trust_and_query_keys(tmp_path, monkeypatch, CommandPolicy(allow_keys=True)) + assert resp["success"] is True + assert resp["data"][0]["name"] == "mykey" + class TestRateLimit: def test_shared_rate_limiter_blocks_second_command(self, monkeypatch): """A burst-1 limiter shared across connections allows the first command, denies the next.""" diff --git a/tests/test_serve_security.py b/tests/test_serve_security.py index 52e4f2e..89384bd 100644 --- a/tests/test_serve_security.py +++ b/tests/test_serve_security.py @@ -6,6 +6,7 @@ from browser_cli.auth.keys import ( format_authorized_line, load_authorized_keys_with_names, load_authorized_keys_with_policies, + set_authorized_key_policy, ) from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.serve.security import ( @@ -58,6 +59,50 @@ def test_full_control_still_cannot_manage_keys(): with pytest.raises(PermissionError): assert_command_allowed("browser-cli.auth.trust", policy) +# ── set_authorized_key_policy ──────────────────────────────────────────────────── + +def test_set_policy_updates_by_pubkey(tmp_path): + path = tmp_path / "authorized_keys" + pub = "a" * 64 + path.write_text(f"{pub} laptop\n") + assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop") + assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])] + +def test_set_policy_by_name_and_remove_with_none(tmp_path): + path = tmp_path / "authorized_keys" + pub = "b" * 64 + path.write_text(f"{pub} ci-bot allow:all\n") + assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token + assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)] + +def test_set_policy_safe_only_writes_empty_token(tmp_path): + path = tmp_path / "authorized_keys" + pub = "c" * 64 + path.write_text(f"{pub} reader\n") + set_authorized_key_policy(path, pub, []) + assert path.read_text().strip() == f"{pub} reader allow:" + +def test_set_policy_not_found_returns_none(tmp_path): + path = tmp_path / "authorized_keys" + path.write_text(f"{'a' * 64} laptop\n") + assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None + +def test_set_policy_ambiguous_name_raises(tmp_path): + path = tmp_path / "authorized_keys" + path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n") + with pytest.raises(ValueError, match="ambiguous"): + set_authorized_key_policy(path, "dup", ["control"]) + +def test_set_policy_preserves_other_lines(tmp_path): + path = tmp_path / "authorized_keys" + a, b = "a" * 64, "b" * 64 + path.write_text(f"{a} first\n{b} second allow:read-page\n") + set_authorized_key_policy(path, a, ["control"]) + assert load_authorized_keys_with_policies(path) == [ + (a, "first", ["control"]), + (b, "second", ["read-page"]), # untouched + ] + # ── authorized_keys line parsing ───────────────────────────────────────────────── def test_parse_line_pubkey_only(): diff --git a/uv.lock b/uv.lock index 66634d7..da8624e 100644 --- a/uv.lock +++ b/uv.lock @@ -489,7 +489,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.16.2" +version = "0.16.3" source = { editable = "." } dependencies = [ { name = "click" },