Compare commits

...

6 Commits

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

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

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

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

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

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

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

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

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

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

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

- Bump package and extension release metadata to 0.16.3.
2026-06-19 10:00:23 +02:00
daniel156161 7fe0e27fec feat(auth): add interactive key policy editing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 36s
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve.
- Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes.
- Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected.
- Show policy descriptions in auth keys output so each capability is easier to understand.
- Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates.
- Bump real-browser-cli to 0.16.2 and lock the new questionary dependency.
- Cover local, remote, validation, and policy-category behavior in tests.
2026-06-18 15:02:18 +02:00
49 changed files with 5889 additions and 460 deletions
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
browser-cli-client-version: browser-cli-client-version:
- "0.9.3" - "0.15.0"
- "0.9.5" - "0.16.0"
steps: steps:
- name: Checkout - name: Checkout
+15 -27
View File
@@ -37,7 +37,6 @@ terminal / python script / remote client
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`. No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format** **Message format**
Every command is a JSON object: Every command is a JSON object:
```json ```json
{ "id": "uuid", "command": "tabs.list", "args": {} } { "id": "uuid", "command": "tabs.list", "args": {} }
@@ -50,7 +49,6 @@ Every response:
--- ---
## Installation ## Installation
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores). browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
@@ -109,7 +107,6 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
--- ---
## Project structure ## Project structure
```text ```text
browser-cli/ browser-cli/
├── browser_cli/ ├── browser_cli/
@@ -145,7 +142,6 @@ browser-cli/
--- ---
## CLI reference ## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`. During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically. If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
@@ -153,7 +149,6 @@ If exactly one browser instance is connected, commands auto-target it. Use `--br
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
### Navigation (`nav`) ### Navigation (`nav`)
```sh ```sh
# Open a URL (no focus stealing by default) # Open a URL (no focus stealing by default)
browser-cli nav open https://example.com browser-cli nav open https://example.com
@@ -175,7 +170,6 @@ browser-cli nav focus github # focuses first tab whose URL contains "
``` ```
### Search ### Search
Each search command opens the search results in your browser using the same flags as `nav open`. Each search command opens the search results in your browser using the same flags as `nav open`.
```sh ```sh
@@ -199,12 +193,12 @@ browser-cli search so click choices
``` ```
### Tabs ### Tabs
```sh ```sh
browser-cli tabs list # list all open tabs (all windows) browser-cli tabs list # list all open tabs (all windows)
browser-cli tabs count # count all tabs browser-cli tabs count # count all tabs
browser-cli tabs count youtube # count tabs matching URL pattern browser-cli tabs count youtube # count tabs whose URL contains "youtube"
browser-cli tabs filter youtube # list tabs matching URL pattern browser-cli tabs filter youtube # list tabs whose URL contains "youtube"
browser-cli tabs filter 'twitch.tv/*' # glob: list every twitch.tv tab
browser-cli tabs query "pull request" # search tabs by URL or title browser-cli tabs query "pull request" # search tabs by URL or title
browser-cli tabs active 1234 # switch browser focus to tab browser-cli tabs active 1234 # switch browser focus to tab
@@ -226,8 +220,11 @@ browser-cli tabs sort --by time
browser-cli tabs merge-windows # pull all tabs into the current window browser-cli tabs merge-windows # pull all tabs into the current window
``` ```
### Tab groups > URL patterns for `tabs filter` / `tabs count` match against the full tab URL.
> A plain string is a case-sensitive substring (`youtube`); a pattern containing
> `*` or `?` is treated as a glob (`twitch.tv/*`, `*.twitch.tv`).
### Tab groups
```sh ```sh
browser-cli groups list # list all tab groups browser-cli groups list # list all tab groups
browser-cli groups count # count groups browser-cli groups count # count groups
@@ -249,7 +246,6 @@ browser-cli groups move 42 -l # short left alias
``` ```
### Windows ### Windows
```sh ```sh
browser-cli windows list # list all windows browser-cli windows list # list all windows
browser-cli windows open # open a new window browser-cli windows open # open a new window
@@ -259,7 +255,6 @@ browser-cli windows close 1 # close a window
``` ```
### DOM ### DOM
These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`. These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`.
```sh ```sh
@@ -272,7 +267,6 @@ browser-cli dom type "#search" "hello" # type text into an input
``` ```
### Extract ### Extract
```sh ```sh
browser-cli extract links # all <a href> links on the page browser-cli extract links # all <a href> links on the page
browser-cli extract images # all <img> tags (src + alt) browser-cli extract images # all <img> tags (src + alt)
@@ -284,7 +278,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
``` ```
### Sessions ### Sessions
A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared. A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared.
```sh ```sh
@@ -298,7 +291,6 @@ browser-cli session auto-save off
``` ```
### Misc ### Misc
```sh ```sh
browser-cli clients # show connected browser info from the registry browser-cli clients # show connected browser info from the registry
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
@@ -309,7 +301,6 @@ browser-cli completion zsh --script # output raw completion script
``` ```
### Remote control, auth, and gateways ### Remote control, auth, and gateways
```sh ```sh
# On the machine with the browser # On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key browser-cli auth keygen --output ~/.config/browser-cli/client.key
@@ -334,22 +325,24 @@ browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
``` ```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint. Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Use `browser-cli remote trust ENDPOINT KEY` to remember a key for later calls. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
#### n8n integration
The n8n community node is published as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli) on npm. It talks directly to a remote `browser-cli serve` endpoint over the same Ed25519-authenticated, ML-KEM-encrypted TCP protocol as the CLI remote client. Install it from n8n's Community Nodes UI, run `browser-cli serve` on the browser machine, paste the client key into the node credential, and drive tabs/DOM/extraction/raw commands from a workflow. See [`n8n-nodes-browser-cli/README.md`](n8n-nodes-browser-cli/README.md).
#### Security model #### Security model
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts. - **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`, `keys`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` when adding a key, or change it later with `auth policy <pubkey|name> …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy.
- **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` (works locally and over `--remote`); `auth keys` shows each key's policy. - **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own.
- **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser. - **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser.
- **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address. - **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address.
- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default), but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there. If you must expose it beyond loopback, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted. - **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use.
For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection. For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection.
--- ---
## Python SDK ## Python SDK
```python ```python
from browser_cli import AsyncBrowserCLI, BrowserCLI from browser_cli import AsyncBrowserCLI, BrowserCLI
@@ -485,7 +478,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
``` ```
**Error handling** **Error handling**
```python ```python
from browser_cli import BrowserCLI, BrowserNotConnected from browser_cli import BrowserCLI, BrowserNotConnected
@@ -517,7 +509,6 @@ if isinstance(counts, BrowserCounts):
--- ---
## Example scripts ## Example scripts
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management. See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
```sh ```sh
@@ -528,7 +519,6 @@ bash examples/demo.sh
--- ---
## Development ## Development
```sh ```sh
npm ci npm ci
npm run check:extension # type-check, build extension bundles, syntax-check bundle npm run check:extension # type-check, build extension bundles, syntax-check bundle
@@ -569,7 +559,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
--- ---
## Limitations ## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`. - **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely. - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
@@ -578,7 +567,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
--- ---
## License ## License
PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE). PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE).
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer. Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
+2
View File
@@ -24,6 +24,7 @@ from browser_cli.auth.keys import (
load_authorized_keys_with_policies, load_authorized_keys_with_policies,
load_private_key, load_private_key,
public_key_hex, public_key_hex,
set_authorized_key_policy,
) )
from browser_cli.auth.pq import ( from browser_cli.auth.pq import (
new_nonce, new_nonce,
@@ -66,6 +67,7 @@ __all__ = [
"pq_kex_server_decapsulate", "pq_kex_server_decapsulate",
"pq_kex_server_keypair", "pq_kex_server_keypair",
"public_key_hex", "public_key_hex",
"set_authorized_key_policy",
"sign", "sign",
"verify", "verify",
] ]
+34
View File
@@ -89,3 +89,37 @@ def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: lis
with open(path, "a", encoding="utf-8") as file: with open(path, "a", encoding="utf-8") as file:
file.write(line) file.write(line)
return True return True
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
"""Update the per-key policy for a trusted key.
``identifier`` may be the full public key or an exact key name. ``categories``
is written as the ``allow:`` token; ``None`` removes the token so the key uses
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
no key matched, and raises ``ValueError`` for ambiguous names.
"""
if not path.exists():
return None
wanted = identifier.strip()
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
matches: list[tuple[int, str, str, str]] = []
for index, line in enumerate(lines):
parsed = _parse_authorized_line(line)
if parsed is None:
continue
pubkey, name, _cats = parsed
if pubkey.lower() == wanted.lower() or (name and name == wanted):
newline = "\n" if line.endswith("\n") else ""
matches.append((index, pubkey, name, newline))
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
index, pubkey, name, newline = matches[0]
lines[index] = format_authorized_line(pubkey, name, categories) + newline
path.write_text("".join(lines), encoding="utf-8")
return pubkey, name
+57
View File
@@ -0,0 +1,57 @@
"""Persistent server identity keys for SSH-style remote host pinning."""
from __future__ import annotations
import json
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
from browser_cli.constants import CONFIG_DIR
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
"""Load the persistent serve identity key, creating it on first start."""
if path.exists():
key = load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"server identity key is not Ed25519: {path}")
return key
path.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
fd = path.open("xb")
try:
fd.write(pem)
finally:
fd.close()
path.chmod(0o600)
return key
def public_key_hex(key: Ed25519PrivateKey) -> str:
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _signed_challenge_fields(challenge: dict) -> dict:
return {key: value for key, value in challenge.items() if key != "server_sig"}
def challenge_payload(challenge: dict) -> bytes:
"""Canonical bytes signed by the server identity key."""
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
return key.sign(challenge_payload(challenge)).hex()
def verify_challenge_signature(challenge: dict) -> bool:
pub_hex = challenge.get("server_pubkey")
sig_hex = challenge.get("server_sig")
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
return True
except (InvalidSignature, ValueError):
return False
-4
View File
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
msg["accept_encoding"] = transport.client_accept_encoding() msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint) key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
private_key = load_private_key(key_spec) private_key = load_private_key(key_spec)
if key is not None:
remote_registry.save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS: if not route_profile and command not in NO_ROUTE_COMMANDS:
@@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil
msg["accept_encoding"] = transport.client_accept_encoding() msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint) key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
private_key = await asyncio.to_thread(load_private_key, key_spec) private_key = await asyncio.to_thread(load_private_key, key_spec)
if key is not None:
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
route_profile = requested_profile route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS: if not route_profile and command not in NO_ROUTE_COMMANDS:
+29
View File
@@ -243,6 +243,35 @@ def collect_browser_clients(
return rows return rows
if remote: if remote:
targets = remote_browser_targets(remote, key=key)
if browser_alias:
targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
if targets:
uncached = []
for target in targets:
cached = _cached_client_row(target)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=remote,
key=key,
profile_group=t.display_group,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key) result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for item in result or []: for item in result or []:
row = dict(item) row = dict(item)
+1
View File
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
KEY_COMMANDS = { KEY_COMMANDS = {
"browser-cli.auth.keys", "browser-cli.auth.keys",
"browser-cli.auth.trust", "browser-cli.auth.trust",
"browser-cli.auth.policy",
} }
@dataclass(frozen=True) @dataclass(frozen=True)
+253 -1
View File
@@ -98,6 +98,88 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control,
else: else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}") console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("policy")
@click.argument("identifier", required=False)
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
@command_policy_options
@click.pass_context
@handle_errors
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Change a trusted key's per-key policy.
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
an interactive terminal to pick a key first, then edit the policy with real
checkbox prompts. Use --safe for an explicit safe-only override,
--server-default to remove the override, or one or more --allow-* flags for
scriptable/non-interactive usage.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
if modes > 1:
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
sys.exit(1)
is_interactive = click.get_text_stream("stdin").isatty()
current_categories = None
if not identifier:
if not is_interactive:
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
sys.exit(1)
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
identifier = entry.get("pubkey") or entry.get("name") or ""
current_categories = entry.get("allow")
elif modes == 0 and is_interactive:
entry = _find_policy_entry(ctx, keys_file, identifier)
current_categories = entry.get("allow") if entry else None
if server_default:
categories = None
elif safe_only:
categories = []
elif explicit_allow:
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
else:
if not is_interactive:
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
sys.exit(1)
categories = _prompt_policy_categories(identifier, current_categories)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.policy",
args={"identifier": identifier, "allow": categories},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
name = (result or {}).get("name") or ""
pubkey = (result or {}).get("pubkey") or identifier
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
try:
updated = set_authorized_key_policy(path, identifier, categories)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
if updated is None:
console.print(f"[red]Trusted key not found:[/red] {identifier}")
sys.exit(1)
pubkey, name = updated
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
console.print(f" File: {path}")
@auth_group.command("show") @auth_group.command("show")
@click.option( @click.option(
"--key", "--key",
@@ -170,11 +252,164 @@ def cmd_auth_keys(ctx, keys_file):
table.add_column("Name") table.add_column("Name")
table.add_column("Public Key") table.add_column("Public Key")
table.add_column("Policy") table.add_column("Policy")
table.add_column("Description")
for entry in entries: for entry in entries:
name = entry.get("name") or "[dim]—[/dim]" name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""), _policy_label(entry.get("allow"))) allow = entry.get("allow")
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
console.print(table) console.print(table)
def _load_policy_entries(ctx, keys_file):
"""Load trusted-key entries for interactive selection."""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
return send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
) or []
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
def _find_policy_entry(ctx, keys_file, identifier: str):
"""Find the current key entry so the checkbox prompt can preselect values."""
wanted = identifier.strip()
for entry in _load_policy_entries(ctx, keys_file):
pubkey = str(entry.get("pubkey") or "")
name = str(entry.get("name") or "")
if pubkey.lower() == wanted.lower() or (name and name == wanted):
return entry
return None
def _prompt_key_entry(entries):
"""Interactive checkbox flow step 1: choose which key to edit."""
if not entries:
raise click.ClickException("no trusted keys found")
import questionary
choices = []
for entry in entries:
name = entry.get("name") or "unnamed key"
pubkey = entry.get("pubkey") or ""
policy = _plain_policy_label(entry.get("allow"))
choices.append(questionary.Choice(
title=f"{name} [{policy}] {pubkey[:12]}{pubkey[-8:]}",
value=entry,
))
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
if selected is None:
raise click.ClickException("cancelled")
return selected
def _prompt_policy_categories(identifier: str, current_categories=None):
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
import questionary
checked = set(current_categories or [])
special_checked = {
"__server_default__": current_categories is None,
"__safe__": current_categories == [],
"__all__": isinstance(current_categories, list) and "all" in current_categories,
}
choices = [
questionary.Choice(
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
value="read-page",
checked="read-page" in checked,
),
questionary.Choice(
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
value="control",
checked="control" in checked,
),
questionary.Choice(
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
value="dangerous",
checked="dangerous" in checked,
),
questionary.Choice(
title="keys — admin access to key management over --remote: auth keys/trust/policy",
value="keys",
checked="keys" in checked,
),
questionary.Separator(),
questionary.Choice(
title="all — allow everything",
value="__all__",
checked=special_checked["__all__"],
),
questionary.Choice(
title="safe — explicit safe-only override",
value="__safe__",
checked=special_checked["__safe__"],
),
questionary.Choice(
title="server default — remove per-key override and inherit server policy",
value="__server_default__",
checked=special_checked["__server_default__"],
),
]
selected = questionary.checkbox(
f"Policy for {identifier}",
choices=choices,
instruction="(space to toggle, enter to save)",
).ask()
if selected is None:
raise click.ClickException("cancelled")
return _parse_checkbox_policy_selection(selected)
def _parse_checkbox_policy_selection(selected):
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
if len(special) > 1 or (special and normal):
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
if special == ["__server_default__"]:
return None
if special == ["__safe__"]:
return []
if special == ["__all__"]:
return ["all"]
return normal
def _parse_policy_selection(raw: str):
value = raw.strip().lower()
if value in {"default", "server-default", "server default", "inherit", "none"}:
return None
if value in {"safe", "safe-only", ""}:
return []
if value == "all":
return ["all"]
number_map = {
"1": "read-page",
"2": "control",
"3": "dangerous",
"4": "keys",
}
valid = {"read-page", "control", "dangerous", "keys"}
categories = []
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
category = number_map.get(token, token)
if category == "all":
return ["all"]
if category not in valid:
raise click.ClickException(f"unknown policy choice: {token}")
if category not in categories:
categories.append(category)
return categories
def _plain_policy_label(categories) -> str:
"""Plain-text policy label for interactive prompt titles."""
if categories is None:
return "server default"
if "all" in categories:
return "all"
return ", ".join(categories) if categories else "safe"
def _policy_label(categories) -> str: def _policy_label(categories) -> str:
"""Render an authorized_keys ``allow:`` token for display.""" """Render an authorized_keys ``allow:`` token for display."""
if categories is None: if categories is None:
@@ -182,3 +417,20 @@ def _policy_label(categories) -> str:
if "all" in categories: if "all" in categories:
return "[yellow]all[/yellow]" return "[yellow]all[/yellow]"
return ", ".join(categories) if categories else "safe" return ", ".join(categories) if categories else "safe"
def _policy_description(categories) -> str:
"""Human-readable explanation for a policy category list."""
if categories is None:
return "Inherits the policy from browser-cli serve"
if "all" in categories:
return "Full access: page reads, browser control, dangerous commands, key admin"
if not categories:
return "Safe status/list commands only"
descriptions = {
"read-page": "read page content",
"control": "control browser/tabs/page input",
"dangerous": "run high-risk commands",
"keys": "manage trusted keys remotely",
}
return "; ".join(descriptions.get(category, category) for category in categories)
+95 -20
View File
@@ -1,18 +1,53 @@
from __future__ import annotations from __future__ import annotations
import json
import click import click
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from browser_cli.errors import BrowserNotConnected
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
console = Console() console = Console()
def _print_remotes() -> None:
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
console.print(table)
def _remove_remote(endpoint: str, *, verb: str) -> None:
if not remove_remote(endpoint):
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
console.print(f"[green]{verb} {endpoint}[/green]")
def _fetch_server_pubkey(endpoint: str) -> str:
from browser_cli.auth.server_identity import verify_challenge_signature
from browser_cli.remote.auth import parse_challenge
from browser_cli.remote.socket import connect_socket, recv_all
sock = connect_socket(endpoint)
try:
challenge, _nonce = parse_challenge(recv_all(sock) or b"")
finally:
sock.close()
if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
raise BrowserNotConnected("remote server did not advertise a server identity key")
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("remote server identity signature is invalid")
return str(challenge["server_pubkey"])
@click.group("remote") @click.group("remote")
def remote_group(): def remote_group():
"""Manage remembered browser-cli remote endpoints.""" """Manage remembered browser-cli remote endpoints."""
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
browser_header="Profile", browser_header="Profile",
) )
@remote_group.command("add")
@click.argument("endpoint")
@click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
def remote_add(endpoint, key_spec):
"""Remember a remote endpoint for global multi-browser commands."""
save_remote(endpoint, key_spec)
if key_spec:
console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
else:
console.print(f"[green]Added remote {endpoint}[/green]")
@remote_group.command("trust") @remote_group.command("trust")
@click.argument("endpoint") @click.argument("endpoint")
@click.argument("key_spec") @click.argument("key_spec")
@@ -50,29 +96,58 @@ def remote_trust(endpoint, key_spec):
save_remote_key(endpoint, key_spec) save_remote_key(endpoint, key_spec)
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]") console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
@remote_group.command("keys") @remote_group.command("list")
def remote_keys(): def remote_list():
"""List remembered remote key specs.""" """List remembered remote endpoints."""
remotes = load_remotes() _print_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]") @remote_group.command("trust-host")
@click.argument("endpoint")
@click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
@handle_errors
def remote_trust_host(endpoint, pubkey):
"""Pin a remote server identity key, SSH known_hosts style."""
server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
save_known_host(endpoint, server_pubkey)
console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
@remote_group.command("known-hosts")
def remote_known_hosts():
"""List pinned remote server identity keys."""
known = load_known_hosts()
if not known:
console.print("[yellow]No known remote server identities[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint") table.add_column("Endpoint")
table.add_column("Key") table.add_column("Fingerprint")
for endpoint, cfg in sorted(remotes.items()): table.add_column("Public Key")
table.add_row(endpoint, str(cfg.get("key", ""))) for endpoint, pubkey in sorted(known.items()):
table.add_row(endpoint, fingerprint(pubkey), pubkey)
console.print(table) console.print(table)
@remote_group.command("untrust-host")
@click.argument("endpoint")
def remote_untrust_host(endpoint):
"""Remove a pinned remote server identity key."""
if not remove_known_host(endpoint):
console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
return
console.print(f"[green]Removed server identity for {endpoint}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
_print_remotes()
@remote_group.command("remove")
@click.argument("endpoint")
def remote_remove(endpoint):
"""Remove a remembered remote endpoint."""
_remove_remote(endpoint, verb="Removed")
@remote_group.command("revoke") @remote_group.command("revoke")
@click.argument("endpoint") @click.argument("endpoint")
def remote_revoke(endpoint): def remote_revoke(endpoint):
"""Remove remembered key/config for ENDPOINT.""" """Remove remembered key/config for ENDPOINT."""
remotes = load_remotes() _remove_remote(endpoint, verb="Revoked")
if endpoint not in remotes:
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
del remotes[endpoint]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
console.print(f"[green]Revoked {endpoint}[/green]")
+43 -13
View File
@@ -11,9 +11,13 @@ from rich.console import Console
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.security import RateLimiter
console = Console() console = Console()
# Hard cap on request body size so a bogus Content-Length can't exhaust memory.
MAX_BODY_BYTES = 8 * 1024 * 1024
def _is_loopback(host: str) -> bool: def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"} return host in {"127.0.0.1", "localhost", "::1"}
@@ -21,6 +25,7 @@ class _Handler(BaseHTTPRequestHandler):
client: BrowserCLI client: BrowserCLI
token: str | None = None token: str | None = None
policy: CommandPolicy = CommandPolicy() policy: CommandPolicy = CommandPolicy()
rate_limiter: RateLimiter | None = None
def _authorized(self) -> bool: def _authorized(self) -> bool:
if self.token is None: if self.token is None:
@@ -37,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
self._send(401, {"error": "missing or invalid token"}) self._send(401, {"error": "missing or invalid token"})
return False return False
def _within_rate_limit(self) -> bool:
if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]):
return True
self._send(429, {"error": "rate limit exceeded; slow down and retry"})
return False
def _send(self, status: int, payload): def _send(self, status: int, payload):
raw = json.dumps(payload, default=str).encode("utf-8") raw = json.dumps(payload, default=str).encode("utf-8")
self.send_response(status) self.send_response(status)
@@ -48,8 +59,11 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
path = urlparse(self.path).path path = urlparse(self.path).path
try: try:
if path != "/health" and not self._require_auth(): if path != "/health":
return if not self._require_auth():
return
if not self._within_rate_limit():
return
if path == "/tabs": if path == "/tabs":
self._send(200, [t.__dict__ for t in self.client.tabs.list()]) self._send(200, [t.__dict__ for t in self.client.tabs.list()])
elif path == "/clients": elif path == "/clients":
@@ -64,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
path = urlparse(self.path).path path = urlparse(self.path).path
try: try:
length = int(self.headers.get("Content-Length", "0")) if path != "/command":
body = json.loads(self.rfile.read(length) or b"{}")
if path == "/command":
if not self._require_auth():
return
command = body.get("command")
assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
else:
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
return
if not self._require_auth():
return
if not self._within_rate_limit():
return
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY_BYTES:
self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"})
return
body = json.loads(self.rfile.read(length) or b"{}")
command = body.get("command")
assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
except PermissionError as exc: except PermissionError as exc:
self._send(403, {"error": str(exc)}) self._send(403, {"error": str(exc)})
except Exception as exc: except Exception as exc:
@@ -90,21 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
@click.option("--key", default=None, help="Remote auth key spec") @click.option("--key", default=None, help="Remote auth key spec")
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)") @click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)") @click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)")
@command_policy_options @command_policy_options
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all): def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command). """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
Auth is enabled by default. Pass the printed token as either Auth is enabled by default. Pass the printed token as either
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``. ``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
This gateway speaks plain HTTP — the token is sent in clear text. Keep it on
loopback, or put a TLS-terminating reverse proxy in front before exposing it.
""" """
if no_auth and not _is_loopback(host): if no_auth and not _is_loopback(host):
raise click.ClickException("--no-auth is only allowed on loopback hosts") raise click.ClickException("--no-auth is only allowed on loopback hosts")
if not _is_loopback(host):
console.print(
"[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the "
"token travels in clear text. Put a TLS-terminating reverse proxy in front, or use "
"[bold]browser-cli serve[/bold] (encrypted) instead."
)
auth_token = None if no_auth else (token or secrets.token_urlsafe(32)) auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all) policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
handler = type( handler = type(
"BrowserCLIHTTPHandler", "BrowserCLIHTTPHandler",
(_Handler,), (_Handler,),
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy}, {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter},
) )
server = ThreadingHTTPServer((host, port), handler) server = ThreadingHTTPServer((host, port), handler)
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]") console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
+13 -27
View File
@@ -3,42 +3,28 @@ Auth-field normalizers — applied to the raw incoming message *before* the
auth check runs. Protocol fields (pubkey, sig, …) are still present here. auth check runs. Protocol fields (pubkey, sig, …) are still present here.
Add one entry per breaking auth-field change: Add one entry per breaking auth-field change:
("X.Y.Z", transformer_fn) ("X.Y.Z", transformer_fn)
Entries must stay in ascending version order. Entries must stay in ascending version order.
The registry is intentionally empty: the first public release was 0.14.1, so no
legacy-client shim has ever been needed. This module is the seam — add a tuple
here (and a unit test for it) the day a breaking auth-field change ships.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Callable from typing import Callable
from browser_cli.version_manager import parse_version from browser_cli.version_manager import parse_version
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
def _auth_0_9_3(msg: dict) -> dict:
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
changed: dict = {}
pk = msg.get("pubkey")
if isinstance(pk, str) and pk:
changed["pubkey"] = pk.lower()
if msg.get("command") == "browser-cli.auth.trust":
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
if isinstance(trust_pk, str) and trust_pk:
changed["args"] = {**args, "pubkey": trust_pk.lower()}
return {**msg, **changed} if changed else msg
# ── registry ────────────────────────────────────────────────────────────────── # ── registry ──────────────────────────────────────────────────────────────────
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [ _AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
("0.9.3", _auth_0_9_3),
]
def adapt_auth(msg: dict, client_version: str) -> dict: def adapt_auth(msg: dict, client_version: str) -> dict:
"""Apply all auth normalizers needed to bring msg up to the current format.""" """Apply all auth normalizers needed to bring msg up to the current format."""
cv = parse_version(client_version) if not _AUTH_COMPAT:
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
return msg return msg
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
return msg
+19 -16
View File
@@ -3,7 +3,7 @@ Command-format shims — applied to clean_msg (protocol fields already stripped)
before forwarding to the native host, and to responses before sending back. before forwarding to the native host, and to responses before sending back.
Add one entry per breaking command-format change: Add one entry per breaking command-format change:
("X.Y.Z", request_fn, response_fn) ("X.Y.Z", request_fn, response_fn)
- request_fn(msg: dict) -> dict or None - request_fn(msg: dict) -> dict or None
- response_fn(resp: bytes, command: str) -> bytes or None - response_fn(resp: bytes, command: str) -> bytes or None
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
Entries must stay in ascending version order. Entries must stay in ascending version order.
adapt_request walks forward (oldest first); adapt_response walks backward. adapt_request walks forward (oldest first); adapt_response walks backward.
Current baseline: 0.9.3 — no command-format shims needed yet. The registry is intentionally empty: no command-format shim has been needed
since the first public release (0.14.1). This module is the seam — add a tuple
here (and a unit test for it) the day a breaking command-format change ships.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Callable from typing import Callable
from browser_cli.version_manager import parse_version from browser_cli.version_manager import parse_version
# ── registry ────────────────────────────────────────────────────────────────── # ── registry ──────────────────────────────────────────────────────────────────
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [ _COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0), # ("1.0.0", _req_1_0_0, _resp_1_0_0),
] ]
def adapt_request(msg: dict, client_version: str) -> dict: def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current browser command format.""" """Upgrade a client message to the current browser command format."""
cv = parse_version(client_version) if not _COMPAT:
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg return msg
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes: def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a native-host response to the format the client expects.""" """Downgrade a native-host response to the format the client expects."""
cv = parse_version(client_version) if not _COMPAT:
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp return resp
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+1 -1
View File
@@ -44,7 +44,7 @@ DEFAULT_TRANSPORT_THRESHOLD = 512
# authenticated connection for multiple commands instead of re-handshaking. # authenticated connection for multiple commands instead of re-handshaking.
REMOTE_SESSION_IDLE_TIMEOUT = 30 REMOTE_SESSION_IDLE_TIMEOUT = 30
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"} NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"] GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
PAGEABLE_COMMANDS = { PAGEABLE_COMMANDS = {
+26 -4
View File
@@ -11,6 +11,13 @@ class _HtmlNode:
self.text = text self.text = text
self.children = [] self.children = []
# Cap how deep the parsed tree may nest. Hostile page content (thousands of
# nested elements) would otherwise blow Python's recursion limit in the
# depth-first render walkers below. Bounding here protects every walker at once.
# 200 levels is far beyond any real document; deeper content is flattened, not
# dropped (its text still reaches the output).
_MAX_TREE_DEPTH = 200
class _HtmlTreeBuilder(HTMLParser): class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"} _VOID_TAGS = {"br", "hr", "img"}
@@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
def handle_starttag(self, tag, attrs): def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs)) node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node) self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS: # Only descend while under the depth cap; beyond it, children of this node
# attach to the current (capped) parent — flattened but preserved.
if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH:
self._stack.append(node) self._stack.append(node)
def handle_startendtag(self, tag, attrs): def handle_startendtag(self, tag, attrs):
@@ -57,6 +66,14 @@ def _collapse_blank_lines(value):
def _escape_markdown(text): def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text) return re.sub(r"([\\`[\]])", r"\\\1", text)
# Schemes that are dangerous if the produced markdown is later rendered as HTML
# by a downstream consumer. The output is plain text here, but neutralising them
# keeps the converter from laundering an XSS payload through to such a consumer.
_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE)
def _safe_url(url):
return "" if _UNSAFE_URL_SCHEME.match(url or "") else url
def _escape_table_cell(text): def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip() return text.replace("|", r"\|").replace("\n", " ").strip()
@@ -86,14 +103,14 @@ def _inline_text(node):
if tag == "br": if tag == "br":
return "\n" return "\n"
if tag == "img": if tag == "img":
src = node.attrs.get("src") or "" src = _safe_url(node.attrs.get("src") or "")
alt = _normalize_text(node.attrs.get("alt") or "") alt = _normalize_text(node.attrs.get("alt") or "")
if not src: if not src:
return "" return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})" return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a": if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children)) text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or "" href = _safe_url(node.attrs.get("href") or "")
return f"[{text or href}]({href})" if href else text return f"[{text or href}]({href})" if href else text
if tag == "code": if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children)) text = _normalize_inline("".join(_inline_text(child) for child in node.children))
@@ -235,5 +252,10 @@ def _block_to_markdown(node):
def convert_html_to_markdown(html, clean_markdown_output): def convert_html_to_markdown(html, clean_markdown_output):
parser = _HtmlTreeBuilder() parser = _HtmlTreeBuilder()
parser.feed(html or "") parser.feed(html or "")
markdown = _block_to_markdown(parser.root) try:
markdown = _block_to_markdown(parser.root)
except RecursionError:
# The depth cap should prevent this, but never let hostile page content
# crash the caller: fall back to a flat, tag-stripped text extraction.
markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or ""))
return clean_markdown_output(markdown) return clean_markdown_output(markdown)
+108
View File
@@ -0,0 +1,108 @@
"""SSH-style known-hosts pinning for browser-cli remote servers."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from browser_cli.constants import CONFIG_DIR
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
def fingerprint(pubkey_hex: str) -> str:
"""Return a compact SHA256 fingerprint for display."""
import base64
import hashlib
digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def load_known_hosts(path: Path | None = None) -> dict[str, str]:
path = path or KNOWN_HOSTS_PATH
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
known[_normalize_endpoint(endpoint)] = pubkey_hex
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
normalized = _normalize_endpoint(endpoint)
if normalized not in known:
return False
del known[normalized]
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
return True
def _is_loopback_endpoint(endpoint: str) -> bool:
host, sep, _port = endpoint.rpartition(":")
check = host if sep else endpoint
return check in {"127.0.0.1", "localhost", "::1"}
def verify_known_host(endpoint: str, challenge: dict | None) -> None:
"""Verify and pin the server identity from a challenge frame.
First contact auto-adds the host when the process is interactive, mirroring
SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
`browser-cli remote trust-host ENDPOINT`.
"""
if not isinstance(challenge, dict):
return
pubkey = challenge.get("server_pubkey")
if not isinstance(pubkey, str) or not pubkey:
return
from browser_cli.auth.server_identity import verify_challenge_signature
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("Remote server identity signature is invalid")
normalized = _normalize_endpoint(endpoint)
known = load_known_hosts()
expected = known.get(normalized)
if expected is None and _is_loopback_endpoint(endpoint):
return
if expected is None:
if not sys.stdin.isatty():
raise BrowserNotConnected(
f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
f"Run: browser-cli remote trust-host {normalized}"
)
sys.stderr.write(
f"The authenticity of remote '{normalized}' can't be established.\n"
f"Server key fingerprint is {fingerprint(pubkey)}.\n"
"Trust this server and add it to known hosts? [y/N] "
)
answer = sys.stdin.readline().strip().lower()
if answer not in {"y", "yes"}:
raise BrowserNotConnected("Remote server identity was not trusted")
save_known_host(normalized, pubkey)
sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
return
if expected != pubkey:
raise BrowserNotConnected(
f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
f"Known: {fingerprint(expected)}\n"
f"Seen: {fingerprint(pubkey)}\n"
f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
)
+28 -7
View File
@@ -28,18 +28,39 @@ def is_valid_key_spec(value: str) -> bool:
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"}) not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
) )
def save_remote_key(endpoint: str, key_spec: str) -> None: def save_remote(endpoint: str, key_spec: str | None = None) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint.""" """Persist a remote endpoint, optionally with a key spec."""
if not endpoint or not key_spec or not is_valid_key_spec(key_spec): if not endpoint:
return return
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes() remotes = load_remotes()
current = remotes.get(endpoint, {}) current = remotes.get(normalized, {})
current["key"] = key_spec if key_spec:
remotes[endpoint] = current if not is_valid_key_spec(key_spec):
return
current["key"] = key_spec
remotes[normalized] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True) REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True)) f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
def remove_remote(endpoint: str) -> bool:
"""Remove a remembered remote endpoint. Returns True when it existed."""
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
if normalized not in remotes:
return False
del remotes[normalized]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
return True
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
save_remote(endpoint, key_spec)
def key_for_remote(endpoint: str | None) -> str | None: def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint: if not endpoint:
+7 -1
View File
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
split_endpoint as _split_endpoint, split_endpoint as _split_endpoint,
) )
from browser_cli.remote import pool as _pool from browser_cli.remote import pool as _pool
from browser_cli.remote.known_hosts import verify_known_host
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None: def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
# Reuse an already-authenticated connection when one is idle for this endpoint. # Reuse an already-authenticated connection when one is idle for this endpoint.
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
sock = _connect_socket(endpoint) sock = _connect_socket(endpoint)
try: try:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth) challenge_raw = _recv_all(sock)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8"))) sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
response = _decode_pq_response(_recv_all(sock), pq_shared_secret) response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
except BaseException: except BaseException:
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
reader, writer = await _open_async_connection(endpoint) reader, writer = await _open_async_connection(endpoint)
try: try:
challenge_raw = await _async_recv_all(reader) challenge_raw = await _async_recv_all(reader)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key): async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
+4
View File
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
if pq_keypair is not None: if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()} challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
server_key = await asyncio.to_thread(load_or_create_server_identity)
challenge_msg["server_pubkey"] = public_key_hex(server_key)
challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
return nonce, pq_private_key, challenge_msg return nonce, pq_private_key, challenge_msg
+47 -9
View File
@@ -54,6 +54,9 @@ class ServeControlMixin:
if self.command == "browser-cli.auth.trust": if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg) return await self._handle_trust(msg)
if self.command == "browser-cli.auth.policy":
return await self._handle_policy(msg)
return False return False
async def _handle_trust(self, msg: dict) -> bool: async def _handle_trust(self, msg: dict) -> bool:
@@ -62,7 +65,6 @@ class ServeControlMixin:
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file") log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True return True
from browser_cli.auth import add_authorized_key from browser_cli.auth import add_authorized_key
from browser_cli.serve.security import policy_from_categories
args = msg.get("args") or {} args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "") pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "") name = str(args.get("name") or "")
@@ -71,18 +73,54 @@ class ServeControlMixin:
await self.send_error("invalid pubkey: expected 64 lowercase hex characters") await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label) log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
return True return True
if not await self._validate_categories(categories):
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
return True
async def _handle_policy(self, msg: dict) -> bool:
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import set_authorized_key_policy
args = msg.get("args") or {}
identifier = str(args.get("identifier") or "")
categories = args.get("allow")
if not identifier.strip():
await self.send_error("missing key identifier")
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
return True
if not await self._validate_categories(categories):
return True
try:
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
return True
if updated is None:
await self.send_error(f"trusted key not found: {identifier}")
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
return True
pubkey, name = updated
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
async def _validate_categories(self, categories) -> bool:
if categories is not None and not isinstance(categories, list):
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return False
if categories is not None: if categories is not None:
if not isinstance(categories, list): from browser_cli.serve.security import policy_from_categories
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return True
try: try:
policy_from_categories(categories) # validate before persisting policy_from_categories(categories) # validate before persisting
except ValueError as exc: except ValueError as exc:
await self.send_error(str(exc)) await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label) log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
return True return False
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
return True return True
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.16.0", "version": "0.16.4",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+22 -2
View File
@@ -4,6 +4,26 @@ import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types'; import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
/** Convert a shell-style glob (`*` = any run, `?` = any single char) to an
* unanchored RegExp. Every other character is matched literally. Unanchored so
* `twitch.tv/*` matches anywhere inside `https://www.twitch.tv/foo`. */
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
return new RegExp(escaped.replace(/\*/g, '.*').replace(/\?/g, '.'));
}
/**
* Match a tab URL against a pattern. Backward-compatible: a pattern with no
* glob metacharacters is a plain case-sensitive substring match (the historic
* behavior); a pattern containing `*` or `?` is treated as a glob, so
* `twitch.tv/*` matches every Twitch tab.
*/
export function urlMatchesPattern(url: string | undefined, pattern: string): boolean {
if (!url || !pattern) return false;
if (/[*?]/.test(pattern)) return globToRegExp(pattern).test(url);
return url.includes(pattern);
}
export class TabsQueryCommands extends CommandGroup { export class TabsQueryCommands extends CommandGroup {
readonly namespace = "tabs"; readonly namespace = "tabs";
readonly commands: Record<string, CommandEntry> = { readonly commands: Record<string, CommandEntry> = {
@@ -50,12 +70,12 @@ export class TabsQueryCommands extends CommandGroup {
private async tabsFilter({ pattern }: TabsPatternArgs) { private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); return all.filter(t => urlMatchesPattern(t.url, pattern)).map(tabInfo);
} }
private async tabsCount({ pattern }: TabsPatternArgs) { private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({}); const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; if (pattern) return all.filter(t => urlMatchesPattern(t.url, pattern)).length;
return all.length; return all.length;
} }
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { urlMatchesPattern } from '../src/commands/tabs-query';
const TWITCH = 'https://www.twitch.tv/somechannel';
test('plain pattern is a case-sensitive substring match (historic behavior)', () => {
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv'), true);
assert.equal(urlMatchesPattern(TWITCH, 'somechannel'), true);
assert.equal(urlMatchesPattern(TWITCH, 'Twitch.tv'), false, 'case-sensitive');
assert.equal(urlMatchesPattern(TWITCH, 'youtube.com'), false);
});
test('glob with /* matches anywhere in the URL', () => {
// The reported case: a glob, not a literal substring.
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://www.twitch.tv/', 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://twitch.tv', 'twitch.tv/*'), false, 'no slash → no match');
});
test('leading wildcard and ? wildcard work', () => {
assert.equal(urlMatchesPattern(TWITCH, '*.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://a.twitch.tv/x', 'https://?.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://ab.twitch.tv/x', 'https://?.twitch.tv/*'), false, '? is one char');
});
test('regex metacharacters in a non-glob pattern stay literal', () => {
assert.equal(urlMatchesPattern('https://x.dev/a.b', 'a.b'), true);
assert.equal(urlMatchesPattern('https://x.dev/axb', 'a.b'), false, 'plain substring is literal — "." is not a regex wildcard');
});
test('regex metacharacters next to a glob are escaped', () => {
// The "." must stay literal even when "*" promotes the pattern to a glob.
assert.equal(urlMatchesPattern('https://x.dev/foo', 'x.dev/*'), true);
assert.equal(urlMatchesPattern('https://xydev/foo', 'x.dev/*'), false, '. does not match y');
});
test('empty url or pattern never matches', () => {
assert.equal(urlMatchesPattern('', 'twitch.tv'), false);
assert.equal(urlMatchesPattern(undefined, 'twitch.tv'), false);
assert.equal(urlMatchesPattern(TWITCH, ''), false);
});
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
test-dist/
*.tsbuildinfo
+124
View File
@@ -0,0 +1,124 @@
# n8n-nodes-browser-cli
An [n8n](https://n8n.io) community node, published on npm as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli), that controls a **real, visible browser**
from your workflows via [browser-cli](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp).
browser-cli drives a running browser through a native-messaging host and a
browser extension — it **cannot be installed inside the n8n container**. So this
node speaks the `browser-cli serve` protocol **directly**: a length-framed TCP
connection authenticated with an Ed25519 key, with request/response bodies
encrypted end-to-end via an ML-KEM-768 (post-quantum) key exchange — the same
wire protocol the `browser-cli --remote` client uses.
```
n8n workflow ──TCP (Ed25519 + ML-KEM-768)──▶ browser-cli serve (remote host) ──▶ browser
```
Because the payloads are end-to-end encrypted, the endpoint is safe to expose on
an untrusted network without a TLS proxy in front of it.
## Remote setup (on the browser machine)
Install browser-cli, register the extension, trust your n8n key, then start
`serve` opening exactly the command tiers you need (it is **safe-only by default**):
```bash
uv tool install real-browser-cli
browser-cli install brave # one-time: register the extension/native host
# On the n8n side, generate a client key and print its public key:
browser-cli auth keygen -o n8n_key.pem
# On the browser machine, trust that public key (optionally scope its policy):
browser-cli auth trust <pubkey-hex> --allow-control
# Expose the browser. Open only what your workflow needs:
browser-cli serve --host 0.0.0.0 --port 8765 \
--authorized-keys ~/.browser_cli/authorized_keys --allow-read-page --allow-control
```
Paste the contents of `n8n_key.pem` into the n8n credential.
## n8n credential — "Browser CLI API"
| Field | Description |
|-------|-------------|
| Host | host of the `serve` endpoint, e.g. `browser-host.example` |
| Port | `serve` TCP port (default `8765`) |
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
| Browser Alias | optional `_route` target — required if the endpoint serves multiple browsers |
| Server Public Key/Fingerprint | pinned `browser-cli serve` identity (`SHA256:...` fingerprint or 64-char server public key hex) |
| Allow Unknown Server Identity | disables SSH-style server pinning; use only for loopback/dev |
| Use TLS | wrap the connection in TLS (only for a TLS-terminating proxy; the protocol is already encrypted) |
| Ignore SSL Issues | when TLS is on, accept a self-signed proxy cert |
### Server identity pinning
Recent `browser-cli serve` versions advertise a persistent Ed25519 server
identity in the challenge frame. The n8n node verifies the challenge signature
and compares the key against the credential's **Server Public Key/Fingerprint**
field, similar to SSH `known_hosts`.
On a trusted machine, pin the server once with the Python CLI and copy the
fingerprint into the n8n credential:
```bash
browser-cli remote trust-host browser-host.example:8765
browser-cli remote known-hosts
```
If the server key changes, the node fails with `REMOTE SERVER IDENTITY CHANGED`.
Only enable **Allow Unknown Server Identity** for local/dev endpoints where you
explicitly do not want pinning.
## Operations
Every operation maps to one raw browser-cli command, each subject to the server
policy tier noted below.
| Resource | Operation | Command | Server flag needed |
|----------|-----------|---------|--------------------|
| Tab | List / Query / Get / Count / Filter / Active in Window | `tabs.list` / `tabs.query` / `tabs.status` / `tabs.count` / `tabs.filter` / `tabs.active_in_window` | safe (default) |
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
| Tab | Open / Close / Activate / Move / Navigate To / Reload / Hard Reload / Back / Forward | `navigate.open` / `tabs.close` / `tabs.active` / `tabs.move` / `navigate.to` / `navigate.reload` / `navigate.hard_reload` / `navigate.back` / `navigate.forward` | `--allow-control` |
| Tab | Mute / Unmute / Pin / Unpin / Dedupe / Sort / Merge Windows | `tabs.mute` / `tabs.unmute` / `tabs.pin` / `tabs.unpin` / `tabs.dedupe` / `tabs.sort` / `tabs.merge_windows` | `--allow-control` |
| Tab | Screenshot | `tabs.screenshot` | `--allow-dangerous` |
| Page | Get Info | `page.info` | safe (default) |
| Page | Extract Text / Links / Images / HTML / Markdown / JSON | `extract.*` | `--allow-read-page` |
| DOM | Query / Text / Attribute / Exists | `dom.query` / `dom.text` / `dom.attr` / `dom.exists` | `--allow-read-page` |
| DOM | Click / Type / Select / Hover / Focus / Check / Uncheck / Clear / Submit / Scroll / Key | `dom.*` | `--allow-control` |
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
| Group | List / Query / Tabs | `group.list` / `group.query` / `group.tabs` | safe (default) |
| Group | Count / Create / Add Tab / Move / Close | `group.count` / `group.open` / `group.add_tab` / `group.move` / `group.close` | `--allow-control` |
| Window | List | `windows.list` | safe (default) |
| Window | Open / Close / Rename | `windows.open` / `windows.close` / `windows.rename` | `--allow-control` |
| Session | List / Save / Load / Remove / Export / Diff / Auto Save | `session.*` | `--allow-control` |
| Storage | Get / Set | `storage.get` / `storage.set` | `--allow-dangerous` |
| Performance | Status | `perf.status` | safe (default) |
| Extension | Info / Capabilities | `extension.info` / `extension.capabilities` | safe (default) |
| Extension | Reload | `extension.reload` | `--allow-control` |
| Client | List | `clients.list` | safe (default) |
| Command | Execute | any command name + JSON args | per command |
**Command → Execute** is the escape hatch: any command string the server policy
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
Use it for anything the typed operations don't cover.
> Note: `serve` returns the **raw** command result (no SDK post-processing).
> `extract.markdown` therefore returns the page payload as the extension hands it
> back, not the CLI's rendered Markdown. For clean text use **Extract Text**.
> **Tab → Filter / Count URL Pattern:** matched against the full tab URL. A plain
> string is a case-sensitive substring (`twitch.tv`); a pattern containing `*` or
> `?` is a glob (`twitch.tv/*`, `*.twitch.tv`). Glob needs the serve-side extension
> at **0.16.4+**; older extensions treat the whole pattern as a literal substring,
> so `twitch.tv/*` matches nothing there — use `twitch.tv` instead.
## Develop / build
```bash
cd n8n-nodes-browser-cli
npm install # add --ignore-scripts if a transitive native dep
# (isolated-vm) fails to compile on your Node version
npm test # pure unit tests: command mapping + crypto known-answer vectors
npm run build # tsc -> dist/, copies the icon
```
Install the published package in n8n as a [community node](https://docs.n8n.io/integrations/community-nodes/installation/) using the package name `n8n-nodes-browser-cli`, or symlink `dist/` into `~/.n8n/custom` for local testing.
## License
PolyForm Noncommercial License 1.0.0 — same as browser-cli.
@@ -0,0 +1,102 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
/**
* Credentials for a raw `browser-cli serve` endpoint.
*
* browser-cli cannot be installed inside n8n, so the node talks directly to a
* `serve` instance running on the machine that drives the browser. Start it
* there and trust this client's key:
*
* browser-cli auth keygen # on the n8n side, prints a PEM
* browser-cli auth trust <pubkey> --allow-control # on the serve side
* browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.browser_cli/authorized_keys
*
* The connection is authenticated with the Ed25519 private key below and the
* request/response bodies are encrypted with an ML-KEM-768 (post-quantum) key
* exchange, so it is safe to expose over an untrusted network without TLS.
* Leave the key empty only for a loopback `serve --no-auth` instance.
*/
export class BrowserCliApi implements ICredentialType {
name = 'browserCliApi';
displayName = 'Browser CLI API';
documentationUrl = 'https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp';
// The serve protocol is raw TCP, not HTTP, so the declarative HTTP test does
// not apply — testing is done by the node method of this name, which runs a
// real authenticated handshake against the endpoint.
testedBy = 'browserCliApiTest';
properties: INodeProperties[] = [
{
displayName: 'Host',
name: 'host',
type: 'string',
default: '127.0.0.1',
placeholder: 'browser-host.example',
required: true,
description: 'Host of the remote `browser-cli serve` endpoint',
},
{
displayName: 'Port',
name: 'port',
type: 'number',
default: 8765,
required: true,
description: 'TCP port the `serve` endpoint listens on',
},
{
displayName: 'Ed25519 Private Key',
name: 'privateKey',
type: 'string',
typeOptions: { password: true, rows: 4 },
default: '',
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
description:
'PKCS8 PEM Ed25519 private key (from `browser-cli auth keygen`) whose public key is trusted by the serve endpoint. Leave empty only for a loopback `serve --no-auth` instance.',
},
{
displayName: 'Browser Alias',
name: 'browser',
type: 'string',
default: '',
placeholder: 'main',
description:
'Optional browser alias to route to (the serve `_route` target). Required when the serve endpoint exposes multiple browser instances; leave empty for a single-browser serve.',
},
{
displayName: 'Server Public Key/Fingerprint',
name: 'serverIdentity',
type: 'string',
default: '',
placeholder: 'SHA256:... or 64-char Ed25519 public key hex',
description:
'Pinned browser-cli serve identity. Get it with `browser-cli remote trust-host ENDPOINT` / `browser-cli remote known-hosts`, then paste the SHA256 fingerprint or raw server public key here. Required for non-loopback endpoints.',
},
{
displayName: 'Allow Unknown Server Identity',
name: 'allowUnknownServerIdentity',
type: 'boolean',
default: false,
description:
'Whether to connect without a pinned server identity. Only use for local development/loopback; disabling pinning weakens SSH-style host verification.',
},
{
displayName: 'Use TLS',
name: 'tls',
type: 'boolean',
default: false,
description:
'Whether to wrap the connection in TLS. Only needed when `serve` sits behind a TLS-terminating proxy; the protocol is already end-to-end encrypted via post-quantum key exchange.',
},
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
default: false,
displayOptions: { show: { tls: [true] } },
description: 'Whether to connect even when the TLS certificate cannot be verified (e.g. a self-signed proxy)',
},
];
}
@@ -0,0 +1,870 @@
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
IExecuteFunctions,
IDataObject,
IDisplayOptions,
INodeCredentialTestResult,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { buildCommand, type CommandParams } from './request';
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
/** Only show a property for the given resource/operation combinations. */
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
return { resource: [resource], operation: operations };
}
export class BrowserCli implements INodeType {
description: INodeTypeDescription = {
displayName: 'Browser CLI',
name: 'browserCli',
icon: 'file:browserCli.svg',
group: ['transform'],
version: 1,
subtitle: '={{({ tab: "Tab", page: "Page", dom: "DOM", group: "Group", window: "Window", session: "Session", storage: "Storage", perf: "Perf", extension: "Extension", client: "Client", command: "Command" }[$parameter["resource"]] || $parameter["resource"]) + ": " + $parameter["operation"]}}',
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
defaults: { name: 'Browser CLI' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,
credentials: [{ name: 'browserCliApi', required: true }],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Tab', value: 'tab' },
{ name: 'Page', value: 'page' },
{ name: 'DOM', value: 'dom' },
{ name: 'Group', value: 'group' },
{ name: 'Window', value: 'window' },
{ name: 'Session', value: 'session' },
{ name: 'Storage', value: 'storage' },
{ name: 'Performance', value: 'perf' },
{ name: 'Extension', value: 'extension' },
{ name: 'Client', value: 'client' },
{ name: 'Command', value: 'command' },
],
default: 'tab',
},
// --- Tab operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['tab'] } },
options: [
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search tabs by text', description: 'tabs.query (safe)' },
{ name: 'Get', value: 'get', action: 'Get a tab status', description: 'tabs.status (safe)' },
{ name: 'Count', value: 'count', action: 'Count open tabs', description: 'tabs.count (safe)' },
{ name: 'Filter', value: 'filter', action: 'Filter tabs by URL pattern', description: 'tabs.filter (safe)' },
{ name: 'Active in Window', value: 'activeInWindow', action: 'Get active tab in a window', description: 'tabs.active_in_window (safe)' },
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
{ name: 'Activate', value: 'activate', action: 'Switch focus to a tab', description: 'tabs.active (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a tab', description: 'tabs.move (needs --allow-control)' },
{ name: 'Navigate To', value: 'navigateTo', action: 'Navigate a tab to a URL', description: 'navigate.to (needs --allow-control)' },
{ name: 'Reload', value: 'reload', action: 'Reload a tab', description: 'navigate.reload (needs --allow-control)' },
{ name: 'Hard Reload', value: 'hardReload', action: 'Hard reload a tab', description: 'navigate.hard_reload (needs --allow-control)' },
{ name: 'Back', value: 'back', action: 'Go back in history', description: 'navigate.back (needs --allow-control)' },
{ name: 'Forward', value: 'forward', action: 'Go forward in history', description: 'navigate.forward (needs --allow-control)' },
{ name: 'Mute', value: 'mute', action: 'Mute a tab', description: 'tabs.mute (needs --allow-control)' },
{ name: 'Unmute', value: 'unmute', action: 'Unmute a tab', description: 'tabs.unmute (needs --allow-control)' },
{ name: 'Pin', value: 'pin', action: 'Pin a tab', description: 'tabs.pin (needs --allow-control)' },
{ name: 'Unpin', value: 'unpin', action: 'Unpin a tab', description: 'tabs.unpin (needs --allow-control)' },
{ name: 'Dedupe', value: 'dedupe', action: 'Close duplicate tabs', description: 'tabs.dedupe (needs --allow-control)' },
{ name: 'Sort', value: 'sort', action: 'Sort tabs within windows', description: 'tabs.sort (needs --allow-control)' },
{ name: 'Merge Windows', value: 'mergeWindows', action: 'Merge all tabs into one window', description: 'tabs.merge_windows (needs --allow-control)' },
{ name: 'Screenshot', value: 'screenshot', action: 'Capture a tab screenshot', description: 'tabs.screenshot (needs --allow-dangerous)' },
],
default: 'list',
},
// --- Page operations --------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['page'] } },
options: [
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
{ name: 'Extract JSON', value: 'extractJson', action: 'Extract JSON-LD / structured data', description: 'extract.json (needs --allow-read-page)' },
],
default: 'extractText',
},
// --- DOM operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['dom'] } },
options: [
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
{ name: 'Text', value: 'text', action: 'Get element text', description: 'dom.text (needs --allow-read-page)' },
{ name: 'Attribute', value: 'attr', action: 'Get an element attribute', description: 'dom.attr (needs --allow-read-page)' },
{ name: 'Exists', value: 'exists', action: 'Check if an element exists', description: 'dom.exists (needs --allow-read-page)' },
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
{ name: 'Select', value: 'select', action: 'Select a dropdown option', description: 'dom.select (needs --allow-control)' },
{ name: 'Hover', value: 'hover', action: 'Hover over an element', description: 'dom.hover (needs --allow-control)' },
{ name: 'Focus', value: 'focus', action: 'Focus an element', description: 'dom.focus (needs --allow-control)' },
{ name: 'Check', value: 'check', action: 'Check a checkbox', description: 'dom.check (needs --allow-control)' },
{ name: 'Uncheck', value: 'uncheck', action: 'Uncheck a checkbox', description: 'dom.uncheck (needs --allow-control)' },
{ name: 'Clear', value: 'clear', action: 'Clear an input', description: 'dom.clear (needs --allow-control)' },
{ name: 'Submit', value: 'submit', action: 'Submit a form', description: 'dom.submit (needs --allow-control)' },
{ name: 'Scroll', value: 'scroll', action: 'Scroll to an element or position', description: 'dom.scroll (needs --allow-control)' },
{ name: 'Key', value: 'key', action: 'Send a keyboard key', description: 'dom.key (needs --allow-control)' },
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
],
default: 'query',
},
// --- Group operations -------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['group'] } },
options: [
{ name: 'List', value: 'list', action: 'List tab groups', description: 'group.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search groups by name', description: 'group.query (safe)' },
{ name: 'Tabs', value: 'tabs', action: 'List tabs in a group', description: 'group.tabs (safe)' },
{ name: 'Count', value: 'count', action: 'Count tab groups', description: 'group.count (needs --allow-control)' },
{ name: 'Create', value: 'create', action: 'Create a tab group', description: 'group.open (needs --allow-control)' },
{ name: 'Add Tab', value: 'addTab', action: 'Add a tab to a group', description: 'group.add_tab (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a group forward/backward', description: 'group.move (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a tab group', description: 'group.close (needs --allow-control)' },
],
default: 'list',
},
// --- Window operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['window'] } },
options: [
{ name: 'List', value: 'list', action: 'List browser windows', description: 'windows.list (safe)' },
{ name: 'Open', value: 'open', action: 'Open a new window', description: 'windows.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a window', description: 'windows.close (needs --allow-control)' },
{ name: 'Rename', value: 'rename', action: 'Rename a window', description: 'windows.rename (needs --allow-control)' },
],
default: 'list',
},
// --- Session operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['session'] } },
options: [
{ name: 'List', value: 'list', action: 'List saved sessions', description: 'session.list (needs --allow-control)' },
{ name: 'Save', value: 'save', action: 'Save the current session', description: 'session.save (needs --allow-control)' },
{ name: 'Load', value: 'load', action: 'Load a saved session', description: 'session.load (needs --allow-control)' },
{ name: 'Remove', value: 'remove', action: 'Delete a saved session', description: 'session.remove (needs --allow-control)' },
{ name: 'Export', value: 'export', action: 'Export a session as JSON', description: 'session.export (needs --allow-control)' },
{ name: 'Diff', value: 'diff', action: 'Diff two sessions', description: 'session.diff (needs --allow-control)' },
{ name: 'Auto Save', value: 'autoSave', action: 'Toggle session auto-save', description: 'session.auto_save (needs --allow-control)' },
],
default: 'list',
},
// --- Storage operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['storage'] } },
options: [
{ name: 'Get', value: 'get', action: 'Read localStorage / sessionStorage', description: 'storage.get (needs --allow-dangerous)' },
{ name: 'Set', value: 'set', action: 'Write localStorage / sessionStorage', description: 'storage.set (needs --allow-dangerous)' },
],
default: 'get',
},
// --- Performance operations -------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['perf'] } },
options: [
{ name: 'Status', value: 'status', action: 'Get performance status', description: 'perf.status (safe)' },
],
default: 'status',
},
// --- Extension operations ---------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['extension'] } },
options: [
{ name: 'Info', value: 'info', action: 'Get extension info', description: 'extension.info (safe)' },
{ name: 'Capabilities', value: 'capabilities', action: 'List extension capabilities', description: 'extension.capabilities (safe)' },
{ name: 'Reload', value: 'reload', action: 'Reload the extension', description: 'extension.reload (needs --allow-control)' },
],
default: 'info',
},
// --- Client operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['client'] } },
options: [
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
],
default: 'list',
},
// --- Command operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['command'] } },
options: [
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
],
default: 'execute',
},
// --- Shared parameter fields -----------------------------------------
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
required: true,
placeholder: 'https://example.com',
displayOptions: { show: showFor('tab', ['open', 'navigateTo']) },
},
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://example.com',
description: 'Optional URL to open. Leave empty for a blank window/tab.',
displayOptions: { show: { resource: ['window', 'group'], operation: ['open', 'addTab'] } },
},
{
displayName: 'Focus Tab',
name: 'focus',
type: 'boolean',
default: false,
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
displayOptions: { show: showFor('tab', ['open']) },
},
{
displayName: 'Close By',
name: 'mode',
type: 'options',
default: 'ids',
options: [
{ name: 'Tab IDs', value: 'ids' },
{ name: 'Inactive Tabs', value: 'inactive' },
{ name: 'Duplicate Tabs', value: 'duplicates' },
],
displayOptions: { show: showFor('tab', ['close']) },
},
{
displayName: 'Tab IDs',
name: 'tabIds',
type: 'string',
default: '',
placeholder: '123, 456',
description: 'Comma/space separated tab IDs, or a JSON array',
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: {
show: {
resource: ['tab', 'dom'],
operation: ['getHtml', 'get', 'reload', 'hardReload', 'back', 'forward', 'mute', 'unmute', 'pin', 'unpin', 'screenshot', 'eval'],
},
},
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
required: true,
description: 'Target tab ID (required — no active-tab fallback)',
displayOptions: { show: showFor('tab', ['activate', 'move', 'navigateTo']) },
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Search',
name: 'search',
type: 'string',
default: '',
required: true,
placeholder: 'github',
description: 'Substring to match against tab/group titles and URLs',
displayOptions: { show: { resource: ['tab', 'group'], operation: ['query'] } },
},
{
displayName: 'URL Pattern',
name: 'pattern',
type: 'string',
default: '',
placeholder: 'twitch.tv/* or twitch.tv',
description: 'Matched against the full tab URL. A plain string is a case-sensitive substring match ("twitch.tv"); a pattern with "*" or "?" is a glob ("twitch.tv/*", "*.twitch.tv"). Glob needs the serve-side extension at 0.16.4+; older extensions treat the whole pattern as a literal substring. Required for Filter; optional for Count (omit to count all).',
displayOptions: { show: showFor('tab', ['filter', 'count']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
required: true,
displayOptions: { show: showFor('tab', ['activeInWindow']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
required: true,
displayOptions: { show: showFor('window', ['close', 'rename']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
description: 'Move the tab to this window. Leave 0 to keep it in the current window.',
displayOptions: { show: showFor('tab', ['move']) },
},
{
displayName: 'Index',
name: 'index',
type: 'number',
default: 0,
description: 'Target position within the window (0-based)',
displayOptions: { show: showFor('tab', ['move']) },
},
{
displayName: 'Format',
name: 'format',
type: 'options',
default: 'png',
options: [
{ name: 'PNG', value: 'png' },
{ name: 'JPEG', value: 'jpeg' },
],
displayOptions: { show: showFor('tab', ['screenshot']) },
},
{
displayName: 'Quality',
name: 'quality',
type: 'number',
default: 80,
description: 'JPEG quality 0-100 (ignored for PNG)',
displayOptions: { show: { resource: ['tab'], operation: ['screenshot'], format: ['jpeg'] } },
},
{
displayName: 'Gentle Mode',
name: 'gentleMode',
type: 'options',
default: 'auto',
description: 'How aggressively to rearrange tabs',
options: [
{ name: 'Auto', value: 'auto' },
{ name: 'On', value: 'on' },
{ name: 'Off', value: 'off' },
],
displayOptions: { show: showFor('tab', ['dedupe', 'sort', 'mergeWindows']) },
},
{
displayName: 'Gentle Mode',
name: 'gentleMode',
type: 'options',
default: 'auto',
options: [
{ name: 'Auto', value: 'auto' },
{ name: 'On', value: 'on' },
{ name: 'Off', value: 'off' },
],
displayOptions: { show: showFor('group', ['close']) },
},
{
displayName: 'Sort By',
name: 'by',
type: 'options',
default: 'domain',
options: [
{ name: 'Domain', value: 'domain' },
{ name: 'Title', value: 'title' },
{ name: 'Time', value: 'time' },
],
displayOptions: { show: showFor('tab', ['sort']) },
},
{
displayName: 'Selector',
name: 'selector',
type: 'string',
default: '',
placeholder: '#main, .content',
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
displayOptions: {
show: {
resource: ['dom', 'page'],
operation: [
'query', 'text', 'attr', 'exists', 'click', 'type', 'select', 'hover', 'focus',
'check', 'uncheck', 'clear', 'submit', 'scroll', 'key',
'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown', 'extractJson',
],
},
},
},
{
displayName: 'Text',
name: 'text',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('dom', ['type']) },
},
{
displayName: 'Attribute',
name: 'attr',
type: 'string',
default: '',
required: true,
placeholder: 'href',
description: 'Attribute name to read from the matched element',
displayOptions: { show: showFor('dom', ['attr']) },
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
required: true,
description: 'Option value to select in the dropdown',
displayOptions: { show: showFor('dom', ['select']) },
},
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
required: true,
placeholder: 'Enter',
description: 'Keyboard key to send, e.g. Enter, Escape, ArrowDown',
displayOptions: { show: showFor('dom', ['key']) },
},
{
displayName: 'X',
name: 'x',
type: 'number',
default: 0,
description: 'Horizontal scroll position (used when no selector is given)',
displayOptions: { show: showFor('dom', ['scroll']) },
},
{
displayName: 'Y',
name: 'y',
type: 'number',
default: 0,
description: 'Vertical scroll position (used when no selector is given)',
displayOptions: { show: showFor('dom', ['scroll']) },
},
{
displayName: 'JavaScript',
name: 'code',
type: 'string',
typeOptions: { rows: 4 },
default: '',
required: true,
placeholder: 'return document.title',
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
displayOptions: { show: showFor('dom', ['eval']) },
},
{
displayName: 'Group ID',
name: 'groupId',
type: 'number',
default: 0,
required: true,
displayOptions: { show: showFor('group', ['tabs', 'close']) },
},
{
displayName: 'Group',
name: 'group',
type: 'string',
default: '',
required: true,
placeholder: 'Research or 12',
description: 'Target group by name or numeric ID',
displayOptions: { show: showFor('group', ['addTab', 'move']) },
},
{
displayName: 'Direction',
name: 'direction',
type: 'options',
default: 'forward',
options: [
{ name: 'Forward', value: 'forward' },
{ name: 'Backward', value: 'backward' },
],
displayOptions: { show: showFor('group', ['move']) },
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name for the group/window/session. Required for all but Export (which dumps the active session when empty).',
displayOptions: {
show: {
resource: ['group', 'window', 'session'],
operation: ['create', 'rename', 'save', 'load', 'remove', 'export'],
},
},
},
{
displayName: 'Session A',
name: 'nameA',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('session', ['diff']) },
},
{
displayName: 'Session B',
name: 'nameB',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('session', ['diff']) },
},
{
displayName: 'Enabled',
name: 'enabled',
type: 'boolean',
default: true,
description: 'Whether to turn session auto-save on',
displayOptions: { show: showFor('session', ['autoSave']) },
},
{
displayName: 'Storage Key',
name: 'key',
type: 'string',
default: '',
description: 'Storage key. Required for Set; on Get, omit to dump all keys.',
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Storage Value',
name: 'value',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('storage', ['set']) },
},
{
displayName: 'Storage Type',
name: 'storeType',
type: 'options',
default: 'local',
options: [
{ name: 'localStorage', value: 'local' },
{ name: 'sessionStorage', value: 'session' },
],
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Command',
name: 'command',
type: 'string',
default: '',
required: true,
placeholder: 'tabs.list',
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
displayOptions: { show: showFor('command', ['execute']) },
},
{
displayName: 'Arguments',
name: 'args',
type: 'json',
default: '{}',
description: 'JSON object of command arguments',
displayOptions: { show: showFor('command', ['execute']) },
},
],
};
methods = {
credentialTest: {
// Runs a real authenticated handshake so "Test" in the credential UI
// reflects the actual serve protocol, not a stand-in HTTP request.
async browserCliApiTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
options.timeoutMs = 10_000;
try {
const response = await sendServeCommand(options, 'clients.list', {});
if (response && response.success === false) {
const message = String(response.error ?? 'serve returned an error');
// A rejected key is a real credential failure; any other server-side
// message means the handshake + auth already succeeded.
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
return { status: 'Error', message };
}
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
}
return { status: 'OK', message: 'Connected to browser-cli serve' };
} catch (error) {
return { status: 'Error', message: (error as Error).message };
}
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const connectOptions = connectOptionsFromCredentials(
(await this.getCredentials('browserCliApi')) as IDataObject,
);
for (let i = 0; i < items.length; i++) {
try {
const resource = this.getNodeParameter('resource', i) as string;
const operation = this.getNodeParameter('operation', i) as string;
const params = collectParams(this, resource, operation, i);
const { command, args } = buildCommand(resource, operation, params);
const response = await sendServeCommand(connectOptions, command, args);
if (response && response.success === false) {
throw new Error(String(response.error || 'serve command failed'));
}
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
const rows = Array.isArray(data) ? data : [data];
for (const row of rows) {
returnData.push({
json: isObject(row) ? (row as IDataObject) : { result: row },
pairedItem: { item: i },
});
}
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
continue;
}
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
}
}
return [returnData];
}
}
function isObject(value: unknown): boolean {
return typeof value === 'object' && value !== null;
}
/** Map decrypted credential fields to serve connection options. */
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
return {
host: String(creds.host || '127.0.0.1'),
port: Number(creds.port || 8765),
tls: Boolean(creds.tls),
rejectUnauthorized: !creds.allowUnauthorizedCerts,
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
route: creds.browser ? String(creds.browser) : null,
serverIdentity: creds.serverIdentity ? String(creds.serverIdentity) : null,
allowUnknownServerIdentity: Boolean(creds.allowUnknownServerIdentity),
};
}
/** Read the UI fields relevant to this operation into a plain params object. */
function collectParams(
ctx: IExecuteFunctions,
resource: string,
operation: string,
i: number,
): CommandParams {
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
const key = `${resource}:${operation}`;
switch (key) {
case 'command:execute': {
const raw = get('args', {});
let args: Record<string, unknown> = {};
if (typeof raw === 'string') {
const trimmed = raw.trim();
args = trimmed ? JSON.parse(trimmed) : {};
} else if (isObject(raw)) {
args = raw as Record<string, unknown>;
}
return { command: get('command'), args };
}
// --- Tabs -------------------------------------------------------------
case 'tab:open':
return { url: get('url'), focus: get('focus', false) };
case 'tab:navigateTo':
return { tabId: get('tabId', 0), url: get('url') };
case 'tab:close':
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
case 'tab:query':
return { search: get('search', '') };
case 'tab:filter':
case 'tab:count':
return { pattern: get('pattern', '') };
case 'tab:activeInWindow':
return { windowId: get('windowId', 0) };
case 'tab:activate':
return { tabId: get('tabId', 0) };
case 'tab:move':
return { tabId: get('tabId', 0), windowId: get('windowId', 0), index: get('index', '') };
case 'tab:get':
case 'tab:getHtml':
case 'tab:reload':
case 'tab:hardReload':
case 'tab:back':
case 'tab:forward':
case 'tab:mute':
case 'tab:unmute':
case 'tab:pin':
case 'tab:unpin':
return { tabId: get('tabId', 0) };
case 'tab:dedupe':
case 'tab:mergeWindows':
return { gentleMode: get('gentleMode', 'auto') };
case 'tab:sort':
return { by: get('by', 'domain'), gentleMode: get('gentleMode', 'auto') };
case 'tab:screenshot':
return { tabId: get('tabId', 0), format: get('format', 'png'), quality: get('quality', '') };
// --- DOM --------------------------------------------------------------
case 'dom:query':
case 'dom:text':
case 'dom:exists':
case 'dom:click':
case 'dom:hover':
case 'dom:focus':
case 'dom:check':
case 'dom:uncheck':
case 'dom:clear':
case 'dom:submit':
return { selector: get('selector', '') };
case 'dom:type':
return { selector: get('selector', ''), text: get('text', '') };
case 'dom:attr':
return { selector: get('selector', ''), attr: get('attr', '') };
case 'dom:select':
return { selector: get('selector', ''), value: get('value', '') };
case 'dom:key':
return { selector: get('selector', ''), key: get('key', '') };
case 'dom:scroll':
return { selector: get('selector', ''), x: get('x', ''), y: get('y', '') };
case 'dom:eval':
return { code: get('code', ''), tabId: get('tabId', 0) };
// --- Page / extraction ------------------------------------------------
case 'page:extractText':
case 'page:extractLinks':
case 'page:extractImages':
case 'page:extractHtml':
case 'page:extractMarkdown':
case 'page:extractJson':
return { selector: get('selector', '') };
// --- Groups -----------------------------------------------------------
case 'group:query':
return { search: get('search', '') };
case 'group:tabs':
return { groupId: get('groupId', 0) };
case 'group:close':
return { groupId: get('groupId', 0), gentleMode: get('gentleMode', 'auto') };
case 'group:create':
return { name: get('name', '') };
case 'group:addTab':
return { group: get('group', ''), url: get('url', '') };
case 'group:move':
return { group: get('group', ''), direction: get('direction', 'forward') };
// --- Windows ----------------------------------------------------------
case 'window:open':
return { url: get('url', '') };
case 'window:close':
return { windowId: get('windowId', 0) };
case 'window:rename':
return { windowId: get('windowId', 0), name: get('name', '') };
// --- Sessions ---------------------------------------------------------
case 'session:save':
case 'session:load':
case 'session:remove':
case 'session:export':
return { name: get('name', '') };
case 'session:diff':
return { nameA: get('nameA', ''), nameB: get('nameB', '') };
case 'session:autoSave':
return { enabled: get('enabled', true) };
// --- Storage ----------------------------------------------------------
case 'storage:get':
return { key: get('key', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
case 'storage:set':
return { key: get('key', ''), value: get('value', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
default:
return {};
}
}
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title>browser-cli icon</title>
<defs>
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f766e" />
<stop offset="1" stop-color="#0f172a" />
</linearGradient>
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f8fafc" />
<stop offset="1" stop-color="#cbd5e1" />
</linearGradient>
</defs>
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,380 @@
/**
* Pure protocol + crypto for talking to a raw `browser-cli serve` endpoint.
*
* This module imports nothing from n8n and only touches Node's `crypto` plus a
* lazily-loaded ML-KEM implementation, so it can be unit-tested with a plain
* esbuild/node toolchain. The socket mechanics live in `serveClient.ts`.
*
* The wire protocol mirrors the Python client in `browser_cli/remote` and
* `browser_cli/auth`:
*
* 1. Server sends a framed `challenge` JSON: {nonce, min_client_version, pq_kex?}.
* 2. Client replies with one framed message. With a private key this is an
* Ed25519 signature over `nonce + sha256(canonical_json(msg))`, optionally
* bound to an ML-KEM-768 shared secret. When the server offers `pq_kex`
* (it always does once authorized_keys is set) the request body is also
* ChaCha20-Poly1305 encrypted under that secret.
* 3. Server replies with one framed payload, encrypted the same way.
*
* Every value the client signs must serialize byte-for-byte like Python's
* `json.dumps(sort_keys=True, separators=(",", ":"))` with `ensure_ascii=True`
* or the signature is rejected see `canonicalJson` and `protocol.test.ts`.
*/
import {
createPrivateKey,
createPublicKey,
createHash,
verify as nodeVerify,
createHmac,
createCipheriv,
createDecipheriv,
randomBytes,
sign as nodeSign,
type KeyObject,
} from 'node:crypto';
export const PQ_KEX_ALG = 'ML-KEM-768';
export const PQ_TRANSPORT_ALG = 'ML-KEM-768+ChaCha20Poly1305';
/** Auth-protocol fields that are never part of the signed canonical payload. */
const AUTH_FIELDS = new Set(['pubkey', 'sig', 'pq_kex', 'encrypted']);
// --- Framing ---------------------------------------------------------------
/** Prefix `payload` with browser-cli's 4-byte little-endian length header. */
export function frame(payload: Buffer): Buffer {
const header = Buffer.allocUnsafe(4);
header.writeUInt32LE(payload.length, 0);
return Buffer.concat([header, payload]);
}
// --- Canonical JSON (matches Python json.dumps sort_keys + ensure_ascii) ----
/** Deterministic JSON string identical to the Python signing canonicalization. */
export function canonicalJson(value: unknown): string {
return encode(value);
}
function encode(value: unknown): string {
if (value === null) return 'null';
const type = typeof value;
if (type === 'string') return encodeString(value as string);
if (type === 'boolean') return value ? 'true' : 'false';
if (type === 'number') {
const n = value as number;
if (!Number.isFinite(n)) throw new Error('cannot encode non-finite number');
// Integers match Python exactly; non-integers are rare in args and fall
// back to JS formatting (documented limitation, see protocol.test.ts).
return Number.isInteger(n) ? String(n) : JSON.stringify(n);
}
if (Array.isArray(value)) return '[' + value.map(encode).join(',') + ']';
if (type === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj)
.filter((key) => obj[key] !== undefined)
.sort();
return '{' + keys.map((key) => encodeString(key) + ':' + encode(obj[key])).join(',') + '}';
}
throw new Error(`cannot encode value of type ${type} in canonical JSON`);
}
/** JSON-encode a string and escape every non-ASCII char as \uXXXX, like Python. */
function encodeString(str: string): string {
return JSON.stringify(str).replace(/[€-￿]/g, (char) =>
'\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'),
);
}
// --- Ed25519 ---------------------------------------------------------------
/**
* Reconstruct a clean PEM from a value mangled by a credential field:
* surrounding whitespace, newlines escaped to literal `\n`, or common with
* single-line/password inputs the internal line breaks stripped entirely so
* the base64 body and the BEGIN/END markers run together.
*/
export function normalizePem(input: string): string {
let pem = (input || '').trim().replace(/\\n/g, '\n');
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+?)-----([\s\S]*?)-----END \1-----/);
if (match) {
const label = match[1].trim();
const body = (match[2].match(/[A-Za-z0-9+/=]+/g) || []).join('');
const wrapped = body.match(/.{1,64}/g) || [];
pem = `-----BEGIN ${label}-----\n${wrapped.join('\n')}\n-----END ${label}-----\n`;
}
return pem;
}
/**
* Load a PKCS8 PEM Ed25519 private key, tolerating the ways credential fields
* mangle multi-line secrets (see {@link normalizePem}).
*/
export function loadPrivateKey(privatePem: string): KeyObject {
const pem = normalizePem(privatePem);
try {
return createPrivateKey(pem);
} catch (err) {
throw new Error(
'Invalid Ed25519 private key: expected a PKCS8 PEM block ' +
'("-----BEGIN PRIVATE KEY-----"), e.g. the file from `browser-cli auth keygen`. ' +
`(${(err as Error).message})`,
);
}
}
/** Raw 32-byte Ed25519 public key (hex) derived from a PKCS8 PEM private key. */
export function ed25519PublicKeyHex(privatePem: string): string {
const publicKey = createPublicKey(loadPrivateKey(privatePem));
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
if (!jwk.x) throw new Error('private key is not an Ed25519 key');
return Buffer.from(jwk.x, 'base64url').toString('hex');
}
/** Bytes signed for auth: nonce + sha256(canonical) [+ sha256(label + secret)]. */
export function authMessage(nonceHex: string, msg: Record<string, unknown>, pqSecret: Buffer | null): Buffer {
const nonce = Buffer.from(nonceHex, 'hex');
const canonical = createHash('sha256').update(canonicalJson(stripAuthFields(msg)), 'utf8').digest();
let data = Buffer.concat([nonce, canonical]);
if (pqSecret) {
const bound = createHash('sha256')
.update(Buffer.concat([Buffer.from('browser-cli ml-kem-768 v1', 'ascii'), pqSecret]))
.digest();
data = Buffer.concat([data, bound]);
}
return data;
}
/** Ed25519 signature (hex) over the canonical auth payload. */
export function signAuth(
privatePem: string,
nonceHex: string,
msg: Record<string, unknown>,
pqSecret: Buffer | null,
): string {
return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex');
}
function stripAuthFields(msg: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(msg)) {
if (!AUTH_FIELDS.has(key)) out[key] = value;
}
return out;
}
// --- ML-KEM-768 transport encryption ---------------------------------------
// Node has no native ML-KEM, so load @noble/post-quantum at runtime. The
// indirection through `Function` keeps a real dynamic `import()` even after
// TypeScript downlevels this module to CommonJS (a plain `import()` would be
// rewritten to `require()` and fail on the ESM-only package).
const importEsm = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
let mlkemPromise: Promise<any> | null = null;
async function mlKem768(): Promise<any> {
if (!mlkemPromise) {
mlkemPromise = importEsm('@noble/post-quantum/ml-kem.js').then((mod) => mod.ml_kem768);
}
return mlkemPromise;
}
/** Encapsulate to the server's ML-KEM public key. Returns (ciphertext hex, secret). */
export async function pqEncapsulate(serverPublicKeyHex: string): Promise<{ ciphertextHex: string; secret: Buffer }> {
const ml = await mlKem768();
const { cipherText, sharedSecret } = ml.encapsulate(Buffer.from(serverPublicKeyHex, 'hex'));
return { ciphertextHex: Buffer.from(cipherText).toString('hex'), secret: Buffer.from(sharedSecret) };
}
/** HKDF-SHA256 with a 32-zero-byte salt (Python `salt=None`) and the given info. */
export function pqTransportKey(secret: Buffer, direction: string): Buffer {
const salt = Buffer.alloc(32, 0);
const prk = createHmac('sha256', salt).update(secret).digest();
const info = Buffer.concat([Buffer.from(`browser-cli pq transport v1 ${direction}`, 'ascii'), Buffer.from([1])]);
return createHmac('sha256', prk).update(info).digest().subarray(0, 32);
}
export interface PqEnvelope {
alg?: string;
nonce: string;
ciphertext: string;
}
/** ChaCha20-Poly1305 encrypt an app-layer frame (ciphertext field is ct||tag). */
export function pqEncrypt(secret: Buffer, direction: string, plaintext: Buffer): PqEnvelope {
const key = pqTransportKey(secret, direction);
const nonce = randomBytes(12);
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
alg: PQ_TRANSPORT_ALG,
nonce: nonce.toString('hex'),
ciphertext: Buffer.concat([body, tag]).toString('hex'),
};
}
/** Inverse of {@link pqEncrypt}. */
export function pqDecrypt(secret: Buffer, direction: string, envelope: PqEnvelope): Buffer {
if (!envelope || envelope.alg !== PQ_TRANSPORT_ALG) {
throw new Error('unsupported encrypted transport envelope');
}
const key = pqTransportKey(secret, direction);
const nonce = Buffer.from(envelope.nonce, 'hex');
const blob = Buffer.from(envelope.ciphertext, 'hex');
const tag = blob.subarray(blob.length - 16);
const body = blob.subarray(0, blob.length - 16);
const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]);
}
// --- Handshake payload + response decoding ---------------------------------
export interface Challenge {
type?: string;
nonce?: string;
min_client_version?: string;
pq_kex?: { alg?: string; public_key?: string };
server_pubkey?: string;
server_sig?: string;
}
export interface AuthPayload {
payload: Record<string, unknown>;
pqSecret: Buffer | null;
}
function pqPublicKey(challenge: Challenge): string | null {
const kex = challenge.pq_kex;
if (kex && kex.alg === PQ_KEX_ALG && kex.public_key) return String(kex.public_key);
return null;
}
function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function ed25519PublicKeyFromHex(pubkeyHex: string): KeyObject {
if (!/^[0-9a-fA-F]{64}$/.test(pubkeyHex)) throw new Error('server public key must be 32-byte hex');
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: base64Url(Buffer.from(pubkeyHex, 'hex')) }, format: 'jwk' });
}
function signedChallenge(challenge: Challenge): Record<string, unknown> {
const { server_sig: _serverSig, ...rest } = challenge;
return rest as Record<string, unknown>;
}
export function serverFingerprint(pubkeyHex: string): string {
return 'SHA256:' + createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('base64').replace(/=+$/g, '');
}
export function verifyServerChallengeSignature(challenge: Challenge): boolean {
const pubkey = challenge.server_pubkey;
const sig = challenge.server_sig;
if (!pubkey || !sig) return false;
try {
return nodeVerify(
null,
Buffer.from(canonicalJson(signedChallenge(challenge)), 'utf8'),
ed25519PublicKeyFromHex(pubkey),
Buffer.from(sig, 'hex'),
);
} catch {
return false;
}
}
export function verifyServerIdentity(
challenge: Challenge,
expectedServerIdentity: string | null | undefined,
endpoint: string,
allowUnknown: boolean,
): void {
const pubkey = challenge.server_pubkey;
const expected = (expectedServerIdentity || '').trim();
if (!pubkey) {
if (expected) throw new Error(`server ${endpoint} did not advertise a server identity key`);
return;
}
if (!verifyServerChallengeSignature(challenge)) {
throw new Error(`server ${endpoint} identity signature is invalid`);
}
const seenFingerprint = serverFingerprint(pubkey);
if (!expected) {
if (allowUnknown) return;
throw new Error(
`Unknown browser-cli server identity for ${endpoint} (${seenFingerprint}). ` +
'Set the expected Server Public Key/Fingerprint in the Browser CLI credential.',
);
}
if (expected.startsWith('SHA256:')) {
if (expected !== seenFingerprint) {
throw new Error(`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${expected}, seen ${seenFingerprint}`);
}
return;
}
const normalizedExpected = expected.toLowerCase();
if (normalizedExpected !== pubkey.toLowerCase()) {
throw new Error(
`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${serverFingerprint(normalizedExpected)}, seen ${seenFingerprint}`,
);
}
}
/**
* Build the single framed message a client sends in response to the challenge.
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
*/
export async function buildAuthPayload(
baseMsg: Record<string, unknown>,
challenge: Challenge,
privatePem: string | null,
): Promise<AuthPayload> {
const nonceHex = challenge.type === 'challenge' ? challenge.nonce : undefined;
if (!nonceHex || !privatePem) {
// No-auth endpoint (loopback `serve --no-auth`): send the bare message.
return { payload: baseMsg, pqSecret: null };
}
const clean = stripAuthFields(baseMsg);
let secret: Buffer | null = null;
const serverPub = pqPublicKey(challenge);
if (serverPub) {
const enc = await pqEncapsulate(serverPub);
secret = enc.secret;
clean.pq_kex = { alg: PQ_KEX_ALG, ciphertext: enc.ciphertextHex };
}
const sig = signAuth(privatePem, nonceHex, clean, secret);
const pubkey = ed25519PublicKeyHex(privatePem);
if (!secret) {
return { payload: { ...clean, pubkey, sig }, pqSecret: null };
}
const encrypted = pqEncrypt(secret, 'request', Buffer.from(JSON.stringify(clean), 'utf8'));
return {
payload: {
id: clean.id,
user_agent: clean.user_agent,
pubkey,
sig,
pq_kex: clean.pq_kex,
encrypted,
},
pqSecret: secret,
};
}
/** Decode a framed server response, decrypting the PQ envelope when present. */
export function decodeResponse(raw: Buffer, pqSecret: Buffer | null): any {
const outer = JSON.parse(raw.toString('utf8'));
if (pqSecret && outer && typeof outer === 'object' && 'encrypted' in outer) {
return JSON.parse(pqDecrypt(pqSecret, 'response', outer.encrypted).toString('utf8'));
}
return outer;
}
@@ -0,0 +1,303 @@
/**
* Pure (resource, operation) -> browser-cli command mapping.
*
* This module imports nothing from n8n so it can be unit-tested with a plain
* esbuild/node toolchain. The node layer collects UI parameters into a plain
* object and asks here for the command + args to run over the `serve` socket
* (see `serveClient.ts`). Every operation maps to one raw extension command;
* what the server returns is the *raw* command result (no SDK-side rendering),
* still subject to the server's --allow-* policy noted per operation.
*
* Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*)
* and the server-side policy in browser_cli/command_security.py. Gating per
* operation is documented in BrowserCli.node.ts next to each operation.
*/
export type CommandParams = Record<string, unknown>;
export interface BrowserCommand {
/** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */
command: string;
/** Argument object forwarded to the command. */
args: Record<string, unknown>;
}
function str(params: CommandParams, key: string): string {
const value = params[key];
return value === undefined || value === null ? '' : String(value);
}
/** Drop keys whose value is undefined/null/"" so we don't send empty args. */
function compact(args: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (value !== undefined && value !== null && value !== '') out[key] = value;
}
return out;
}
/**
* Map a (resource, operation) pair plus collected parameters to a single raw
* browser-cli command. Throws on an unknown pairing so the node fails loudly
* rather than silently issuing a wrong call.
*/
export function buildCommand(
resource: string,
operation: string,
params: CommandParams,
): BrowserCommand {
const key = `${resource}:${operation}`;
switch (key) {
// --- Raw escape hatch -------------------------------------------------
case 'command:execute':
return { command: str(params, 'command'), args: (params.args as Record<string, unknown>) ?? {} };
// --- Tabs -------------------------------------------------------------
case 'tab:list':
return { command: 'tabs.list', args: {} };
case 'tab:query':
return { command: 'tabs.query', args: { search: str(params, 'search') } };
case 'tab:get':
return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:count':
return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) };
case 'tab:filter':
return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } };
case 'tab:activeInWindow':
return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } };
case 'tab:open': {
const focus = Boolean(params.focus);
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
}
case 'tab:close': {
const mode = str(params, 'mode') || 'ids';
if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } };
if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } };
return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } };
}
case 'tab:getHtml':
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:activate':
return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } };
case 'tab:move':
return {
command: 'tabs.move',
args: compact({
tabId: numArg(params.tabId),
windowId: tabIdArg(params.windowId),
index: indexArg(params.index),
}),
};
case 'tab:reload':
return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:hardReload':
return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:back':
return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:forward':
return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:navigateTo':
return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } };
case 'tab:mute':
return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unmute':
return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:pin':
return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unpin':
return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:dedupe':
return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:sort':
return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:mergeWindows':
return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:screenshot':
return {
command: 'tabs.screenshot',
args: compact({
tabId: tabIdArg(params.tabId),
format: str(params, 'format') || 'png',
quality: indexArg(params.quality),
}),
};
// --- Page / extraction ------------------------------------------------
case 'page:info':
return { command: 'page.info', args: {} };
case 'page:extractText':
return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractLinks':
return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractImages':
return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractHtml':
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractMarkdown':
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractJson':
return { command: 'extract.json', args: { selector: str(params, 'selector') } };
// --- DOM --------------------------------------------------------------
case 'dom:query':
return { command: 'dom.query', args: { selector: str(params, 'selector') } };
case 'dom:click':
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
case 'dom:type':
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
case 'dom:attr':
return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } };
case 'dom:text':
return { command: 'dom.text', args: { selector: str(params, 'selector') } };
case 'dom:exists':
return { command: 'dom.exists', args: { selector: str(params, 'selector') } };
case 'dom:scroll':
return {
command: 'dom.scroll',
args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }),
};
case 'dom:select':
return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } };
case 'dom:hover':
return { command: 'dom.hover', args: { selector: str(params, 'selector') } };
case 'dom:check':
return { command: 'dom.check', args: { selector: str(params, 'selector') } };
case 'dom:uncheck':
return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } };
case 'dom:clear':
return { command: 'dom.clear', args: { selector: str(params, 'selector') } };
case 'dom:focus':
return { command: 'dom.focus', args: { selector: str(params, 'selector') } };
case 'dom:submit':
return { command: 'dom.submit', args: { selector: str(params, 'selector') } };
case 'dom:key':
return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) };
case 'dom:eval':
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
// --- Groups -----------------------------------------------------------
case 'group:list':
return { command: 'group.list', args: {} };
case 'group:query':
return { command: 'group.query', args: { search: str(params, 'search') } };
case 'group:tabs':
return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } };
case 'group:count':
return { command: 'group.count', args: {} };
case 'group:create':
return { command: 'group.open', args: { name: str(params, 'name') } };
case 'group:addTab':
return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) };
case 'group:move': {
const direction = str(params, 'direction');
return {
command: 'group.move',
args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' },
};
}
case 'group:close':
return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } };
// --- Windows ----------------------------------------------------------
case 'window:list':
return { command: 'windows.list', args: {} };
case 'window:open':
return { command: 'windows.open', args: compact({ url: str(params, 'url') }) };
case 'window:close':
return { command: 'windows.close', args: { windowId: numArg(params.windowId) } };
case 'window:rename':
return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } };
// --- Sessions ---------------------------------------------------------
case 'session:list':
return { command: 'session.list', args: {} };
case 'session:save':
return { command: 'session.save', args: { name: str(params, 'name') } };
case 'session:load':
return { command: 'session.load', args: { name: str(params, 'name') } };
case 'session:remove':
return { command: 'session.remove', args: { name: str(params, 'name') } };
case 'session:export':
return { command: 'session.export', args: compact({ name: str(params, 'name') }) };
case 'session:diff':
return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } };
case 'session:autoSave':
return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } };
// --- Storage ----------------------------------------------------------
case 'storage:get':
return {
command: 'storage.get',
args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }),
};
case 'storage:set':
return {
command: 'storage.set',
args: compact({
key: str(params, 'key'),
value: str(params, 'value'),
type: str(params, 'storeType') || 'local',
tabId: tabIdArg(params.tabId),
}),
};
// --- Performance ------------------------------------------------------
case 'perf:status':
return { command: 'perf.status', args: {} };
// --- Extension --------------------------------------------------------
case 'extension:info':
return { command: 'extension.info', args: {} };
case 'extension:capabilities':
return { command: 'extension.capabilities', args: {} };
case 'extension:reload':
return { command: 'extension.reload', args: {} };
// --- Clients ----------------------------------------------------------
case 'client:list':
return { command: 'clients.list', args: {} };
default:
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
}
}
/** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */
function tabIdArg(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
/** A required numeric arg; non-finite values fall through as 0. */
function numArg(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */
function indexArg(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
/** Accept an array, a JSON array string, or a comma/space separated list. */
export function parseTabIds(value: unknown): number[] {
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
if (typeof value === 'number') return [value];
const raw = String(value ?? '').trim();
if (!raw) return [];
if (raw.startsWith('[')) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite);
} catch {
/* fall through to split */
}
}
return raw
.split(/[\s,]+/)
.map((part) => Number(part))
.filter(Number.isFinite);
}
@@ -0,0 +1,157 @@
/**
* Socket client for a raw `browser-cli serve` endpoint.
*
* One command per connection: connect, read the challenge frame, send the
* authenticated (and PQ-encrypted) request frame, read the response frame,
* close. The crypto and payload shapes live in `protocol.ts`.
*/
import { connect as netConnect, isIP } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
import type { Socket } from 'node:net';
import { randomUUID } from 'node:crypto';
import { buildAuthPayload, decodeResponse, frame, verifyServerIdentity, type Challenge } from './protocol';
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
* client implements. */
const CLIENT_VERSION = '0.16.0';
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
const MAX_MSG_BYTES = 32 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 30_000;
export interface ServeConnectOptions {
host: string;
port: number;
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
tls?: boolean;
/** Reject self-signed / invalid certs when `tls` is on. */
rejectUnauthorized?: boolean;
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
privateKeyPem?: string | null;
/** Optional `_route` target for a multi-browser serve. */
route?: string | null;
/** Expected server identity: raw Ed25519 public key hex or SHA256 fingerprint. */
serverIdentity?: string | null;
/** Allow unknown server identities (TOFU disabled). Intended for loopback/dev only. */
allowUnknownServerIdentity?: boolean;
timeoutMs?: number;
}
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
class FrameReader {
private buffer = Buffer.alloc(0);
private waiters: Array<(frame: Buffer) => void> = [];
private failed: Error | null = null;
constructor(socket: Socket) {
socket.on('data', (chunk: Buffer) => this.onData(chunk));
}
private onData(chunk: Buffer): void {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.buffer.length >= 4) {
const length = this.buffer.readUInt32LE(0);
if (length > MAX_MSG_BYTES) {
this.fail(new Error(`serve frame too large (${length} bytes)`));
return;
}
if (this.buffer.length < 4 + length) break;
const payload = this.buffer.subarray(4, 4 + length);
this.buffer = this.buffer.subarray(4 + length);
const waiter = this.waiters.shift();
if (waiter) waiter(Buffer.from(payload));
}
}
fail(error: Error): void {
this.failed = error;
}
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
next(): Promise<Buffer> {
if (this.failed) return Promise.reject(this.failed);
return new Promise((resolve) => this.waiters.push(resolve));
}
}
function openSocket(opts: ServeConnectOptions): Socket {
if (opts.tls) {
return tlsConnect({
host: opts.host,
port: opts.port,
rejectUnauthorized: opts.rejectUnauthorized !== false,
// SNI must be a hostname; Node rejects an IP literal as servername.
...(isIP(opts.host) ? {} : { servername: opts.host }),
});
}
return netConnect({ host: opts.host, port: opts.port });
}
/**
* Run a single browser-cli command against a `serve` endpoint and return the
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
*/
export async function sendServeCommand(
opts: ServeConnectOptions,
command: string,
args: Record<string, unknown>,
): Promise<any> {
const socket = openSocket(opts);
socket.setNoDelay(true);
const reader = new FrameReader(socket);
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise<any>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
fn();
};
const timer = setTimeout(
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
timeoutMs,
);
socket.on('error', (err) => finish(() => reject(err)));
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
const run = async () => {
const challengeRaw = await reader.next();
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
verifyServerIdentity(
challenge,
opts.serverIdentity,
`${opts.host}:${opts.port}`,
Boolean(opts.allowUnknownServerIdentity),
);
const baseMsg: Record<string, unknown> = {
id: randomUUID(),
command,
args: args ?? {},
user_agent: USER_AGENT,
accept_encoding: ACCEPT_ENCODING,
};
if (opts.route) baseMsg._route = opts.route;
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
const responseRaw = await reader.next();
const response = decodeResponse(responseRaw, pqSecret);
finish(() => resolve(response));
};
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
run().catch((err) => finish(() => reject(err)));
});
});
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "n8n-nodes-browser-cli",
"version": "0.3.1",
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
"keywords": [
"n8n-community-node-package",
"browser-cli",
"browser",
"automation",
"n8n"
],
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp",
"author": "Daniel Dolezal",
"scripts": {
"build": "tsc && node scripts/copy-assets.mjs",
"dev": "tsc --watch",
"test": "node scripts/run-tests.mjs",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/BrowserCliApi.credentials.js"
],
"nodes": [
"dist/nodes/BrowserCli/BrowserCli.node.js"
]
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.28.0",
"n8n-workflow": "*",
"typescript": "^5.6.0"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"dependencies": {
"@noble/post-quantum": "^0.6.1"
}
}
@@ -0,0 +1,17 @@
// Copy node icons next to their compiled .node.js files. n8n loads the icon
// from a path relative to the node file in dist/, so the SVG must travel along.
import { cp, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const assets = [
['nodes/BrowserCli/browserCli.svg', 'dist/nodes/BrowserCli/browserCli.svg'],
];
for (const [from, to] of assets) {
await mkdir(dirname(join(root, to)), { recursive: true });
await cp(join(root, from), join(root, to));
console.log(`copied ${from} -> ${to}`);
}
@@ -0,0 +1,27 @@
// Build the pure-logic tests to ESM and run them with node --test. request.ts
// and protocol.ts import nothing from n8n, so this needs only esbuild + node.
// node_modules stay external (--packages=external) so the @noble/post-quantum
// dynamic import in protocol.ts resolves at runtime instead of being bundled.
import { spawnSync } from 'node:child_process';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const run = (cmd, args) => spawnSync(cmd, args, { cwd: root, stdio: 'inherit' });
const suites = ['request', 'protocol'];
const build = run('npx', [
'esbuild',
...suites.map((name) => `test/${name}.test.ts`),
'--bundle',
'--format=esm',
'--platform=node',
'--packages=external',
'--outdir=test-dist',
'--out-extension:.js=.mjs',
]);
if (build.status !== 0) process.exit(build.status ?? 1);
const test = run('node', ['--test', ...suites.map((name) => `test-dist/${name}.test.mjs`)]);
process.exit(test.status ?? 1);
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
authMessage,
buildAuthPayload,
canonicalJson,
decodeResponse,
ed25519PublicKeyHex,
frame,
pqDecrypt,
pqEncrypt,
pqTransportKey,
serverFingerprint,
signAuth,
verifyServerChallengeSignature,
verifyServerIdentity,
} from '../nodes/BrowserCli/protocol';
// Known-answer vectors produced by the real Python implementation
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
// Regenerate with the snippet in the PR description if the protocol changes.
const PEM =
'-----BEGIN PRIVATE KEY-----\n' +
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
'-----END PRIVATE KEY-----\n';
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
const MSG = {
id: 'abc-123',
command: 'dom.type',
args: { selector: '#q', text: 'héllo ☃ "x"' },
user_agent: 'browser-cli/0.15.4',
accept_encoding: { ser: ['json'], comp: [] },
};
const CANON =
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
'"user_agent":"browser-cli/0.15.4"}';
const NONCE_HEX = '11'.repeat(32);
const SIG_NOPQ =
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
const SIG_PQ =
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
const DECRYPT_ENV = {
alg: 'ML-KEM-768+ChaCha20Poly1305',
nonce: 'a77e8a72e13f49aab713e0dc',
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
};
const DECRYPT_PLAIN = 'hello world payload';
const SERVER_PUB_HEX = '982c13bda72ef7b2bf4a8cd9756e4f283faaf4a34f9dec8e6c65585b64d9a902';
const SERVER_SIG =
'fa6897bb7f00f711ee8af151384eda736a503008f8b8e11b972cfea46a0366d5' +
'3127c2f1e9ba01e72805b267d023c552756645ae7d95fd00fa277ed20a43ee09';
const SERVER_FP = 'SHA256:wsYDqD4OnF/Sfvr3RKvVCOW8ET802H2qHSvWfnQwQrs';
const SERVER_CHALLENGE = {
type: 'challenge',
nonce: NONCE_HEX,
server_version: '0.16.4',
min_client_version: '0.9.0',
server_pubkey: SERVER_PUB_HEX,
server_sig: SERVER_SIG,
};
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
assert.equal(canonicalJson(MSG), CANON);
});
test('canonicalJson sorts nested keys and omits undefined', () => {
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
});
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
});
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
});
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
// What a single-line/password credential field does to a multi-line PEM.
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
});
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
});
test('signAuth reproduces the Python signature without PQ', () => {
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
});
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
});
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
});
test('pqTransportKey matches Python HKDF for both directions', () => {
const secret = Buffer.from(SECRET_HEX, 'hex');
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
});
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
});
test('pqEncrypt/pqDecrypt round-trip', () => {
const secret = Buffer.from(SECRET_HEX, 'hex');
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
});
test('pqDecrypt rejects an unknown envelope', () => {
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
});
test('frame prepends a 4-byte little-endian length', () => {
const out = frame(Buffer.from('abc'));
assert.equal(out.readUInt32LE(0), 3);
assert.equal(out.subarray(4).toString(), 'abc');
});
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
assert.equal(pqSecret, null);
assert.equal((payload as any).pubkey, PUB_HEX);
assert.equal((payload as any).sig, SIG_NOPQ);
});
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
const base = { ...MSG };
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
assert.equal(pqSecret, null);
assert.equal(payload, base);
});
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
const secret = Buffer.from(SECRET_HEX, 'hex');
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
});
test('server identity fingerprint and signature match Python challenge signing', () => {
assert.equal(serverFingerprint(SERVER_PUB_HEX), SERVER_FP);
assert.equal(verifyServerChallengeSignature(SERVER_CHALLENGE), true);
assert.equal(verifyServerChallengeSignature({ ...SERVER_CHALLENGE, nonce: '22'.repeat(32) }), false);
});
test('verifyServerIdentity accepts pinned pubkey or fingerprint', () => {
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_PUB_HEX, 'browser-host.example:8765', false));
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_FP, 'browser-host.example:8765', false));
});
test('verifyServerIdentity rejects unknown and changed identities', () => {
assert.throws(
() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', false),
/Unknown browser-cli server identity/,
);
assert.throws(
() => verifyServerIdentity(SERVER_CHALLENGE, '00'.repeat(32), 'browser-host.example:8765', false),
/REMOTE SERVER IDENTITY CHANGED/,
);
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', true));
});
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCommand, parseTabIds } from '../nodes/BrowserCli/request';
test('tab:list maps to tabs.list', () => {
assert.deepEqual(buildCommand('tab', 'list', {}), { command: 'tabs.list', args: {} });
});
test('client:list maps to clients.list', () => {
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
});
test('tab:open sends navigate.open with background derived from focus', () => {
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
assert.deepEqual(bg, {
command: 'navigate.open',
args: { url: 'https://example.com', focus: false, background: true },
});
const fg = buildCommand('tab', 'open', { url: 'https://example.com', focus: true });
assert.equal(fg.args.focus, true);
assert.equal(fg.args.background, false, 'focused open sends background:false (matches SDK)');
});
test('tab:close supports ids, inactive, duplicates', () => {
assert.deepEqual(buildCommand('tab', 'close', { mode: 'ids', tabIds: '12, 34' }), {
command: 'tabs.close',
args: { tabIds: [12, 34] },
});
assert.deepEqual(buildCommand('tab', 'close', { mode: 'inactive' }).args, { inactive: true });
assert.deepEqual(buildCommand('tab', 'close', { mode: 'duplicates' }).args, { duplicates: true });
});
test('dom:type sends dom.type with selector + text', () => {
assert.deepEqual(buildCommand('dom', 'type', { selector: '#q', text: 'hello' }), {
command: 'dom.type',
args: { selector: '#q', text: 'hello' },
});
});
test('dom:eval includes tabId only when set', () => {
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 0 }).args, { code: 'return 1' });
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 7 }).args, { code: 'return 1', tabId: 7 });
});
test('page:extractMarkdown omits empty selector', () => {
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: '' }).args, {});
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: 'main' }).args, { selector: 'main' });
});
test('command:execute passes command and args through', () => {
assert.deepEqual(buildCommand('command', 'execute', { command: 'tabs.query', args: { search: 'docs' } }), {
command: 'tabs.query',
args: { search: 'docs' },
});
});
test('tab read ops map to safe commands', () => {
assert.deepEqual(buildCommand('tab', 'query', { search: 'docs' }), { command: 'tabs.query', args: { search: 'docs' } });
assert.deepEqual(buildCommand('tab', 'filter', { pattern: '*.dev/*' }), { command: 'tabs.filter', args: { pattern: '*.dev/*' } });
assert.deepEqual(buildCommand('tab', 'count', { pattern: '' }).args, {}, 'empty pattern is dropped');
assert.deepEqual(buildCommand('tab', 'get', { tabId: 0 }).args, {}, 'tabId 0 means active tab');
assert.deepEqual(buildCommand('tab', 'get', { tabId: 5 }), { command: 'tabs.status', args: { tabId: 5 } });
assert.deepEqual(buildCommand('tab', 'activeInWindow', { windowId: 3 }), {
command: 'tabs.active_in_window',
args: { windowId: 3 },
});
});
test('tab control ops map to navigate/tabs commands', () => {
assert.deepEqual(buildCommand('tab', 'activate', { tabId: 7 }), { command: 'tabs.active', args: { tabId: 7 } });
assert.deepEqual(buildCommand('tab', 'navigateTo', { tabId: 7, url: 'https://x.dev' }), {
command: 'navigate.to',
args: { tabId: 7, url: 'https://x.dev' },
});
assert.deepEqual(buildCommand('tab', 'reload', { tabId: 0 }), { command: 'navigate.reload', args: {} });
assert.deepEqual(buildCommand('tab', 'back', { tabId: 0 }).command, 'navigate.back');
assert.deepEqual(buildCommand('tab', 'mute', { tabId: 2 }), { command: 'tabs.mute', args: { tabId: 2 } });
assert.deepEqual(buildCommand('tab', 'pin', { tabId: 0 }), { command: 'tabs.pin', args: {} });
});
test('tab move keeps index 0 but drops active tabId fallback for window', () => {
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 0, index: 0 }), {
command: 'tabs.move',
args: { tabId: 4, index: 0 },
});
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 9, index: '' }).args, { tabId: 4, windowId: 9 });
});
test('tab rearrange ops default gentleMode and sort key', () => {
assert.deepEqual(buildCommand('tab', 'dedupe', {}), { command: 'tabs.dedupe', args: { gentleMode: 'auto' } });
assert.deepEqual(buildCommand('tab', 'sort', { by: 'title' }), {
command: 'tabs.sort',
args: { by: 'title', gentleMode: 'auto' },
});
assert.deepEqual(buildCommand('tab', 'mergeWindows', {}).command, 'tabs.merge_windows');
});
test('tab screenshot includes quality only when set', () => {
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 0, format: 'png', quality: '' }).args, { format: 'png' });
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 1, format: 'jpeg', quality: 80 }).args, {
tabId: 1,
format: 'jpeg',
quality: 80,
});
});
test('dom interaction ops map to dom.* commands', () => {
assert.deepEqual(buildCommand('dom', 'attr', { selector: 'a', attr: 'href' }), {
command: 'dom.attr',
args: { selector: 'a', attr: 'href' },
});
assert.deepEqual(buildCommand('dom', 'select', { selector: '#s', value: 'v' }), {
command: 'dom.select',
args: { selector: '#s', value: 'v' },
});
assert.deepEqual(buildCommand('dom', 'key', { key: 'Enter', selector: '' }).args, { key: 'Enter' });
assert.deepEqual(buildCommand('dom', 'scroll', { selector: '', x: '', y: 500 }).args, { y: 500 });
assert.deepEqual(buildCommand('dom', 'exists', { selector: '#x' }), { command: 'dom.exists', args: { selector: '#x' } });
});
test('page extractJson sends selector', () => {
assert.deepEqual(buildCommand('page', 'extractJson', { selector: 'script' }), {
command: 'extract.json',
args: { selector: 'script' },
});
});
test('group ops map to group.* commands', () => {
assert.deepEqual(buildCommand('group', 'create', { name: 'Research' }), { command: 'group.open', args: { name: 'Research' } });
assert.deepEqual(buildCommand('group', 'tabs', { groupId: 3 }), { command: 'group.tabs', args: { groupId: 3 } });
assert.deepEqual(buildCommand('group', 'addTab', { group: 'Research', url: '' }).args, { group: 'Research' });
assert.deepEqual(buildCommand('group', 'move', { group: '5', direction: 'backward' }), {
command: 'group.move',
args: { group: '5', forward: false, backward: true },
});
assert.deepEqual(buildCommand('group', 'close', { groupId: 2, gentleMode: 'off' }).args, { groupId: 2, gentleMode: 'off' });
});
test('window ops map to windows.* commands', () => {
assert.deepEqual(buildCommand('window', 'open', { url: '' }), { command: 'windows.open', args: {} });
assert.deepEqual(buildCommand('window', 'rename', { windowId: 1, name: 'Work' }), {
command: 'windows.rename',
args: { windowId: 1, name: 'Work' },
});
});
test('session ops map to session.* commands', () => {
assert.deepEqual(buildCommand('session', 'save', { name: 'morning' }), { command: 'session.save', args: { name: 'morning' } });
assert.deepEqual(buildCommand('session', 'export', { name: '' }), { command: 'session.export', args: {} });
assert.deepEqual(buildCommand('session', 'diff', { nameA: 'a', nameB: 'b' }).args, { nameA: 'a', nameB: 'b' });
assert.deepEqual(buildCommand('session', 'autoSave', { enabled: false }), {
command: 'session.auto_save',
args: { enabled: false },
});
});
test('storage ops default to local and drop active tabId', () => {
assert.deepEqual(buildCommand('storage', 'get', { key: 'token', storeType: 'local', tabId: 0 }), {
command: 'storage.get',
args: { key: 'token', type: 'local' },
});
assert.deepEqual(buildCommand('storage', 'set', { key: 'k', value: 'v', storeType: 'session', tabId: 4 }), {
command: 'storage.set',
args: { key: 'k', value: 'v', type: 'session', tabId: 4 },
});
assert.deepEqual(buildCommand('storage', 'get', { key: '', storeType: 'local', tabId: 0 }).args, { type: 'local' });
});
test('perf and extension ops map to safe/control commands', () => {
assert.deepEqual(buildCommand('perf', 'status', {}), { command: 'perf.status', args: {} });
assert.deepEqual(buildCommand('extension', 'capabilities', {}), { command: 'extension.capabilities', args: {} });
assert.deepEqual(buildCommand('extension', 'reload', {}), { command: 'extension.reload', args: {} });
});
test('unknown operation throws', () => {
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
assert.throws(() => buildCommand('connection', 'health', {}), /Unsupported operation/);
assert.throws(() => buildCommand('gateway', 'health', {}), /Unsupported operation/);
});
test('parseTabIds accepts array, json, and delimited strings', () => {
assert.deepEqual(parseTabIds([1, 2, 3]), [1, 2, 3]);
assert.deepEqual(parseTabIds('[4, 5]'), [4, 5]);
assert.deepEqual(parseTabIds('6, 7 8'), [6, 7, 8]);
assert.deepEqual(parseTabIds(''), []);
assert.deepEqual(parseTabIds(9), [9]);
});
+19
View File
@@ -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"]
}
+107 -107
View File
@@ -13,9 +13,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -30,9 +30,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -47,9 +47,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -64,9 +64,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -81,9 +81,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -98,9 +98,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -115,9 +115,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -132,9 +132,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -149,9 +149,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -166,9 +166,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -183,9 +183,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -200,9 +200,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -217,9 +217,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -234,9 +234,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -251,9 +251,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -268,9 +268,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -285,9 +285,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -302,9 +302,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -319,9 +319,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -336,9 +336,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -353,9 +353,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -370,9 +370,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -387,9 +387,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -404,9 +404,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -421,9 +421,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -438,9 +438,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -497,9 +497,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -510,32 +510,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.0", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.0", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.0", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.0", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.0", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.0", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.0", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.0", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.0", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.0" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.0" version = "0.16.4"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
@@ -11,6 +11,7 @@ dependencies = [
"cryptography>=48", "cryptography>=48",
"rich>=13", "rich>=13",
"msgpack>=1", "msgpack>=1",
"questionary>=2",
] ]
[project.urls] [project.urls]
+53 -8
View File
@@ -461,8 +461,8 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path): def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it.""" """--key is a one-shot override; use `browser-cli remote trust` to remember it."""
import json as _json import json as _json
remotes_path = tmp_path / "remotes.json" remotes_path = tmp_path / "remotes.json"
@@ -491,16 +491,12 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote) monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
# First call with explicit --key agent
send_command("tabs.list", remote="host:8765", key=_Path("agent")) send_command("tabs.list", remote="host:8765", key=_Path("agent"))
assert used_keys[-1] == "agent" assert used_keys[-1] == "agent"
assert key_for_remote("host:8765") is None
# Key must be persisted now
assert key_for_remote("host:8765") == "agent"
# Second call without --key — should reuse saved "agent"
send_command("tabs.list", remote="host:8765") send_command("tabs.list", remote="host:8765")
assert used_keys[-1] == "agent" assert used_keys[-1] is None
# ── async command transport ────────────────────────────────────────────────── # ── async command transport ──────────────────────────────────────────────────
@@ -653,6 +649,55 @@ def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_pat
"extensionVersion": "0.15.6", "extensionVersion": "0.15.6",
}] }]
def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypatch, tmp_path):
"""`browser-cli --remote host clients` should list all profiles, not auto-route and fail as ambiguous."""
from browser_cli.client import collect_browser_clients
import browser_cli.client.core as core
targets = [
BrowserTarget(
profile="main",
display_name="browser-host.example:main",
socket_path="",
remote="browser-host.example:8765",
browser_name="Chrome",
display_group="browser-host.example",
version="149.0.0.0",
extension_version="0.16.4",
),
BrowserTarget(
profile="work",
display_name="browser-host.example:work",
socket_path="",
remote="browser-host.example:8765",
browser_name="Firefox",
display_group="browser-host.example",
version="151.0",
extension_version="0.16.4",
),
]
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
monkeypatch.setattr(core, "send_command", lambda *a, **k: pytest.fail("clients.list must not auto-route for cached targets"))
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
assert [row["profile"] for row in rows] == ["browser-host.example:main", "browser-host.example:work"]
assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
from browser_cli.client import collect_browser_clients
import browser_cli.client.core as core
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", version="1"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", version="1"),
]
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json")
assert [row["profile"] for row in rows] == ["browser-host.example:work"]
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path): def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
"""An older remote (no advertised version) still triggers a clients.list query.""" """An older remote (no advertised version) still triggers a clients.list query."""
from browser_cli.client import collect_browser_clients from browser_cli.client import collect_browser_clients
+48
View File
@@ -0,0 +1,48 @@
"""Compat shim framework.
The registries are empty today (no legacy-client shim has been needed since the
first public release, 0.14.1), so every adapter must be a verbatim pass-through
regardless of client version. These tests lock that in and exercise the
empty-registry short-circuit so the seam can't silently start mutating traffic.
"""
import browser_cli.compat as compat
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
def test_registries_are_empty():
assert compat.commands._COMPAT == []
assert compat.auth._AUTH_COMPAT == []
def test_adapt_auth_is_passthrough_for_any_version():
msg = {"id": "1", "command": "tabs.list", "pubkey": "ABCdef", "args": {"x": 1}}
for version in ("0.9.0", "0.14.1", "0.16.4", "99.0.0"):
out = adapt_auth(msg, version)
assert out == msg
# pubkey casing is NOT normalized anymore (the old <0.9.3 shim is gone)
assert out["pubkey"] == "ABCdef"
def test_adapt_request_is_passthrough():
msg = {"command": "tabs.query", "args": {"search": "docs"}}
assert adapt_request(msg, "0.9.0") == msg
assert adapt_request(msg, "0.16.4") == msg
def test_adapt_response_is_passthrough():
resp = b'{"id":"1","success":true,"data":[]}'
assert adapt_response(resp, "tabs.list", "0.9.0") == resp
assert adapt_response(resp, "tabs.list", "0.16.4") == resp
def test_empty_guard_skips_version_parsing(monkeypatch):
"""With empty registries the adapters return before parse_version runs."""
called = False
def _boom(_v):
nonlocal called
called = True
raise AssertionError("parse_version should not be called on an empty registry")
monkeypatch.setattr(compat.auth, "parse_version", _boom)
monkeypatch.setattr(compat.commands, "parse_version", _boom)
assert adapt_auth({"a": 1}, "0.9.0") == {"a": 1}
assert adapt_request({"a": 1}, "0.9.0") == {"a": 1}
assert adapt_response(b"x", "cmd", "0.9.0") == b"x"
assert called is False
+64
View File
@@ -0,0 +1,64 @@
import json
import pytest
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge, verify_challenge_signature
from browser_cli.errors import BrowserNotConnected
from browser_cli.remote import known_hosts
def _challenge(tmp_path):
key = load_or_create_server_identity(tmp_path / "server.pem")
msg = {
"type": "challenge",
"nonce": "00" * 32,
"server_version": "0.16.4",
"min_client_version": "0.9.0",
"server_pubkey": public_key_hex(key),
}
msg["server_sig"] = sign_challenge(msg, key)
return msg
def test_challenge_signature_verifies(tmp_path):
challenge = _challenge(tmp_path)
assert verify_challenge_signature(challenge) is True
challenge["nonce"] = "11" * 32
assert verify_challenge_signature(challenge) is False
def test_known_host_mismatch_is_rejected(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"browser-host.example": "00" * 32}), encoding="utf-8")
with pytest.raises(BrowserNotConnected, match="REMOTE SERVER IDENTITY CHANGED"):
known_hosts.verify_known_host("browser-host.example", challenge)
def test_unknown_non_interactive_host_is_rejected(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
with pytest.raises(BrowserNotConnected, match="Unknown remote server identity"):
known_hosts.verify_known_host("browser-host.example", challenge)
def test_loopback_unknown_host_is_allowed(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
known_hosts.verify_known_host("127.0.0.1:8765", challenge)
assert not path.exists()
def test_save_and_remove_known_host(tmp_path):
path = tmp_path / "known_hosts.json"
known_hosts.save_known_host("browser-host.example:443", "11" * 32, path)
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": "11" * 32}
assert known_hosts.remove_known_host("browser-host.example", path) is True
assert known_hosts.load_known_hosts(path) == {}
+50
View File
@@ -0,0 +1,50 @@
"""Security/robustness tests for the HTML→Markdown converter on hostile page content."""
from browser_cli.markdown.html import _MAX_TREE_DEPTH, convert_html_to_markdown
from browser_cli.markdown.render import render_markdown
def _identity(markdown):
return markdown
# ── depth-bounded recursion (Finding 4: HIGH/DoS) ─────────────────────────────────
def test_deeply_nested_html_does_not_crash():
"""Thousands of nested elements must not raise RecursionError."""
depth = 5000
html = "<div>" * depth + "deep content" + "</div>" * depth
out = convert_html_to_markdown(html, _identity)
assert "deep content" in out # text preserved despite flattening
def test_deeply_nested_via_render_markdown_entrypoint():
html = "<div>" * 3000 + "x" + "</div>" * 3000
out = render_markdown(html) # routes HTML through the converter
assert "x" in out
def test_nesting_within_cap_is_preserved_structurally():
# A modest list nesting (well under the cap) still renders as a list.
html = "<ul><li>a<ul><li>b</li></ul></li></ul>"
out = convert_html_to_markdown(html, _identity)
assert "- a" in out
assert "b" in out
def test_max_tree_depth_is_sane():
# Cap must be high enough for real documents, low enough to stay under the
# interpreter recursion limit with a few frames per level.
assert 50 <= _MAX_TREE_DEPTH <= 400
# ── unsafe URL schemes (Finding 5: LOW) ───────────────────────────────────────────
def test_javascript_url_in_link_is_neutralised():
# Anchors render their href only in inline context (inside a block like <p>).
out = convert_html_to_markdown('<p><a href="javascript:alert(1)">click</a></p>', _identity)
assert "javascript:" not in out
assert "click" in out # link text kept, dangerous href dropped
def test_data_and_vbscript_urls_dropped():
assert "vbscript:" not in convert_html_to_markdown('<p><a href="vbscript:x">y</a></p>', _identity)
assert "data:" not in convert_html_to_markdown('<img src="data:text/html,<script>">', _identity)
def test_normal_urls_pass_through():
out = convert_html_to_markdown('<p><a href="https://example.com">site</a></p>', _identity)
assert "(https://example.com)" in out
img = convert_html_to_markdown('<img src="https://example.com/x.png" alt="pic">', _identity)
assert "(https://example.com/x.png)" in img
+110
View File
@@ -237,9 +237,77 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)]) result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Policy" in result.output assert "Policy" in result.output
assert "Description" in result.output
assert "read-page" in result.output assert "read-page" in result.output
assert "all" in result.output assert "all" in result.output
assert "server default" in result.output assert "server default" in result.output
assert "read page content" in result.output
assert "Full access" in result.output
def test_auth_policy_updates_existing_key_policy(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "a" * 64
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
result = CliRunner().invoke(main, [
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
])
assert result.exit_code == 0
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
assert "Updated policy" in result.output
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "b" * 64
keys.write_text(f"{pub} laptop allow:all\n")
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
assert safe_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop allow:"
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
assert default_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop"
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
assert result.exit_code == 1
assert "Choose a policy mode" in result.output
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
assert result.exit_code == 1
assert "Choose exactly one policy mode" in result.output
def test_parse_interactive_policy_selection():
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
assert _parse_policy_selection("1,2") == ["read-page", "control"]
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
assert _parse_policy_selection("all") == ["all"]
assert _parse_policy_selection("safe") == []
assert _parse_policy_selection("default") is None
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
assert _parse_checkbox_policy_selection(["__safe__"]) == []
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
def test_auth_policy_without_identifier_requires_interactive_picker():
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
assert result.exit_code == 1
assert "Missing key identifier" in result.output
def test_auth_policy_remote_sends_policy_command():
pub = "c" * 64
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
assert result.exit_code == 0
send.assert_called_once()
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
assert "Updated policy" in result.output
def test_auth_keys_remote_unreachable_clean_error(): def test_auth_keys_remote_unreachable_clean_error():
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback.""" """`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
@@ -289,6 +357,48 @@ def test_serve_http_uses_compare_digest():
assert "compare_digest" in src assert "compare_digest" in src
assert "== f\"Bearer" not in src assert "== f\"Bearer" not in src
def test_serve_http_rate_limiter_blocks_when_exhausted():
"""A burst-1 limiter lets the first request through, then sends 429."""
from browser_cli.commands.serve_http import _Handler
from browser_cli.serve.security import RateLimiter
handler = _Handler.__new__(_Handler)
handler.client_address = ("203.0.113.5", 5000)
handler.rate_limiter = RateLimiter(rate=0.001, burst=1)
sent = []
handler._send = lambda status, payload: sent.append((status, payload))
assert handler._within_rate_limit() is True
assert handler._within_rate_limit() is False
assert sent and sent[-1][0] == 429
def test_serve_http_rate_limiter_none_never_limits():
from browser_cli.commands.serve_http import _Handler
handler = _Handler.__new__(_Handler)
handler.client_address = ("203.0.113.5", 5000)
handler.rate_limiter = None
assert all(handler._within_rate_limit() for _ in range(100))
def test_serve_http_default_rate_limit_active():
from browser_cli.commands.serve_http import _Handler
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
CliRunner().invoke(main, ["serve-http", "--no-auth"])
# The handler class passed to the server carries an active RateLimiter by default.
handler_cls = server_cls.call_args.args[1]
assert handler_cls.rate_limiter is not None
assert handler_cls.rate_limiter.rate == 100.0
def test_serve_http_non_loopback_warns_about_cleartext():
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--token", "x"])
assert "clear text" in result.output
def test_command_policy_allow_all_grants_everything(): def test_command_policy_allow_all_grants_everything():
policy = command_policy_from_options( policy = command_policy_from_options(
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
+42
View File
@@ -0,0 +1,42 @@
import json
from click.testing import CliRunner
from browser_cli.commands.remote import remote_group
from browser_cli.remote import registry as remote_registry
def test_save_remote_persists_endpoint_without_key(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
remote_registry.save_remote("browser-host.example:443")
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": {}}
def test_save_remote_with_key_and_remove(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
remote_registry.save_remote("browser-host.example", "agent")
assert remote_registry.load_remotes() == {"browser-host.example": {"key": "agent"}}
assert remote_registry.remove_remote("browser-host.example:443") is True
assert remote_registry.load_remotes() == {}
def test_remote_add_list_remove_cli(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
runner = CliRunner()
add_result = runner.invoke(remote_group, ["add", "browser-host.example", "--key", "agent"])
list_result = runner.invoke(remote_group, ["list"])
remove_result = runner.invoke(remote_group, ["remove", "browser-host.example"])
assert add_result.exit_code == 0
assert "Added remote browser-host.example with key agent" in add_result.output
assert list_result.exit_code == 0
assert "browser-host.example" in list_result.output
assert "agent" in list_result.output
assert remove_result.exit_code == 0
assert "Removed browser-host.example" in remove_result.output
assert remote_registry.load_remotes() == {}
+42 -27
View File
@@ -228,33 +228,6 @@ class TestAuthSuccess:
client.close() client.close()
t.join(timeout=2) t.join(timeout=2)
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair() # pub is lowercase hex
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
client, server = _pair()
t = _spawn(server, path)
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
# old client sends uppercase pubkey
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert "unauthorized" not in resp.get("error", "").lower()
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch): def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
"""ML-KEM shared secret is decapsulated and bound to the auth signature.""" """ML-KEM shared secret is decapsulated and bound to the auth signature."""
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser) monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
@@ -462,6 +435,48 @@ class TestPerKeyPolicy:
client.close() client.close()
t.join(timeout=2) t.join(timeout=2)
def _trust_and_query_keys(self, tmp_path, monkeypatch, server_policy):
"""Authenticate, then send browser-cli.auth.keys; return the response dict."""
from browser_cli.serve.security import ServeSecurity
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + " mykey\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, server = _pair()
t = _spawn(server, path, ServeSecurity(policy=server_policy))
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "browser-cli.auth.keys", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
client.close()
t.join(timeout=2)
return resp
def test_key_management_blocked_without_keys_grant(self, tmp_path, monkeypatch):
"""Even full control+dangerous can't list keys — the control command is gated."""
from browser_cli.command_security import CommandPolicy
resp = self._trust_and_query_keys(
tmp_path, monkeypatch,
CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True),
)
assert resp["success"] is False
assert "blocked" in resp["error"].lower() and "keys" in resp["error"].lower()
def test_key_management_allowed_with_keys_grant(self, tmp_path, monkeypatch):
"""With allow_keys, the control command runs and returns the trusted-key list."""
from browser_cli.command_security import CommandPolicy
resp = self._trust_and_query_keys(tmp_path, monkeypatch, CommandPolicy(allow_keys=True))
assert resp["success"] is True
assert resp["data"][0]["name"] == "mykey"
class TestRateLimit: class TestRateLimit:
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch): def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
"""A burst-1 limiter shared across connections allows the first command, denies the next.""" """A burst-1 limiter shared across connections allows the first command, denies the next."""
+47 -1
View File
@@ -6,6 +6,7 @@ from browser_cli.auth.keys import (
format_authorized_line, format_authorized_line,
load_authorized_keys_with_names, load_authorized_keys_with_names,
load_authorized_keys_with_policies, load_authorized_keys_with_policies,
set_authorized_key_policy,
) )
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.serve.security import ( from browser_cli.serve.security import (
@@ -42,10 +43,11 @@ def test_key_commands_are_keys_category():
from browser_cli.command_security import command_category from browser_cli.command_security import command_category
assert command_category("browser-cli.auth.keys") == "keys" assert command_category("browser-cli.auth.keys") == "keys"
assert command_category("browser-cli.auth.trust") == "keys" assert command_category("browser-cli.auth.trust") == "keys"
assert command_category("browser-cli.auth.policy") == "keys"
assert command_category("browser-cli.targets") == "safe" # discovery stays open assert command_category("browser-cli.targets") == "safe" # discovery stays open
def test_key_commands_blocked_without_allow_keys(): def test_key_commands_blocked_without_allow_keys():
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"): for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed(cmd, CommandPolicy()) # safe-only default assert_command_allowed(cmd, CommandPolicy()) # safe-only default
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
@@ -57,6 +59,50 @@ def test_full_control_still_cannot_manage_keys():
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed("browser-cli.auth.trust", policy) assert_command_allowed("browser-cli.auth.trust", policy)
# ── set_authorized_key_policy ────────────────────────────────────────────────────
def test_set_policy_updates_by_pubkey(tmp_path):
path = tmp_path / "authorized_keys"
pub = "a" * 64
path.write_text(f"{pub} laptop\n")
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
def test_set_policy_by_name_and_remove_with_none(tmp_path):
path = tmp_path / "authorized_keys"
pub = "b" * 64
path.write_text(f"{pub} ci-bot allow:all\n")
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
def test_set_policy_safe_only_writes_empty_token(tmp_path):
path = tmp_path / "authorized_keys"
pub = "c" * 64
path.write_text(f"{pub} reader\n")
set_authorized_key_policy(path, pub, [])
assert path.read_text().strip() == f"{pub} reader allow:"
def test_set_policy_not_found_returns_none(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} laptop\n")
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
def test_set_policy_ambiguous_name_raises(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
with pytest.raises(ValueError, match="ambiguous"):
set_authorized_key_policy(path, "dup", ["control"])
def test_set_policy_preserves_other_lines(tmp_path):
path = tmp_path / "authorized_keys"
a, b = "a" * 64, "b" * 64
path.write_text(f"{a} first\n{b} second allow:read-page\n")
set_authorized_key_policy(path, a, ["control"])
assert load_authorized_keys_with_policies(path) == [
(a, "first", ["control"]),
(b, "second", ["read-page"]), # untouched
]
# ── authorized_keys line parsing ───────────────────────────────────────────────── # ── authorized_keys line parsing ─────────────────────────────────────────────────
def test_parse_line_pubkey_only(): def test_parse_line_pubkey_only():
Generated
+201 -181
View File
@@ -86,14 +86,14 @@ wheels = [
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.1" version = "8.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
] ]
[[package]] [[package]]
@@ -107,115 +107,100 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.14.1" version = "7.14.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" },
{ url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" },
{ url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" },
{ url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" },
{ url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" },
{ url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" },
{ url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" },
{ url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" },
{ url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" },
{ url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" },
{ url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" },
{ url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" },
{ url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" },
{ url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" },
{ url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" },
{ url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" },
{ url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" },
{ url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" },
{ url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" },
{ url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" },
{ url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" },
{ url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" },
{ url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" },
{ url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" },
{ url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" },
{ url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" },
{ url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" },
{ url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" },
{ url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" },
{ url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" },
{ url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" },
{ url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" },
{ url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" },
{ url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" },
{ url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" },
{ url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" },
{ url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" },
{ url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
{ url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
{ url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
{ url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
{ url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
{ url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
{ url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
{ url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -324,75 +309,75 @@ wheels = [
[[package]] [[package]]
name = "msgpack" name = "msgpack"
version = "1.2.0" version = "1.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/23/6139781ca7aadf656fa8e384fa84693ffb13f299e6931b6526427fe5e297/msgpack-1.2.0.tar.gz", hash = "sha256:8e17af38197bf58e7e819041678f6178f4491493f5b8c8580414f40f7c2c3c41", size = 183017, upload-time = "2026-06-11T04:16:10.775Z" } sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/52/fed22bca455ff3ed28c0ee0d1117398b7cb3ce440270050e85b09240fa8d/msgpack-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ed8c9495a0f12d17a2b4b69e23f895b88f26aabe40911c86594d3fbddecfff08", size = 82473, upload-time = "2026-06-11T04:14:38.484Z" }, { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
{ url = "https://files.pythonhosted.org/packages/3b/09/0b54d386024a9fa2073135212c11d1e83b059d98459d943d5a82ba9dcdc9/msgpack-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7384859c90b45a28a4b31aa50b49cca84504c9f27df459cea6e072627650dcb", size = 82150, upload-time = "2026-06-11T04:14:39.985Z" }, { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/44/ba/c6310a6f37e9bf9279b492640ec425e6f6e68a94e4cac4782ab518b05d64/msgpack-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63b35e8e65f04ff7ad5c9c70885da587c74f51e4b4eb3db624eac6d250e8cf59", size = 398355, upload-time = "2026-06-11T04:14:41.493Z" }, { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1b/f4bad0e9dea608b14d36065c44e347e4b10c0392f92cca441496cc0598ef/msgpack-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004c5a02acd3eca4e15e1ae7b461c32e3711105a28b1ad78be2f6facff4c523", size = 405162, upload-time = "2026-06-11T04:14:42.957Z" }, { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
{ url = "https://files.pythonhosted.org/packages/63/34/4653bc7f426bd6ce9803f75133aa362232639e5adb8c6b99550107c71ed5/msgpack-1.2.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e2032dacb0a973fcbf7bd088415a369dae31c5af40e199d234806be22e86765", size = 372720, upload-time = "2026-06-11T04:14:44.532Z" }, { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
{ url = "https://files.pythonhosted.org/packages/13/3c/8c607e10db2225af52107ffa918280483248363819fecb4437a35a1f4ae2/msgpack-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c1feb100651fbe4b39826207cb20af065dfbfbfa43b1bafd7eaa2252abf7acfd", size = 390946, upload-time = "2026-06-11T04:14:46.054Z" }, { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
{ url = "https://files.pythonhosted.org/packages/96/05/c4cb5fb30569cff4b4c7be4574adddb0faf7faaf3049bbab000b6f07da5b/msgpack-1.2.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:82487709d4c597d252311a65370220675fb1cc859e7da9269a3060c03ac02cf6", size = 374062, upload-time = "2026-06-11T04:14:47.817Z" }, { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
{ url = "https://files.pythonhosted.org/packages/40/d7/b51b11e58277e6b678ba5a2f6608f88fdb0778973391a39d7f1a385f5bde/msgpack-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0268c67a74f5f913f545a0fdbbfaa3f6ebcf23b4c3209bb99704a2ea87e13f90", size = 405458, upload-time = "2026-06-11T04:14:49.618Z" }, { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/9eca2961be302a6fc77a3fcb15faec749e325c9f0a8fe9c4c4576fc2cad5/msgpack-1.2.0-cp310-cp310-win32.whl", hash = "sha256:7df87173b0e13ddd134919731f13525dbbf75204145597decf1cb86887ebb492", size = 64010, upload-time = "2026-06-11T04:14:51.071Z" }, { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e3/55b14ae13ed056ed35364ff71144c6a12af25227c20093045a945d08273a/msgpack-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:6371edb47788fbfd8a22016f9a97b5616dd9849bc50abcbb8e82d38f71efa096", size = 69863, upload-time = "2026-06-11T04:14:52.376Z" }, { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
{ url = "https://files.pythonhosted.org/packages/ee/23/35de3182a647fcc84ab304160169edfa5dac7bbd8913fbed0a505ddc0d55/msgpack-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec35cd3f127f50806aa10c3f74bf27b749f13ddf1d2217964ada8f38042d1653", size = 82368, upload-time = "2026-06-11T04:14:53.57Z" }, { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
{ url = "https://files.pythonhosted.org/packages/aa/79/8d9bfdab933b1c7a02aba9518605a81aa30d38e9efd4915ec1a6b2d55778/msgpack-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:317eb298297121bfad9173d748124a04a36af27b6ac39c2bbc1db1ce57608dcf", size = 82095, upload-time = "2026-06-11T04:14:54.784Z" }, { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e1/b5accbc1354edbcee107fb35ec247db0547e91c3f90e4fabdeaee500a5a6/msgpack-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fe6434de89073273026dd032a62e8b63f8857a261d7a2df5b07c9e72f3a8f7", size = 413818, upload-time = "2026-06-11T04:14:56.1Z" }, { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
{ url = "https://files.pythonhosted.org/packages/82/31/1141cbbf7118d525834f20dcd614d1b85f1f2ffd33bc2a5ce710e6dd2516/msgpack-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106c6d333ff3d4eda075b7d4b9695d1752c5bcc635e40d0dbaf4e276c9ed80e1", size = 423790, upload-time = "2026-06-11T04:14:57.509Z" }, { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
{ url = "https://files.pythonhosted.org/packages/04/e7/9582f2bd4d7546139fe297740de49bd1f7ef2d195eb0bb9fa5efeee88158/msgpack-1.2.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:67055a611e871cb1bd0acb732f2e9f64ca8155ca0bba1d0a5bb362e7209e5541", size = 387521, upload-time = "2026-06-11T04:14:59.08Z" }, { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/5aadd08ff068bfd42e2ac0be6a20aa9819965df8622e87c1f0c6119c1c22/msgpack-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceec7f8e633d5a4b4a32b0416bef90ee3cd1017ea36247f705e523072e576119", size = 406324, upload-time = "2026-06-11T04:15:00.686Z" }, { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
{ url = "https://files.pythonhosted.org/packages/39/ee/3041564f0cc4c2fe7c53315aec0edf3d84807fc9b9ea714e6ac07dbdb1db/msgpack-1.2.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7ec5851160a3c2c0f77d68ddec620318cd8e7d88d94f9c058190e8ce0dfa1d31", size = 384242, upload-time = "2026-06-11T04:15:02.121Z" }, { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d4/de94b3dbc266229f4c2ce84485eeb221220351b7f1931029e875995bb232/msgpack-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd7140f7b09dbe1984a0dff3189375d840247e3e4cf4ac45c5a499b3b599c8d2", size = 420392, upload-time = "2026-06-11T04:15:03.692Z" }, { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
{ url = "https://files.pythonhosted.org/packages/f7/5d/c4a3fde69a292eecb202caaa87c29df7728644a65118614b821bcaddc05a/msgpack-1.2.0-cp311-cp311-win32.whl", hash = "sha256:cbfd54018d386da0951c7a2be13de0f58559d251313e613b2155e52ed1cbd8f1", size = 63976, upload-time = "2026-06-11T04:15:05.355Z" }, { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
{ url = "https://files.pythonhosted.org/packages/18/fa/df47f83115375e7717c985265a30f3ba096c5331518e28fb647b55c46d31/msgpack-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:653373c4614c31463ba486a67776e4bb396af289921bd5353e209534b71467fa", size = 70273, upload-time = "2026-06-11T04:15:06.529Z" }, { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
{ url = "https://files.pythonhosted.org/packages/54/d1/ffd02e54c064aa73b6b53aa08171f92dc406727077ff275d7050c6aca28a/msgpack-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:7a260aea1e5e7d6c7f1d9284c7360d29021627b61dc4dd7df144b81210810537", size = 64783, upload-time = "2026-06-11T04:15:07.677Z" }, { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
{ url = "https://files.pythonhosted.org/packages/44/07/dcb13f37e670257c8d0e944f116c799c34ac6968ecb48c83619f7e91d8b5/msgpack-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2d6047ccd11a12c96a69f2bfe026471abef67334c3d0494a93e5310e45140a2", size = 82888, upload-time = "2026-06-11T04:15:08.992Z" }, { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
{ url = "https://files.pythonhosted.org/packages/84/5f/6643b2a6a36ca4bc73c7674831be1d4d581cceecc7eb019dba1915951739/msgpack-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0347e3ac0dfee99086d3b68fe959da3f5f657c0019ddbaeaaa259a85f8603422", size = 82223, upload-time = "2026-06-11T04:15:10.182Z" }, { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c8/9e1668b9897358e5ab39a18142e38be3cf15807e643757782da9f4a53cb3/msgpack-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25552ff1f2ff3dc8333e27eabb94f702da5929ed0e07969688194a3e9f12e151", size = 409700, upload-time = "2026-06-11T04:15:11.441Z" }, { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
{ url = "https://files.pythonhosted.org/packages/38/ed/b7728573156d70b6b094233b0f38d876fc37340826cf852347ec2c7ca8ca/msgpack-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0d94420d9d52c56568159a69200af7e45eadb29615fa9d09fada140de1c38c7", size = 420090, upload-time = "2026-06-11T04:15:12.868Z" }, { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f7/5ea755a89868c04f9cdf6d96d2d99da4b3d198af10e76a6082dd0fceccc0/msgpack-1.2.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d16e1f2db4a9eebc07b7cc91898d71e710f2eed8358711a605fee802caff8923", size = 378538, upload-time = "2026-06-11T04:15:14.511Z" }, { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/126e59332a439c94ffd682c38ca0102b23480e2784b3dac48d8959b0bbac/msgpack-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9cb2e700e85f1e27bbb5c9de6cc1c9a4bc5ac64d5404bdcbcb37a0dc7a947a3", size = 399468, upload-time = "2026-06-11T04:15:16.133Z" }, { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/da/f9/7abcef683a0ad2e5ab3a4940344aad9f20cdf1f42057ecb0982cf55085d6/msgpack-1.2.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:717d0b166dd176a5f786aeafff081f6439680acf5af193eb63e6266c12b04d3d", size = 374212, upload-time = "2026-06-11T04:15:17.536Z" }, { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
{ url = "https://files.pythonhosted.org/packages/27/23/2d62cf0e971678e96f8a3cfa9bd77fb719ddb98da73790f63c53fd847ad8/msgpack-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e87c7a21654d18111eb1a89bd5c42baba42e61887365d9e89585e112b4203f9e", size = 414361, upload-time = "2026-06-11T04:15:18.99Z" }, { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
{ url = "https://files.pythonhosted.org/packages/32/fb/f5c153f614037aaf802d291a4653ba1bb731f56feacba886f7c21c109e56/msgpack-1.2.0-cp312-cp312-win32.whl", hash = "sha256:967e0c891f5f23ab65762f2e5dc95922759c79f1ef99ef4c7e1fdd863e0d0af9", size = 64389, upload-time = "2026-06-11T04:15:20.237Z" }, { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
{ url = "https://files.pythonhosted.org/packages/90/af/8aafce6e5544b43b84cb670aca40c8bea7eb5ae8f42bfcbdc7098739987a/msgpack-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c23e33cee28dcffa112ae205661da4636fd7b06bd9ad1559a890623b92d060b", size = 71185, upload-time = "2026-06-11T04:15:21.51Z" }, { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/ba/08/9cc94be1fc1fe3d1379d439326259aef0344274f64623a8138feb54dff68/msgpack-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:6eeb771571f63f68045433b1a35c0256b946f31ed62f006997e40b8ad8b735af", size = 64481, upload-time = "2026-06-11T04:15:22.639Z" }, { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/7d/26/2902c6946ab5c8fe1e46e40842dfc32b8824464ad5cd4725364fd83f7a58/msgpack-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a1d30df1f302f2b7a7404afbac2ab76d510036c34cf34dffb01f704a7288e45", size = 82621, upload-time = "2026-06-11T04:15:23.844Z" }, { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
{ url = "https://files.pythonhosted.org/packages/c9/59/7e6b812629d2f919e586041bffc130e1af32079f71bb20699eed54ed6d92/msgpack-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:581e317112260d8ca488d490cad9290a5682276f309c41c7de237a85ed8799c8", size = 81866, upload-time = "2026-06-11T04:15:25.032Z" }, { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/31/13/8c291196e60aafdbae38f482205d79432297749ac5d412fe638154fb6f1d/msgpack-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6827d12eacc16873eba62408a1b7bbe8ecfb4a8f7ed78a631ae9bae6ad43cf2", size = 405618, upload-time = "2026-06-11T04:15:26.235Z" }, { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
{ url = "https://files.pythonhosted.org/packages/fb/63/68f5d0ea81e167db5f59ddb94dc6f837667062113feff1c73fabf8907061/msgpack-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a186027e4279efa4c8bf06ce30605498d7d0d3af0fba0b9799dce85a3fd4a93c", size = 416468, upload-time = "2026-06-11T04:15:27.732Z" }, { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
{ url = "https://files.pythonhosted.org/packages/73/58/567dddf5c5a2790f673bcd7d80c83466d68e5ee9a9674ebca3db8101c0c8/msgpack-1.2.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a96142c14a11cf1a509e8b9aaf72858a3b742b7613e095ce646913e88ce7bd99", size = 374464, upload-time = "2026-06-11T04:15:29.286Z" }, { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
{ url = "https://files.pythonhosted.org/packages/0d/30/0c2342fc9092e4498045f5f60bca6ccbe4f4d87789778c2300e6fd6efe82/msgpack-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50c220579b68a6085b95408b2eaa486b259520f55d8e363ddc9b5d7ba5a6ac6d", size = 395879, upload-time = "2026-06-11T04:15:30.973Z" }, { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/9565b29b58ce3c33e177b490478b7aaeb8f726ecaaeda26d815893c1db5a/msgpack-1.2.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4dcb9d12ab100ecacdfaaf37a3d72fe8392eacc7054afc1916b12d1b747c8446", size = 371749, upload-time = "2026-06-11T04:15:32.418Z" }, { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
{ url = "https://files.pythonhosted.org/packages/f2/da/7bade19d60b73e2ef73fb76aaf4504c112a70cb760951b7202a0c64b5111/msgpack-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a804727188ab0ebb237fadb303b743f04925a69d8c3247292d1e33e679767c15", size = 410416, upload-time = "2026-06-11T04:15:34.053Z" }, { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
{ url = "https://files.pythonhosted.org/packages/6d/14/c0c619571c02432208a5977a8dbdd3fc65fe1369f8226ca4b6d08cca87d8/msgpack-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1a1ac6ae1fe23298f79380e7b144c8a454e5d05616b0096584f353ba2d750114", size = 64357, upload-time = "2026-06-11T04:15:35.535Z" }, { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
{ url = "https://files.pythonhosted.org/packages/50/a5/de06718460909aa965737fec4cfe8a15dedc6544a8c55feeb6956fa0d6e3/msgpack-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c3c80949d79578f9dc85fd9fb91edfe6694e8a729cd5744634d59d8455fdde3", size = 71057, upload-time = "2026-06-11T04:15:36.83Z" }, { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
{ url = "https://files.pythonhosted.org/packages/c7/52/73446b0141c94a856e22b787c56709c0815fc34f185326577e15b26d8cfe/msgpack-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fcf8f76fa587c2395fd0057c7232dbf071241f9ad280b235adb7ab585289989e", size = 64490, upload-time = "2026-06-11T04:15:38.001Z" }, { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
{ url = "https://files.pythonhosted.org/packages/35/3d/a7e3cdafa8c0cf36c81e2fa848ec4d30cf089459af45b390ad03f9ce6f49/msgpack-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f854fa1a8b55d75d82ef9a905d9cdbeffdf7897c088f6020bd221867da5e56a5", size = 83032, upload-time = "2026-06-11T04:15:39.38Z" }, { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/aa/53ddfba0e347cc4b484e95f629c5850b9e800ca8390c91ffc604407acf87/msgpack-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e90df581f80f53b372d5d9d9349078d729851a3a0d0bd74f53ccb598d01e45b8", size = 82600, upload-time = "2026-06-11T04:15:40.609Z" }, { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
{ url = "https://files.pythonhosted.org/packages/59/fd/e64c2c776e6dbad0af3c963fe0c0dd1ee1ba09efac478b233ab1db41868f/msgpack-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b276ed50d8ac75d1f134a433ae79af8557d0fa25ee5b4737da533dfc2ce382e8", size = 404342, upload-time = "2026-06-11T04:15:41.87Z" }, { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
{ url = "https://files.pythonhosted.org/packages/1b/60/fb9a08e6ccba882dfd370a5837fe3a07572938fdfe954f0f17fdf3e574b9/msgpack-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:544d972459c92aa32e63b800d07c2d9cf2734a3be29cee3a0b478a622850e9f5", size = 412351, upload-time = "2026-06-11T04:15:43.253Z" }, { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" },
{ url = "https://files.pythonhosted.org/packages/37/4d/df5c575c274fedc68ac9c6c61d045161899efad2afcdc25138efa7edde69/msgpack-1.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a070147cc2cf6b8a891734e0f5c8fe8f70ed8739ab30ba140b058005a6e86af4", size = 373331, upload-time = "2026-06-11T04:15:44.754Z" }, { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a4/c8b98f8191e985ed2003d87664ce3c95cca41db5d0cf6bf4f54327d32ec8/msgpack-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7685e23b0f51745a751629c31713fbefdef8896b31b2bb38299dfa4ae6c0740c", size = 394654, upload-time = "2026-06-11T04:15:46.423Z" }, { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" },
{ url = "https://files.pythonhosted.org/packages/d4/49/76f036720a602ea24428cfec5ec806f2487c0380b1bff0a2aa3094e15f87/msgpack-1.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b9204daeee8d91a7ae5acf2d2a8e3983be9a3025f38aa21bfaefbd7eea84a7dc", size = 370624, upload-time = "2026-06-11T04:15:48.062Z" }, { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/9f/38/40af3d29232833705a43b0fce0d07425cc280a7b92ab2b29932425b40df4/msgpack-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bfc057248609742ebbabf6bcd27fea4fd99c4980584e613c168c9b002318298f", size = 408038, upload-time = "2026-06-11T04:15:49.669Z" }, { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/30/b2/f140ca450524dff4d8d0eb81eb9ed75f8f3e0b1f12e49c5b01617cfa0b1c/msgpack-1.2.0-cp314-cp314-win32.whl", hash = "sha256:a3faa7edf2388337ae849239878e92f0298b4dab4488e4f1834062f9d0c410c9", size = 65823, upload-time = "2026-06-11T04:15:51.062Z" }, { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" },
{ url = "https://files.pythonhosted.org/packages/4d/13/6517bf966b841c7675ded30701a068ce141f3e698a27aaa35c702d8e078b/msgpack-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3effc392a57744e4681e55d05f97d5ee7b598747d718340a9b4b8a970c40e1", size = 72484, upload-time = "2026-06-11T04:15:52.289Z" }, { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" },
{ url = "https://files.pythonhosted.org/packages/45/8c/1d948420fdaa24de4efdb8012a6a5bebe09c82ee002b8c2ca745e9917f1f/msgpack-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:56a318f7df6bec7b40928d6b0519961f20a510d8baabf6baa393a70444588f0a", size = 66657, upload-time = "2026-06-11T04:15:53.583Z" }, { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" },
{ url = "https://files.pythonhosted.org/packages/39/16/1674faa1b7bddc19e79b465fd8e88e2cf4e3f7cae90723740701e8541068/msgpack-1.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4a65ab2097795e771a74a3a81ea49534aaeba874eaf426a3332268e045ae6", size = 86093, upload-time = "2026-06-11T04:15:54.98Z" }, { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" },
{ url = "https://files.pythonhosted.org/packages/dd/24/f241bcfdd9e96b2246289357c5a5e5a496189fd41c5844bee802c116aac7/msgpack-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:409550770632bb28daa70a11d0ed5763f7db38f40b06f7db9f11dd2794d01102", size = 86372, upload-time = "2026-06-11T04:15:56.381Z" }, { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c9/57f8ab98a1b21808c27b6dd6029053e0a796ffbb9b371e460dbe997011a9/msgpack-1.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf47e3cd11ce044965a9736a322afdd390b31ed602d1c1b10211d1a841f1d587", size = 428207, upload-time = "2026-06-11T04:15:57.739Z" }, { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/17/6b/4fd4aa739f131ded751ca7167c8ee87d2aab32506ebbeea893b60b51d343/msgpack-1.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:204bc9f5d6e59c1718c0a4a84fc8ff71b5b4562faac257c1a68bca611ecf9b72", size = 426082, upload-time = "2026-06-11T04:15:59.356Z" }, { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" },
{ url = "https://files.pythonhosted.org/packages/f9/00/db88e9a08fcd6513decaad06cbd5c168142bc3e662fb2f1aca3a563b7aa1/msgpack-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:610154307b27267266368bc1d1c7bb8aeb71da7be9356d403cb2442d9e6399f5", size = 378355, upload-time = "2026-06-11T04:16:00.916Z" }, { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" },
{ url = "https://files.pythonhosted.org/packages/54/84/eee4dd703d7a600cf46159d621c070b0b9468cf3dbade4ea8272bf5232a4/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6799f157bb63e79f11e2e590cfdb28423fc18dd60c270c3914b5b4586ae36f7e", size = 410848, upload-time = "2026-06-11T04:16:02.745Z" }, { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" },
{ url = "https://files.pythonhosted.org/packages/12/0a/195e2c549fd4631eb7f157d016ff15a10c4c1cf82b6d0a9b1edaef5174b1/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:72bd844902cf0a5ac3af2ef742f253cd0b1e5bcd184f49b4fb9a6a1f7bf305e8", size = 376152, upload-time = "2026-06-11T04:16:04.041Z" }, { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" },
{ url = "https://files.pythonhosted.org/packages/45/9b/bdd143fa79baec411dc658f5686fed680a18b36fcea5fccb6af1b8c7d832/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c0bd450f78d0d81722c80da6cdbf674a856967870a9db2f6c4debc4d8b3c67c", size = 417061, upload-time = "2026-06-11T04:16:05.63Z" }, { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ce/011ffcd8b919f55196ec53f12ae162e21c879d95afba226894314ff62c07/msgpack-1.2.0-cp314-cp314t-win32.whl", hash = "sha256:378caf74c4c718dfc17590ce68a6d710ed398ff6fcf08237de23b77755730b55", size = 70782, upload-time = "2026-06-11T04:16:07.105Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" },
{ url = "https://files.pythonhosted.org/packages/57/a8/9b8791ca96b1be6b9f659c718271e2cb7f99f73f58aad2dd0b30f750f6c0/msgpack-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:553b42598165c4dd3235994fd6e4b0dfb1ce5f3fd33d94ba9609442643015f38", size = 77899, upload-time = "2026-06-11T04:16:08.353Z" }, { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" },
{ url = "https://files.pythonhosted.org/packages/5b/04/3fa2dffb87bf598696b86bde7cd642d0a7590520c3fa24cd19611dfebeb7/msgpack-1.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2825bb1da548d214ab8a810906b7dd69a10f3838b615a2cc46e5172d3cb44f6e", size = 71004, upload-time = "2026-06-11T04:16:09.556Z" }, { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" },
] ]
[[package]] [[package]]
@@ -413,6 +398,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -433,7 +430,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.0" version = "9.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -444,9 +441,9 @@ dependencies = [
{ name = "pygments" }, { name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomli", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]] [[package]]
@@ -463,14 +460,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.0" version = "0.16.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "msgpack" }, { name = "msgpack" },
{ name = "questionary" },
{ name = "rich" }, { name = "rich" },
] ]
@@ -491,6 +501,7 @@ requires-dist = [
{ name = "click", specifier = ">=8" }, { name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=48" }, { name = "cryptography", specifier = ">=48" },
{ name = "msgpack", specifier = ">=1" }, { name = "msgpack", specifier = ">=1" },
{ name = "questionary", specifier = ">=2" },
{ name = "rich", specifier = ">=13" }, { name = "rich", specifier = ">=13" },
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" }, { name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
] ]
@@ -579,6 +590,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "wcwidth"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
]
[[package]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"