Compare commits

..

25 Commits

Author SHA1 Message Date
daniel156161 2c38cc8874 docs: link n8n node to public npm package
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 55s
Package Extension / package-extension (push) Successful in 25s
Build & Publish Package / publish (push) Successful in 27s
- Point the main README n8n integration section at the published npm package.

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

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

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

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

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

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

- Bump package and extension release metadata to 0.16.3.
2026-06-19 10:00:23 +02:00
daniel156161 7fe0e27fec feat(auth): add interactive key policy editing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 36s
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve.
- Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes.
- Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected.
- Show policy descriptions in auth keys output so each capability is easier to understand.
- Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates.
- Bump real-browser-cli to 0.16.2 and lock the new questionary dependency.
- Cover local, remote, validation, and policy-category behavior in tests.
2026-06-18 15:02:18 +02:00
daniel156161 6fa931aa36 feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels.
- Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips.
- Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely.
- Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows.
- Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0.
- Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
2026-06-18 14:24:15 +02:00
daniel156161 8dece7800f feat: group multi-browser output by source
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
2026-06-18 00:52:04 +02:00
daniel156161 479a0f1964 feat: improve remote browser tree routing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
- Allow remote host aliases passed via --browser to fan out for read-only
  multi-browser SDK paths while preserving strict routing for mutating commands.
- Add remote host grouping and scoped profile labels to tabs tree output so
  global views avoid repeated host prefixes.
- Carry browser family metadata through remote targets, tabs, and groups and
  style tree browser labels by family.
- Split CLI rendering helpers into a typed rendering package with dedicated
  common, label, tabs-tree, and windows-tree modules.
- Bump browser-cli and extension versions to 0.15.5.
- Cover the new routing and rendering behavior with unit and CLI tests.
2026-06-18 00:12:17 +02:00
daniel156161 371b794170 chore: prepare verified CRX uploads and release 0.15.4
Testing / remote-protocol-compat (0.9.5) (push) Successful in 36s
Package Extension / package-extension (push) Successful in 33s
Build & Publish Package / publish (push) Successful in 31s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 32s
Testing / test (push) Successful in 36s
- Add helper scripts for Chrome Web Store verified CRX uploads using a dedicated RSA upload key protected by GPG.
- Document the verified upload packaging flow and ignore local signing secrets.
- Add npm packaging entry point for signed webstore CRX artifacts.
- Chunk large SDK tab close batches to avoid native-host response timeouts.
- Bump project and extension versions to 0.15.4 with matching tests.
2026-06-17 16:54:20 +02:00
daniel156161 0ac652beee ci: publish gitea package as browser-cli
Testing / test (push) Successful in 1m13s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 57s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 53s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m9s
2026-06-15 01:39:12 +02:00
daniel156161 7cb2a8b618 refactor: modularize auth transport and markdown
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s
- Split auth into focused package modules for agent keys, file keys,
  signing, and post-quantum transport helpers while keeping the public
  browser_cli.auth import surface intact.
- Move transport encoding internals into a package with separate codec and
  binary-hoisting helpers, preserving browser_cli.transport compatibility.
- Extract remote TCP auth/socket helpers and serve challenge setup out of the
  runtime paths to make connection handling easier to reason about.
- Move the extension markdown extractor into a dedicated content/markdown
  folder with separate root selection, code normalization, renderer, and utils.
- Centralize CLI Rich rendering helpers for tab/window tree and table output,
  and add rendering tests for the shared builders.
- Remove local typing ignores in SDK/decorator/script plumbing and bump the
  package and extension version to 0.15.3.
2026-06-15 01:23:57 +02:00
daniel156161 0b43408a8d feat(cli): improve tab and window tree rendering
- Add shared rendering helpers for width-aware tree labels, truncation, and no-wrap Rich text.
- Preserve tab index and group window metadata through the extension and SDK factories.
- Render tab trees in browser/window/index order with grouped tab details and optional shortened URLs.
- Reuse the tab tree labels in window trees to keep output compact and consistent.
- Cover legacy missing-index responses, grouped/collapsed tabs, URL display, and rendering helpers with tests.
2026-06-15 01:04:02 +02:00
daniel156161 657b1b0923 test: make Firefox install output assertion wrap-safe
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Testing / test (push) Successful in 1m0s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 2m28s
- Normalize Rich CLI output before asserting the packaged Firefox manifest path.
- Replace split substring checks with the full expected manifest path.
- Keep the warning assertion stable when terminal wrapping inserts line breaks.
2026-06-14 22:57:07 +02:00
daniel156161 477a00db1a feat(extension): add Firefox WebExtension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Build & Publish Package / publish (push) Successful in 46s
Package Extension / package-extension (push) Successful in 59s
Testing / test (push) Failing after 50s
- Add a neutral WebExtension API adapter that uses Firefox browser.* or Chromium chrome.* without mutating globals.
- Switch extension runtime code to the adapter and add Firefox-specific typings for tabs, windows, tab groups, storage, scripting, and native messaging ports.
- Fix Firefox temporary add-on instructions to load the packaged manifest with background.scripts instead of the Chromium service worker manifest.
- Detect Firefox in clients.list via runtime.getBrowserInfo and keep Chromium user-agent fallback support.
- Make navigate.open wait briefly for Firefox to replace initial about:blank with the requested URL.
- Add JS coverage for API selection, clients.list browser detection, and Firefox navigate.open URL polling.
- Bump package and extension version to 0.15.2.
2026-06-14 19:09:10 +02:00
daniel156161 523108e442 test: skip generated extension bundle checks when missing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 57s
- Add a shared helper for reading the built extension background bundle.
- Skip the Firefox/WebExtension linter assertions when the generated bundle is absent.
- Keep fresh checkouts able to run pytest without building gitignored artifacts first.
2026-06-14 17:26:20 +02:00
daniel156161 9b8cefcd72 feat: add Firefox extension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / test (push) Failing after 53s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
- Add Firefox as an install target with native messaging manifest support.
- Generate Firefox-specific extension packages with Gecko metadata and AMO-compatible manifest transforms.
- Keep tab group commands available in Firefox through dynamic tab group API helpers.
- Avoid Firefox linter warnings for static tab group API references and direct eval tokens.
- Add Firefox packaging and installer regression coverage.
- Bump the package and extension version to 0.15.1.
2026-06-14 17:19:25 +02:00
daniel156161 9df5e1bd8f chore(release): publish as real-browser-cli
Testing / remote-protocol-compat (0.9.5) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Package Extension / package-extension (push) Successful in 32s
Build & Publish Package / publish (push) Successful in 25s
Testing / test (push) Successful in 31s
- Rename the PyPI distribution from browser-cli to real-browser-cli after PyPI rejected the original name as too similar to an existing project.
- Keep the installed console command as browser-cli so user-facing CLI usage remains unchanged.
- Add README-based package metadata, author information, and project URLs so PyPI renders a proper project description.
- Centralize the PyPI distribution name for importlib.metadata version lookups used by the CLI, doctor command, and remote user agent.
- Document uv tool install, optional fast extra installation, and upgrade commands.
- Bump package and extension metadata to 0.14.3 for the republished release.
2026-06-14 16:18:12 +02:00
daniel156161 6a806f857c docs: add noncommercial license
Testing / remote-protocol-compat (0.9.3) (push) Successful in 35s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 42s
Testing / test (push) Successful in 31s
Package Extension / package-extension (push) Successful in 28s
Build & Publish Package / publish (push) Successful in 32s
- Add the PolyForm Noncommercial License 1.0.0 with the required copyright notice.
- Declare the license file in Python package metadata.
- Document that commercial use is not permitted without separate licensing.
- Refresh the extension packaging wording to match the testing archive name.
2026-06-14 15:47:48 +02:00
daniel156161 642e22759f fix: allow Web Store extension native messaging
Testing / remote-protocol-compat (0.9.3) (push) Successful in 35s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 35s
Testing / test (push) Successful in 29s
- Add the Chrome Web Store extension ID alongside the keyed testing ID.
- Register both extension origins in the native messaging host manifest.
- Update install output so users can distinguish testing and Web Store IDs.
- Add CLI coverage for the generated allowed_origins list.
2026-06-14 15:39:41 +02:00
daniel156161 65a032f961 ci: publish separate extension archives
Testing / remote-protocol-compat (0.9.3) (push) Successful in 49s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 53s
Testing / test (push) Successful in 36s
Package Extension / package-extension (push) Successful in 31s
Build & Publish Package / publish (push) Successful in 37s
- Rename the keyed extension package to a testing archive to make its purpose explicit.
- Keep the webstore archive as a separate keyless package for Chrome Web Store upload.
- Upload both extension archives to tagged releases instead of only publishing one ambiguous asset.
- Update the package helper's default suffix and documentation to match the release asset names.
2026-06-14 15:20:48 +02:00
daniel156161 809c73c3a3 chore: remove ServiceLink integration
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 40s
Testing / test (push) Successful in 33s
- Drop the ServiceLink submodule, link-serve command, and serve --rpc mode.
- Remove the now-unused httpx dependency and regenerate uv.lock.
- Add a privacy policy for Chrome Web Store publication.
- Refresh extension icons and bump package/extension version to 0.14.2.
- Keep the native TCP serve path as the only remote-control endpoint.
2026-06-14 15:13:55 +02:00
daniel156161 c79e4dd664 test: make extension packaging tests build-independent
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 41s
- Use a temporary fake extension tree in packaging tests instead of relying on ignored generated bundles.

- Keep coverage for Web Store manifest.key stripping and local-package key retention.

- Fix plain pytest runs in clean CI checkouts where extension/background.js has not been built yet.
2026-06-14 14:37:42 +02:00
daniel156161 5cec57e06d feat!: harden raw browser control and packaging
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
- Add safe-by-default policy gates for raw command surfaces: command, script, and serve-http /command.

- Require explicit opt-ins for page reads, browser control, and high-risk commands such as dom.eval, storage.*, and screenshots.

- Remove all cookies support from CLI, SDK, extension commands, permissions, constants, docs, and tests.

- Add diagnostic, events, watch, workspace, remote, raw command, script, HTTP gateway, tree-view, session import/export, and extension info/capability commands.

- Add Chrome Web Store packaging that strips manifest.key while keeping local packages with a stable native-messaging extension ID.

- Bump browser-cli and extension version to 0.14.1 and cover the new behavior with pytest and extension packaging tests.

BREAKING CHANGE: cookies commands and the b.cookies SDK namespace have been removed; generic raw command execution now blocks non-safe commands unless explicitly allowed.
2026-06-14 14:33:15 +02:00
daniel156161 3e3b8d529c fix: make navigation no-focus by default
Testing / test (push) Failing after 15s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
- Change nav open and open-wait to avoid activating newly created tabs unless
  --focus is explicitly requested.
- Send background=true for default opens so older or remote extensions also
  avoid stealing focus even if they ignore the new focus flag.
- Remove the redundant --bg flag from navigation and search CLI commands now
  that no-focus/background behavior is the default.
- Thread focus support through the sync SDK, async SDK, tab helpers, and
  workflow decorators.
- Update README and demo usage to document the new default and --focus opt-in.
- Bump package and extension metadata to 0.12.3.
- Add regression coverage for CLI help, wire payloads, and extension behavior.
2026-06-14 13:59:15 +02:00
daniel156161 509f1387de fix: prevent browser target and focus surprises
Testing / remote-protocol-compat (0.9.5) (push) Successful in 57s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m1s
Testing / test (push) Successful in 1m7s
- Respect the globally selected browser when renaming client aliases.
- Pass the resolved local profile into sync and async local transports so
  BROWSER_CLI_PROFILE is honored consistently.
- Stop tabs.active from explicitly focusing the OS browser window, avoiding
  virtual-desktop jumps during tab activation.
- Make window merging skip audible, unmuted windows so video playback windows
  are not selected as merge targets.
- Bump the Python package and extension manifest versions to 0.12.2.
- Add regression coverage for browser selection and focus-stealing behavior.
2026-06-14 13:00:33 +02:00
daniel156161 e1c495d82d chore: update ServiceLink submodule
- Advance the ServiceLink submodule to the latest origin/main commit.
- Pick up the new ServiceLink CLI and RPC discovery support.
- Keep the parent repository pinned to the exact submodule revision used by browser-cli.
2026-06-14 00:34:17 +02:00
daniel156161 ade6bf0002 chore: refresh locked Python dependencies
Testing / test (push) Successful in 43s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 33s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 36s
- Update uv.lock to the latest resolver output for existing dependency constraints.
- Bump locked Click, Coverage, Cryptography and Pytest patch/minor releases.
- Keep project metadata unchanged; this commit only refreshes the lockfile.
2026-06-14 00:07:25 +02:00
166 changed files with 12672 additions and 4423 deletions
+37 -28
View File
@@ -39,19 +39,17 @@ jobs:
)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Build extension archive
- name: Build extension archives
run: |
rm -rf extension-package
mkdir -p dist extension-package
cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/
cp -R extension/icons extension-package/icons
cd extension-package
zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" .
python scripts/package_extension.py --out "dist/browser-cli-extension-testing-v${{ steps.version.outputs.version }}.zip"
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${{ steps.version.outputs.version }}.zip"
- name: Publish extension release asset
- name: Publish extension release assets
env:
ACTION_ACCESS_TOKEN: ${{ secrets.ACTION_ACCESS_TOKEN }}
ASSET_NAME: browser-cli-extension-v${{ steps.version.outputs.version }}.zip
ASSET_NAMES: |
browser-cli-extension-testing-v${{ steps.version.outputs.version }}.zip
browser-cli-extension-webstore-v${{ steps.version.outputs.version }}.zip
EXTENSION_VERSION: ${{ steps.version.outputs.version }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
@@ -59,15 +57,19 @@ jobs:
run: |
set -euo pipefail
asset_path="dist/browser-cli-extension-v${EXTENSION_VERSION}.zip"
asset_name="$(basename "$asset_path")"
tag_name="v${EXTENSION_VERSION}"
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
if [ ! -f "$asset_path" ]; then
echo "Missing asset: $asset_path" >&2
exit 1
fi
while IFS= read -r asset_name; do
[ -n "$asset_name" ] || continue
asset_path="dist/${asset_name}"
if [ ! -f "$asset_path" ]; then
echo "Missing asset: $asset_path" >&2
exit 1
fi
done <<EOF
${ASSET_NAMES}
EOF
release_body="$(mktemp)"
create_body="$(mktemp)"
@@ -146,7 +148,11 @@ jobs:
exit 1
fi
existing_asset_id="$(python - "$release_body" <<'PY'
while IFS= read -r asset_name; do
[ -n "$asset_name" ] || continue
asset_path="dist/${asset_name}"
existing_asset_id="$(ASSET_NAME="$asset_name" python - "$release_body" <<'PY'
import json
import os
import sys
@@ -162,19 +168,22 @@ jobs:
else:
print("")
PY
)"
)"
if [ -n "$existing_asset_id" ]; then
curl --silent --show-error \
--request DELETE \
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
--header "Accept: application/json" \
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
fi
if [ -n "$existing_asset_id" ]; then
curl --silent --show-error \
--request DELETE \
--request POST \
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
--header "Accept: application/json" \
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
fi
curl --silent --show-error \
--request POST \
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
--header "Accept: application/json" \
--form "attachment=@${asset_path}" \
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
--form "attachment=@${asset_path}" \
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
done <<EOF
${ASSET_NAMES}
EOF
+25 -2
View File
@@ -17,8 +17,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Build package
run: uv build
- name: Build Gitea package
run: |
# Keep the public/PyPI distribution as real-browser-cli in the repo,
# but publish the private Gitea package under browser-cli.
python - <<'PY'
from pathlib import Path
replacements = {
Path("pyproject.toml"): (
'name = "real-browser-cli"',
'name = "browser-cli"',
),
Path("browser_cli/constants.py"): (
'PYPI_PACKAGE_NAME = "real-browser-cli"',
'PYPI_PACKAGE_NAME = "browser-cli"',
),
}
for path, (old, new) in replacements.items():
text = path.read_text()
if old not in text:
raise SystemExit(f"expected text not found in {path}: {old}")
path.write_text(text.replace(old, new, 1))
PY
uv build
- name: Publish to Gitea
run: |
+5
View File
@@ -5,6 +5,11 @@ extension/test-dist/
node_modules/
dist/
# Local secrets / signing keys
secrets/
*.pem
*.pem.gpg
# Python
__pycache__/
*.pyc
-3
View File
@@ -1,3 +0,0 @@
[submodule "servicelink"]
path = servicelink
url = git@git.yiprawr.dev:submodules/servicelink.git
+75
View File
@@ -0,0 +1,75 @@
# PolyForm Noncommercial License 1.0.0
Required Notice: Copyright (c) 2026 Daniel Dolezal
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Copyright License
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
## Patent License
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
## Patent Defense
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
## No Liability
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization.
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the software under these terms.
**Use** means anything you do with the software requiring one of your licenses.
+23
View File
@@ -0,0 +1,23 @@
# Privacy Policy for browser-cli
Last updated: 2026-06-14
browser-cli does not collect, transmit, sell, or share user data with the developer or any third party.
browser-cli is a local browser automation tool. The browser extension communicates with the locally installed browser-cli native messaging host so the user can control their own browser through the command line or Python SDK.
## Local data access
Depending on the command explicitly run by the user, browser-cli may locally access browser data such as:
- tab URLs, titles, status, and window or tab group information
- page content, links, images, HTML, text, screenshots, or DOM data
- cookies, local storage, session storage, and saved browser-cli session data
This access happens only to perform the command requested by the user. The data stays on the user's device unless the user explicitly configures browser-cli to connect to another machine they control.
## Remote control mode
browser-cli includes an optional remote control mode. If the user enables this mode, command data may be transmitted between the user's configured browser-cli client and server endpoints. This is user-configured infrastructure. The developer does not receive or operate these endpoints.
## No analytics or tracking
browser-cli does not use analytics, telemetry, advertising, behavioral tracking, or remote code. The extension does not send data to the developer.
## Contact
For privacy questions or security reports, please open an issue in the project repository or contact the project maintainer through the repository hosting platform.
+156 -88
View File
@@ -1,20 +1,21 @@
# browser-cli
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
---
## What it does
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel, or through the optional authenticated TCP remote bridge.
---
## How it works
```
terminal / python script
terminal / python script / remote client
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
│ or TCP remote bridge (Ed25519 auth, optional compression)
Native Messaging Host (Python process, launched by the browser)
@@ -33,10 +34,9 @@ terminal / python script
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
No server needs to be running beforehand. The browser manages the native host's lifecycle.
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format**
Every command is a JSON object:
```json
{ "id": "uuid", "command": "tabs.list", "args": {} }
@@ -49,98 +49,110 @@ Every response:
---
## 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
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
### Install with uv
Install the CLI from PyPI as a uv tool, then register the native host:
```sh
uv tool install real-browser-cli
browser-cli --version
browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
```
The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
For better remote-response compression, install the optional `fast` extra:
```sh
uv tool install "real-browser-cli[fast]"
```
To upgrade later:
```sh
uv tool upgrade real-browser-cli
```
### Add the browser extension
Install the extension from its public store listing (the `install` command prints the right link for you):
- Chrome / Chromium / Brave / Edge / Vivaldi — [Chrome Web Store](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp)
- Firefox — [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/browser-cli/)
The native host manifest trusts both the published store ID and the unpacked development ID, so the store extension works out of the box. If you are hacking on the extension yourself, run `browser-cli install <browser> --dev` for the unpacked / temporary-add-on load steps instead.
### Install from source
```sh
git clone <repo>
cd browser-cli
uv sync
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
npm ci && npm run build:extension # build the unpacked extension bundles
uv run browser-cli install brave --dev # --dev prints unpacked-load steps; or: chrome, chromium, edge, vivaldi, firefox
```
The `install` command will:
1. Ask you to load the browser-specific extension package
2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
3. Write the native messaging manifest to your OS so the browser can find the host
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
Omit `--dev` to be pointed at the public store listing instead of loading the unpacked build.
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
The `install` command will:
1. Write the native messaging manifest to your OS so the browser can find the host
2. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
3. Print the public store link for installing the extension (or, with `--dev`, the unpacked / temporary-add-on load steps)
After install, add the extension from the store link above and **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper imports the installed `browser_cli.native.host` entry point. On Windows the install command also registers the host in the current user's Registry for the selected browser.
---
## Project structure
```text
browser-cli/
├── browser_cli/
│ ├── __init__.py # Python SDK BrowserCLI class and SDK entry point
│ ├── cli.py # Click CLI entry point
│ ├── client/ # Client-side command routing used by CLI and SDK
│ ├── core.py # send_command and remote command routing
│ ├── targets.py # Browser target discovery and socket resolution
│ ├── auth.py # Remote auth fields and key lookup
│ └── messages.py # Request/response helpers
│ ├── models.py # Tab and Group helper models
│ ├── native/ # Native messaging host internals
│ ├── host.py # Browser-launched native host entry point
│ ├── local_server.py # Local CLI IPC server
│ └── protocol.py # Chrome Native Messaging framing
── remote/ # Client-side remote browser support
│ │ ├── transport.py # TCP/TLS remote transport
│ │ └── registry.py # Saved remote endpoints/keys
│ └── commands/
│ ├── navigate.py # nav open/reload/back/forward/focus
│ ├── search.py # search engine shortcuts
│ ├── tabs.py # tab management
│ ├── groups.py # tab group management
│ ├── windows.py # window management
│ ├── dom.py # DOM querying and interaction
│ ├── extract.py # content extraction
│ └── session.py # session save/load
│ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
│ ├── async_sdk.py # AsyncBrowserCLI
│ ├── cli.py # Click root command and native-host entry point
│ ├── client/ # send_command path, local/remote routing, message helpers
│ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
│ ├── commands/ # CLI presentation layer over the SDK namespaces
├── native/ # Browser-launched Native Messaging host + local IPC server
│ ├── remote/ # TCP remote client transport and saved endpoint registry
│ ├── serve/ # Authenticated TCP server runtime
│ ├── transport/ # JSON/msgpack response encoding and compression helpers
│ ├── markdown/ # HTML-to-Markdown extraction helpers
├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
── models.py # Tab, Group, BrowserCounts dataclasses
├── extension/
│ ├── manifest.json # MV3 extension manifest
── content.js # Content-script helpers
└── src/ # TypeScript source split by command area
│ ├── index.ts # Builds generated extension/background.js
── content/ # Builds generated extension/content-dispatch.js
├── examples/
├── demo.py # Python SDK walkthrough
│ └── demo.sh # Bash CLI walkthrough
├── tests/
│ ├── conftest.py # shared pytest fixtures
│ ├── test_api.py
│ ├── test_cli.py
│ ├── test_dom.py
│ ├── test_extract.py
│ ├── test_groups.py
│ ├── test_nav.py
│ ├── test_session.py
│ ├── test_tabs.py
│ └── test_windows.py
├── com.browsercli.host.json # native messaging manifest template
├── pyproject.toml # package metadata and CLI entry point
└── uv.lock # locked dependencies for uv
│ ├── manifest.json # Chromium MV3 manifest
── src/ # TypeScript WebExtension source
├── index.ts # Background/service-worker bundle entry
│ ├── content-dispatch.ts
── commands/ # Browser-side command implementations
│ ├── content/ # DOM/extract/Markdown logic injected into pages
└── core/ # Shared extension helpers
├── examples/ # Python and shell walkthroughs
├── scripts/ # Packaging and release helper scripts
├── tests/ # pytest suite
├── package.json # Extension build/test/package scripts
├── pyproject.toml # Python package metadata
└── uv.lock # locked Python dependencies
```
---
## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
### Navigation (`nav`)
```sh
# Open a URL
# Open a URL (no focus stealing by default)
browser-cli nav open https://example.com
browser-cli nav open https://example.com --bg # background, no focus
browser-cli nav open https://example.com --focus # bring opened tab/window forward
browser-cli nav open https://example.com --window work # into a named window
browser-cli nav open https://example.com --group research # into a tab group (name or ID)
@@ -158,12 +170,11 @@ browser-cli nav focus github # focuses first tab whose URL contains "
```
### Search
Each search command opens the search results in your browser using the same flags as `nav open`.
```sh
browser-cli search google openai api
browser-cli search brave rust iterators --bg
browser-cli search brave rust iterators
browser-cli search ddg tab groups --window work
browser-cli search youtube browser automation
browser-cli search yt lo fi
@@ -182,7 +193,6 @@ browser-cli search so click choices
```
### Tabs
```sh
browser-cli tabs list # list all open tabs (all windows)
browser-cli tabs count # count all tabs
@@ -210,7 +220,6 @@ browser-cli tabs merge-windows # pull all tabs into the current wi
```
### Tab groups
```sh
browser-cli groups list # list all tab groups
browser-cli groups count # count groups
@@ -232,7 +241,6 @@ browser-cli groups move 42 -l # short left alias
```
### Windows
```sh
browser-cli windows list # list all windows
browser-cli windows open # open a new window
@@ -242,20 +250,18 @@ browser-cli windows close 1 # close a window
```
### DOM
These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`.
```sh
browser-cli dom query "h1" # return elements matching CSS selector
browser-cli dom text "h1" # get text content of matching elements
browser-cli dom attr "a" href # get attribute value from elements
browser-cli dom exists ".cookie-banner" # exits 0 if found, 1 if not
browser-cli dom exists ".modal-banner" # exits 0 if found, 1 if not
browser-cli dom click ".accept-button" # click an element
browser-cli dom type "#search" "hello" # type text into an input
```
### Extract
```sh
browser-cli extract links # all <a href> links on the page
browser-cli extract images # all <img> tags (src + alt)
@@ -267,7 +273,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
```
### Sessions
A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared.
```sh
@@ -281,7 +286,6 @@ browser-cli session auto-save off
```
### Misc
```sh
browser-cli clients # show connected browser info from the registry
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
@@ -291,17 +295,56 @@ browser-cli completion zsh # print setup instructions
browser-cli completion zsh --script # output raw completion script
```
### Remote control, auth, and gateways
```sh
# On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
browser-cli auth trust "$PUBKEY"
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
# Allow remote browser control (navigation, clicks); safe-only otherwise
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --allow-control
# Per-key authorization (inline in authorized_keys) + a tighter rate limit
browser-cli auth trust "$PUBKEY" --name ci-bot --allow-read-page --allow-control
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --rate-limit 20
# From another machine
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
browser-cli --remote browser-host.example:8765 clients
# Local HTTP JSON gateway for small integrations
browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Use `browser-cli remote trust ENDPOINT KEY` to remember a key for later calls. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
#### n8n integration
The n8n community node is published as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli) on npm. It talks directly to a remote `browser-cli serve` endpoint over the same Ed25519-authenticated, ML-KEM-encrypted TCP protocol as the CLI remote client. Install it from n8n's Community Nodes UI, run `browser-cli serve` on the browser machine, paste the client key into the node credential, and drive tabs/DOM/extraction/raw commands from a workflow. See [`n8n-nodes-browser-cli/README.md`](n8n-nodes-browser-cli/README.md).
#### Security model
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
- **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`, `keys`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` when adding a key, or change it later with `auth policy <pubkey|name> …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy.
- **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own.
- **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser.
- **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address.
- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use.
For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection.
---
## Python SDK
```python
from browser_cli import AsyncBrowserCLI, BrowserCLI
b = BrowserCLI()
```
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
```python
# Navigation ── b.nav
@@ -363,7 +406,7 @@ b.windows.close(1)
elements = b.dom.query("h2") # list of { tag, text, attrs }
texts = b.dom.text(".article p") # list of strings
attrs = b.dom.attr("a", "href") # list of strings
exists = b.dom.exists(".cookie-banner")# bool
exists = b.dom.exists(".modal-banner") # bool
b.dom.click(".accept-button")
b.dom.type("#search", "hello world")
b.dom.wait_for("#results", visible=True, timeout=10)
@@ -376,11 +419,10 @@ text = b.extract.text() # string
data = b.extract.json("#app-data") # parsed Python object
md = b.extract.markdown("article")
# Page / storage / cookies
# Page / storage
info = b.page.info()
b.storage.set("token", "abc")
val = b.storage.get("token")
cookies = b.cookies.list(domain="example.com")
# Sessions ── b.session
b.session.save("before-meeting")
@@ -431,7 +473,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
```
**Error handling**
```python
from browser_cli import BrowserCLI, BrowserNotConnected
@@ -457,12 +498,12 @@ counts = b.tabs.count()
if isinstance(counts, BrowserCounts):
print(counts.total)
print(counts.by_browser)
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
```
---
## Example scripts
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
```sh
@@ -473,7 +514,6 @@ bash examples/demo.sh
---
## Development
```sh
npm ci
npm run check:extension # type-check, build extension bundles, syntax-check bundle
@@ -487,13 +527,41 @@ nix-shell # automatically runs npm ci when node_modules is missing/outdated
npm run check:extension
```
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before loading the unpacked `extension/` directory; `browser-cli install <browser> --dev` prints the per-browser load steps. On NixOS, use `nix-shell` first if npm is not installed globally.
Packaging:
```bash
just publish # build to /tmp/dist-browser-cli and publish with .env credentials
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
```
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
```bash
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
npm run package:extension:webstore:verified
```
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
---
## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
- **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.
- **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
---
## 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.
+1 -4
View File
@@ -29,7 +29,6 @@ Commands are grouped into namespaces on the client:
b.extract content extraction (links, images, text, json, markdown)
b.page page info
b.storage localStorage / sessionStorage
b.cookies cookies (list, get, set)
b.session sessions (save, load, list, diff, ...)
b.perf performance profile + background jobs
b.extension control the extension itself
@@ -37,11 +36,10 @@ Commands are grouped into namespaces on the client:
"""
from collections.abc import Callable
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk import (
CookiesNS,
DecoratorsNS,
DomNS,
ExtensionNS,
@@ -85,7 +83,6 @@ class BrowserCLI(FactoryMixin, RoutingMixin):
extract: ExtractNS
page: PageNS
storage: StorageNS
cookies: CookiesNS
session: SessionNS
perf: PerfNS
extension: ExtensionNS
+31 -7
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio
import functools
from collections.abc import Callable
from typing import TypeVar
from typing import TypeVar, cast
from browser_cli.models import Group, Tab
from browser_cli.sdk import NAMESPACE_NAMES
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally:
if cleanup is not None:
await self._maybe_await(cleanup(value))
return wrapper # type: ignore[return-value]
return cast(F, wrapper)
return decorator(func) if func is not None else decorator
def new_tab(
@@ -84,6 +84,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
close: bool = False,
@@ -95,6 +96,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
wait=wait,
timeout=timeout,
background=background,
focus=focus,
window=window,
group=group,
)
@@ -115,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally:
if previous:
await self._c.perf.set_profile(previous)
return wrapper # type: ignore[return-value]
return cast(F, wrapper)
return decorator
def retry(
@@ -140,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
raise
if delay > 0:
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
return wrapper # type: ignore[return-value]
raise cast(BaseException, last_error)
return cast(F, wrapper)
return decorator
class AsyncBrowserCLI:
@@ -218,18 +220,40 @@ class AsyncBrowserCLI:
async def clients(self) -> list[dict]:
return await self._cmd("clients.list", {})
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab:
return self._sync.tab_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
browser_type=browser_type,
browser_group=browser_group,
)
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group:
return self._sync.group_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
browser_type=browser_type,
browser_group=browser_group,
)
-263
View File
@@ -1,263 +0,0 @@
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
import hashlib
import json
import os
import secrets
import socket
import struct
from dataclasses import dataclass
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
from browser_cli.constants import (
DEFAULT_AUTHORIZED_KEYS_PATH,
DEFAULT_KEY_PATH,
PQ_KEX_ALG,
PQ_TRANSPORT_ALG,
SSH_AGENT_IDENTITIES_ANSWER,
SSH_AGENT_SIGN_RESPONSE,
SSH_AGENTC_REQUEST_IDENTITIES,
SSH_AGENTC_SIGN_REQUEST,
)
def _pack_str(s: bytes) -> bytes:
return struct.pack(">I", len(s)) + s
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
n = struct.unpack_from(">I", data, off)[0]
return data[off + 4 : off + 4 + n], off + 4 + n
def _agent_roundtrip(msg: bytes) -> bytes:
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
n = struct.unpack(">I", raw_len)[0]
resp = b""
while len(resp) < n:
chunk = sock.recv(n - len(resp))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
resp += chunk
return resp
# ── AgentKey ───────────────────────────────────────────────────────────────────
@dataclass
class AgentKey:
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, off = _unpack_str(self.blob, 0)
key_bytes, _ = _unpack_str(self.blob, off)
return key_bytes
# ── Agent helpers ──────────────────────────────────────────────────────────────
def agent_list_keys() -> list[AgentKey]:
"""Return all Ed25519 keys currently held by the SSH agent."""
resp = _agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
if resp[0] != SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
n_keys = struct.unpack_from(">I", resp, 1)[0]
keys: list[AgentKey] = []
off = 5
for _ in range(n_keys):
blob, off = _unpack_str(resp, off)
comment, off = _unpack_str(resp, off)
algo, _ = _unpack_str(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
def agent_find_key(selector: str | None = None) -> AgentKey | None:
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([SSH_AGENTC_SIGN_REQUEST])
+ _pack_str(key.blob)
+ _pack_str(data)
+ struct.pack(">I", 0)
)
resp = _agent_roundtrip(msg)
if resp[0] != SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
sig_blob, _ = _unpack_str(resp, 1)
_algo, soff = _unpack_str(sig_blob, 0)
raw_sig, _ = _unpack_str(sig_blob, soff)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
# ── File-based key helpers ─────────────────────────────────────────────────────
def generate_keypair() -> tuple[bytes, str]:
"""Return (private_key_pem_bytes, public_key_hex)."""
priv = Ed25519PrivateKey.generate()
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, pub_hex
def load_private_key(path: Path) -> Ed25519PrivateKey:
return load_pem_private_key(path.read_bytes(), password=None)
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
# ── Canonical payload + sign/verify ───────────────────────────────────────────
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = _auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
priv = mlkem.MLKEM768PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
return priv, pub
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = pub.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pk for pk, _ in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
return True
+73
View File
@@ -0,0 +1,73 @@
"""Public auth API for browser-cli.
Implementation lives in focused modules:
- ``auth.agent``: SSH-agent/YubiKey helpers
- ``auth.keys``: file keys and authorized_keys management
- ``auth.signing``: canonical payload signing/verification
- ``auth.pq``: ML-KEM KEX and encrypted transport helpers
"""
from browser_cli.auth.agent import (
AgentKey,
agent_find_key,
agent_list_keys,
agent_roundtrip as _agent_roundtrip,
agent_sign_raw,
pack_ssh_string as _pack_str,
unpack_ssh_string as _unpack_str,
)
from browser_cli.auth.keys import (
add_authorized_key,
format_authorized_line,
generate_keypair,
load_authorized_keys,
load_authorized_keys_with_names,
load_authorized_keys_with_policies,
load_private_key,
public_key_hex,
set_authorized_key_policy,
)
from browser_cli.auth.pq import (
new_nonce,
pq_decrypt,
pq_encrypt,
pq_kex_client_encapsulate,
pq_kex_server_decapsulate,
pq_kex_server_keypair,
pq_transport_key as _pq_transport_key,
)
from browser_cli.auth.signing import (
auth_message as _auth_message,
canonical_payload,
sign,
verify,
)
from browser_cli.constants import DEFAULT_AUTHORIZED_KEYS_PATH, DEFAULT_KEY_PATH, PQ_KEX_ALG, PQ_TRANSPORT_ALG
__all__ = [
"AgentKey",
"DEFAULT_AUTHORIZED_KEYS_PATH",
"DEFAULT_KEY_PATH",
"PQ_KEX_ALG",
"PQ_TRANSPORT_ALG",
"add_authorized_key",
"agent_find_key",
"agent_list_keys",
"agent_sign_raw",
"canonical_payload",
"format_authorized_line",
"generate_keypair",
"load_authorized_keys",
"load_authorized_keys_with_names",
"load_authorized_keys_with_policies",
"load_private_key",
"new_nonce",
"pq_decrypt",
"pq_encrypt",
"pq_kex_client_encapsulate",
"pq_kex_server_decapsulate",
"pq_kex_server_keypair",
"public_key_hex",
"set_authorized_key_policy",
"sign",
"verify",
]
+103
View File
@@ -0,0 +1,103 @@
"""SSH-agent backed Ed25519 key helpers."""
from __future__ import annotations
import os
import socket
import struct
from dataclasses import dataclass
from browser_cli.constants import (
SSH_AGENT_IDENTITIES_ANSWER,
SSH_AGENT_SIGN_RESPONSE,
SSH_AGENTC_REQUEST_IDENTITIES,
SSH_AGENTC_SIGN_REQUEST,
)
def pack_ssh_string(value: bytes) -> bytes:
return struct.pack(">I", len(value)) + value
def unpack_ssh_string(data: bytes, offset: int) -> tuple[bytes, int]:
length = struct.unpack_from(">I", data, offset)[0]
return data[offset + 4 : offset + 4 + length], offset + 4 + length
def agent_roundtrip(msg: bytes) -> bytes:
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
length = struct.unpack(">I", raw_len)[0]
response = b""
while len(response) < length:
chunk = sock.recv(length - len(response))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
response += chunk
return response
@dataclass
class AgentKey:
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, offset = unpack_ssh_string(self.blob, 0)
key_bytes, _ = unpack_ssh_string(self.blob, offset)
return key_bytes
def agent_list_keys() -> list[AgentKey]:
"""Return all Ed25519 keys currently held by the SSH agent."""
response = agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
if response[0] != SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {response[0]}")
key_count = struct.unpack_from(">I", response, 1)[0]
keys: list[AgentKey] = []
offset = 5
for _ in range(key_count):
blob, offset = unpack_ssh_string(response, offset)
comment, offset = unpack_ssh_string(response, offset)
algo, _ = unpack_ssh_string(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
def agent_find_key(selector: str | None = None) -> AgentKey | None:
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([SSH_AGENTC_SIGN_REQUEST])
+ pack_ssh_string(key.blob)
+ pack_ssh_string(data)
+ struct.pack(">I", 0)
)
response = agent_roundtrip(msg)
if response[0] != SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {response[0]})")
sig_blob, _ = unpack_ssh_string(response, 1)
_algo, sig_offset = unpack_ssh_string(sig_blob, 0)
raw_sig, _ = unpack_ssh_string(sig_blob, sig_offset)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
+125
View File
@@ -0,0 +1,125 @@
"""File-based Ed25519 keys and authorized_keys helpers."""
from __future__ import annotations
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
from browser_cli.auth.agent import AgentKey
def generate_keypair() -> tuple[bytes, str]:
"""Return (private_key_pem_bytes, public_key_hex)."""
private_key = Ed25519PrivateKey.generate()
pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
public_hex = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, public_hex
def load_private_key(path: Path) -> Ed25519PrivateKey:
return load_pem_private_key(path.read_bytes(), password=None)
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _parse_authorized_line(line: str) -> tuple[str, str, list[str] | None] | None:
"""Parse one authorized_keys line into (pubkey, name, categories).
Line format: ``<pubkey> [name words...] [allow:cat,cat,...]``. The optional
``allow:`` token may appear anywhere after the pubkey (conventionally last);
the remaining words form the name. ``categories`` is None when no ``allow:``
token is present (the key falls back to the server-wide policy), or a list of
category strings (possibly empty) otherwise.
"""
line = line.strip()
if not line or line.startswith("#"):
return None
tokens = line.split()
pubkey = tokens[0]
categories: list[str] | None = None
name_tokens: list[str] = []
for tok in tokens[1:]:
if tok.startswith("allow:"):
categories = [c for c in tok[len("allow:"):].split(",") if c]
else:
name_tokens.append(tok)
return pubkey, " ".join(name_tokens), categories
def format_authorized_line(pub_hex: str, name: str = "", categories: list[str] | None = None) -> str:
"""Render an authorized_keys line. Inverse of :func:`_parse_authorized_line`."""
parts = [pub_hex]
if name:
parts.append(name)
if categories is not None:
parts.append("allow:" + ",".join(categories))
return " ".join(parts)
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
return [(pubkey, name) for pubkey, name, _cats in load_authorized_keys_with_policies(path)]
def load_authorized_keys_with_policies(path: Path) -> list[tuple[str, str, list[str] | None]]:
"""Return list of (pubkey_hex, name, categories) triples. categories is None when unset."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
parsed = _parse_authorized_line(line)
if parsed is not None:
result.append(parsed)
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: list[str] | None = None) -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = format_authorized_line(pub_hex, name, categories) + "\n"
with open(path, "a", encoding="utf-8") as file:
file.write(line)
return True
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
"""Update the per-key policy for a trusted key.
``identifier`` may be the full public key or an exact key name. ``categories``
is written as the ``allow:`` token; ``None`` removes the token so the key uses
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
no key matched, and raises ``ValueError`` for ambiguous names.
"""
if not path.exists():
return None
wanted = identifier.strip()
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
matches: list[tuple[int, str, str, str]] = []
for index, line in enumerate(lines):
parsed = _parse_authorized_line(line)
if parsed is None:
continue
pubkey, name, _cats = parsed
if pubkey.lower() == wanted.lower() or (name and name == wanted):
newline = "\n" if line.endswith("\n") else ""
matches.append((index, pubkey, name, newline))
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
index, pubkey, name, newline = matches[0]
lines[index] = format_authorized_line(pubkey, name, categories) + newline
path.write_text("".join(lines), encoding="utf-8")
return pubkey, name
+65
View File
@@ -0,0 +1,65 @@
"""Post-quantum ML-KEM key exchange and app-layer transport encryption."""
from __future__ import annotations
import secrets
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from browser_cli.constants import PQ_TRANSPORT_ALG
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
private_key = mlkem.MLKEM768PrivateKey.generate()
public_key = private_key.public_key().public_bytes_raw()
return private_key, public_key
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
public_key = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = public_key.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
+42
View File
@@ -0,0 +1,42 @@
"""Canonical browser-cli auth payload signing and verification."""
from __future__ import annotations
import hashlib
import json
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from browser_cli.auth.agent import AgentKey, agent_sign_raw
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{key: value for key, value in msg.items() if key not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
+17 -22
View File
@@ -5,9 +5,6 @@ browser-cli — Control your running browser from the terminal.
import click
import os
import shutil
import re
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
from rich.console import Console
from browser_cli.commands.navigate import nav_group
@@ -20,15 +17,22 @@ from browser_cli.commands.session import session_group
from browser_cli.commands.search import search_group
from browser_cli.commands.page import page_group
from browser_cli.commands.storage import storage_group
from browser_cli.commands.cookies import cookies_group
from browser_cli.commands.perf import perf_group
from browser_cli.commands.extension import extension_group
from browser_cli.commands.serve import cmd_serve
from browser_cli.commands.link_serve import cmd_link_serve
from browser_cli.commands.auth import auth_group
from browser_cli.commands.clients import clients_group
from browser_cli.commands.completion import cmd_completion
from browser_cli.commands.install import cmd_install
from browser_cli.commands.doctor import cmd_doctor
from browser_cli.commands.events import cmd_events
from browser_cli.commands.remote import remote_group
from browser_cli.commands.script import cmd_script
from browser_cli.commands.serve_http import cmd_serve_http
from browser_cli.commands.watch import watch_group
from browser_cli.commands.workspace import workspace_group
from browser_cli.commands.raw import cmd_command
from browser_cli.version_manager import project_version as _project_version
console = Console()
@@ -46,21 +50,6 @@ def _patched_group_shell_complete(self, ctx, incomplete):
click.Group.shell_complete = _patched_group_shell_complete
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
try:
return package_version("browser-cli")
except PackageNotFoundError:
return "unknown"
def _print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
@@ -118,14 +107,20 @@ main.add_command(session_group)
main.add_command(search_group)
main.add_command(page_group)
main.add_command(storage_group)
main.add_command(cookies_group)
main.add_command(perf_group)
main.add_command(extension_group)
main.add_command(cmd_serve)
main.add_command(cmd_link_serve)
main.add_command(clients_group)
main.add_command(cmd_completion)
main.add_command(cmd_install)
main.add_command(cmd_doctor)
main.add_command(cmd_events)
main.add_command(remote_group)
main.add_command(cmd_script)
main.add_command(cmd_serve_http)
main.add_command(watch_group)
main.add_command(workspace_group)
main.add_command(cmd_command)
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
+4
View File
@@ -7,9 +7,11 @@ from browser_cli.client.core import (
_send_remote,
_send_remote_async,
active_browser_targets,
collect_browser_clients,
remote_browser_targets,
remote_browser_targets_async,
remote_target_for_alias,
remote_targets_for_alias,
send_command,
send_command_async,
)
@@ -38,10 +40,12 @@ __all__ = [
"_send_remote",
"_send_remote_async",
"active_browser_targets",
"collect_browser_clients",
"display_browser_name",
"remote_browser_targets",
"remote_browser_targets_async",
"remote_target_for_alias",
"remote_targets_for_alias",
"send_command",
"send_command_async",
]
-4
View File
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
private_key = load_private_key(key_spec)
if key is not None:
remote_registry.save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS:
@@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil
msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
private_key = await asyncio.to_thread(load_private_key, key_spec)
if key is not None:
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS:
+234 -24
View File
@@ -15,22 +15,58 @@ from browser_cli import local_transport
from browser_cli.client import auth, messages, targets as target_discovery
from browser_cli.client.targets import BrowserTarget
from browser_cli.remote import registry as remote_registry
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _remote_display_name
from browser_cli.endpoints import _remote_display_name, display_browser_name
from browser_cli.registry import load_registry
from browser_cli.remote.transport import _send_remote, _send_remote_async
def _run_concurrent(factories: list) -> list:
"""Run async thunks concurrently, returning results in order.
Each item in *factories* is a zero-arg callable returning a coroutine. The
return list mirrors the input order; a thunk that raises yields its exception
object in that slot (callers filter as they would in a sequential loop). Falls
back to sequential execution if an event loop is already running on this
thread (e.g. inside the async serve handler), where ``asyncio.run`` is illegal.
"""
if not factories:
return []
async def _gather():
return await asyncio.gather(*(factory() for factory in factories), return_exceptions=True)
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_gather())
# An event loop is already running on this thread (e.g. the async serve
# handler), where asyncio.run is illegal. Run the gather on a worker thread
# that has no loop of its own, preserving concurrency and result order.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(lambda: asyncio.run(_gather())).result()
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for item in items or []:
profile = str(item.get("profile") or "default")
display = str(item.get("displayName") or profile)
display_name = _remote_display_name(endpoint, profile, display)
browser_name = item.get("browserName") or item.get("name")
version = item.get("version")
extension_version = item.get("extensionVersion")
targets.append(
BrowserTarget(
profile=profile,
display_name=_remote_display_name(endpoint, profile, display),
display_name=display_name,
socket_path="",
remote=endpoint,
browser_name=str(browser_name) if browser_name else None,
display_group=display_name.rsplit(":", 1)[0],
version=str(version) if version else None,
extension_version=str(extension_version) if extension_version else None,
)
)
return targets
@@ -44,23 +80,37 @@ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool
)
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
endpoints = list(remote_registry.load_remotes())
if not endpoints:
return []
results = _run_concurrent([
(lambda ep=ep: asyncio.to_thread(remote_browser_targets, ep, key=key, suppress_pq_warning=suppress_pq_warning))
for ep in endpoints
])
targets: list[BrowserTarget] = []
for endpoint in remote_registry.load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
except (BrowserNotConnected, RuntimeError):
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
targets.extend(result)
return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
"""Return remote targets matching a user-facing alias.
Exact browser aliases such as ``host:profile`` return one target. Endpoint
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
lets read/list SDK commands fan out while command dispatch can still reject
the ambiguous target.
"""
if not alias:
return None
targets = _remote_browser_targets()
return []
targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
for target in targets:
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
if alias in {target.display_name, endpoint_profile}:
return target
return [target]
endpoint_matches = []
for target in targets:
@@ -69,16 +119,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
remote_host, sep, _remote_port = target.remote.rpartition(":")
if alias == target.remote or (sep and alias == remote_host):
endpoint_matches.append(target)
if len(endpoint_matches) == 1:
return endpoint_matches[0]
if len(endpoint_matches) > 1:
aliases = [target.profile for target in endpoint_matches]
endpoint = endpoint_matches[0].remote or alias
return endpoint_matches
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
matches = remote_targets_for_alias(alias)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
aliases = [target.profile for target in matches]
endpoint = matches[0].remote or alias or "remote"
examples = "\n".join(
f" browser-cli --remote {endpoint} --browser {a} ..."
for a in aliases
)
display_aliases = [target.display_name for target in endpoint_matches]
display_aliases = [target.display_name for target in matches]
shorthand_examples = "\n".join(
f" browser-cli --browser {a} ..."
for a in display_aliases
@@ -96,6 +151,160 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
return targets
def _cached_client_row(target: BrowserTarget) -> dict | None:
"""Build a clients row from a target's discovery data, skipping a roundtrip.
Returns None when the remote didn't advertise its version (older serve), so
callers fall back to an explicit ``clients.list`` query.
"""
if target.version is None and target.extension_version is None:
return None
return {
"profile": target.display_name,
"profileGroup": target.display_group,
"name": target.browser_name or "",
"version": target.version or "",
"extensionVersion": target.extension_version or "",
}
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
rows = []
for item in result or []:
row = dict(item)
row["profile"] = label
if profile_group:
row["profileGroup"] = profile_group
rows.append(row)
return rows
async def _client_rows_async(
label: str,
*,
profile: str | None = None,
remote: str | None = None,
key=None,
suppress_pq_warning: bool = False,
profile_group: str | None = None,
) -> list[dict]:
"""Return display-ready clients.list rows for one browser target."""
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
result = await asyncio.to_thread(
send_command,
"clients.list",
profile=profile,
remote=remote,
key=key,
**kwargs,
)
return _rows_from_result(result, label, profile_group)
def collect_browser_clients(
*,
browser_alias: str | None = None,
remote: str | None = None,
key=None,
registry_path=None,
) -> list[dict]:
"""Return display-ready browser client rows for CLI/SDK consumers.
Rows preserve the CLI-facing shape: ``profile``, optional ``profileGroup``,
``name``, ``version``, and ``extensionVersion``.
"""
rows: list[dict] = []
if not remote and browser_alias:
resolved = remote_target_for_alias(browser_alias)
if not resolved:
return rows
targets = remote_browser_targets(resolved.remote)
uncached = []
for target in targets:
cached = _cached_client_row(target)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=resolved.remote,
key=key,
profile_group=t.display_group,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
if remote:
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for item in result or []:
row = dict(item)
row["profile"] = row.get("profile") or browser_alias or "remote"
rows.append(row)
return rows
path = registry_path or target_discovery.REGISTRY_PATH
profiles: dict[str, str] = load_registry(path) if path.exists() else {}
local_items = list(profiles.items())
remote_targets = []
cached_remote_rows = [] # deferred so local profiles still render first
for target in active_browser_targets(suppress_pq_warning=True):
if target.remote is None:
continue
cached = _cached_client_row(target)
if cached is not None:
cached_remote_rows.append(cached) # discovery already carried version/extVersion — no extra roundtrip
else:
remote_targets.append(target)
factories = [
(lambda name=name, sock=sock: _client_rows_async(
display_browser_name(name, sock), profile=name, profile_group="local",
))
for name, sock in local_items
] + [
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=t.remote,
suppress_pq_warning=True,
profile_group=t.display_group,
))
for t in remote_targets
]
results = _run_concurrent(factories)
for (name, sock), result in zip(local_items, results[:len(local_items)]):
if isinstance(result, (BrowserNotConnected, RuntimeError)):
rows.append({
"profile": display_browser_name(name, sock),
"profileGroup": "local",
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
elif isinstance(result, BaseException):
raise result
else:
rows.extend(result)
for result in results[len(local_items):]:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
rows.extend(cached_remote_rows)
return rows
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
@@ -137,18 +346,19 @@ def send_command(
response = (
_send_remote(remote_endpoint, msg, private_key)
if remote_endpoint
else local_transport.send_local_sync(profile, payload, target_discovery.resolve_socket)
else local_transport.send_local_sync(requested_profile, payload, target_discovery.resolve_socket)
)
except (FileNotFoundError, ConnectionRefusedError, OSError):
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
return messages.decode_response(response)
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
async def remote_browser_targets_async(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
"""Async variant of :func:`remote_browser_targets`."""
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
return _remote_target_items(
endpoint,
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
await send_command_async("browser-cli.targets", remote=endpoint, key=key, **kwargs),
)
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
@@ -192,9 +402,9 @@ async def send_command_async(
response = (
await _send_remote_async(remote_endpoint, msg, private_key)
if remote_endpoint
else await local_transport.send_local_async(profile, payload, target_discovery.resolve_socket)
else await local_transport.send_local_async(requested_profile, payload, target_discovery.resolve_socket)
)
except (FileNotFoundError, ConnectionRefusedError, OSError):
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
return messages.decode_response(response)
+7
View File
@@ -17,6 +17,13 @@ class BrowserTarget:
display_name: str
socket_path: str
remote: str | None = None
browser_name: str | None = None
display_group: str | None = None
# Populated from a remote ``browser-cli.targets`` response when the remote is
# new enough to advertise them, letting ``clients`` skip a redundant
# ``clients.list`` roundtrip. None means "unknown — fall back to a query".
version: str | None = None
extension_version: str | None = None
def is_reachable_unix_endpoint(endpoint: str) -> bool:
"""Return True when a Unix socket path exists and accepts connections."""
+133
View File
@@ -0,0 +1,133 @@
"""Safety policy for generic command execution surfaces.
Dedicated first-party CLI/SDK methods keep their normal behavior. This module
only gates raw surfaces where a single string can trigger arbitrary browser
capabilities: ``browser-cli command``, ``browser-cli script``, and the HTTP
``/command`` endpoint.
"""
from __future__ import annotations
from dataclasses import dataclass
SAFE_COMMANDS = {
"browser-cli.targets",
"clients.list",
"extension.capabilities",
"extension.info",
"group.list",
"group.query",
"group.tabs",
"page.info",
"perf.status",
"tabs.active_in_window",
"tabs.count",
"tabs.filter",
"tabs.list",
"tabs.query",
"tabs.status",
"windows.list",
}
READ_PAGE_COMMANDS = {
"dom.attr",
"dom.exists",
"dom.query",
"dom.text",
"extract.html",
"extract.images",
"extract.json",
"extract.links",
"extract.markdown",
"extract.text",
"tabs.html",
}
CONTROL_PREFIXES = (
"navigate.",
"nav.",
"group.",
"session.",
"tabs.",
"windows.",
)
CONTROL_COMMANDS = {
"dom.check",
"dom.clear",
"dom.click",
"dom.focus",
"dom.hover",
"dom.key",
"dom.poll",
"dom.scroll",
"dom.select",
"dom.submit",
"dom.type",
"dom.uncheck",
"dom.wait_for",
"extension.reload",
}
DANGEROUS_COMMANDS = {
"dom.eval",
"tabs.screenshot",
}
DANGEROUS_PREFIXES = (
"storage.",
)
# Server-side key-management control commands. Gated separately so a key can be
# trusted for browser use without also being able to list or add trusted keys.
KEY_COMMANDS = {
"browser-cli.auth.keys",
"browser-cli.auth.trust",
"browser-cli.auth.policy",
}
@dataclass(frozen=True)
class CommandPolicy:
allow_read_page: bool = False
allow_control: bool = False
allow_dangerous: bool = False
allow_keys: bool = False
@classmethod
def unrestricted(cls) -> "CommandPolicy":
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True, allow_keys=True)
def _is_control(command: str) -> bool:
if command in CONTROL_COMMANDS:
return True
if any(command.startswith(prefix) for prefix in CONTROL_PREFIXES):
return command not in SAFE_COMMANDS and command not in READ_PAGE_COMMANDS and command not in DANGEROUS_COMMANDS
return False
def command_category(command: str) -> str:
name = str(command or "")
if name in KEY_COMMANDS:
return "keys"
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
return "dangerous"
if name in READ_PAGE_COMMANDS:
return "read-page"
if name in SAFE_COMMANDS:
return "safe"
if _is_control(name):
return "control"
return "unknown"
def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
category = command_category(command)
if category == "safe":
return
if category == "read-page" and policy.allow_read_page:
return
if category == "control" and policy.allow_control:
return
if category == "dangerous" and policy.allow_dangerous:
return
if category == "keys" and policy.allow_keys:
return
raise PermissionError(
f"Raw command '{command}' is {category} and blocked by default; "
"use --allow-read-page, --allow-control, --allow-dangerous, or --allow-keys explicitly"
)
+121 -44
View File
@@ -22,57 +22,134 @@ tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab I
def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
def command_policy_options(fn):
"""Reusable raw-command safety flags for /command-like entry points."""
fn = click.option(
"--allow-all",
is_flag=True,
help="Allow every command (equivalent to --allow-read-page --allow-control --allow-dangerous --allow-keys)",
)(fn)
fn = click.option(
"--allow-keys",
is_flag=True,
help="Allow key-management commands (list/trust authorized keys over --remote)",
)(fn)
fn = click.option(
"--allow-dangerous",
is_flag=True,
help="Allow high-risk commands such as dom.eval, storage.*, screenshots",
)(fn)
fn = click.option(
"--allow-control",
is_flag=True,
help="Allow browser-control commands such as nav.*, tabs.close, dom.click",
)(fn)
fn = click.option(
"--allow-read-page",
is_flag=True,
help="Allow page-content read commands such as extract.* and dom.text",
)(fn)
return fn
def command_policy_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
"""Build a CommandPolicy from shared raw-command safety flags."""
from browser_cli.command_security import CommandPolicy
if allow_all:
return CommandPolicy.unrestricted()
return CommandPolicy(
allow_read_page=allow_read_page,
allow_control=allow_control,
allow_dangerous=allow_dangerous,
allow_keys=allow_keys,
)
def command_categories_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
"""Convert the shared --allow-* flags into a category list, or None if none were set.
None means "no explicit policy" — the key falls back to the server-wide default.
"""
if allow_all:
return ["all"]
cats = []
if allow_read_page:
cats.append("read-page")
if allow_control:
cats.append("control")
if allow_dangerous:
cats.append("dangerous")
if allow_keys:
cats.append("keys")
return cats or None
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result.
"""Render a count result.
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items():
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser", no_wrap=True)
table.add_column(f"{noun.capitalize()}s", justify="right")
rendered_groups: set[str] = set()
for name, count in result.by_browser.items():
group = result.browser_groups.get(name)
if group:
if group not in rendered_groups:
group_total = sum(
browser_count
for browser_name, browser_count in result.by_browser.items()
if result.browser_groups.get(browser_name) == group
)
table.add_row(f"[bold]{group}[/bold]", str(group_total))
rendered_groups.add(group)
display_name = name.removeprefix(f"{group}:")
table.add_row(f" {display_name}", str(count))
else:
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options.
"""Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
return wrapper
return wrapper
+288 -9
View File
@@ -8,6 +8,8 @@ from pathlib import Path
import click
from rich.console import Console
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
console = Console()
@click.group("auth")
@@ -39,9 +41,15 @@ def cmd_auth_keygen(output, force):
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@command_policy_options
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
@handle_errors
def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Add a public key to the authorized keys file (locally or on a remote serve host).
Pass --allow-read-page/--allow-control/--allow-dangerous/--allow-all to record a
per-key policy (an ``allow:`` token); without any, the key uses the server default.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
@@ -53,34 +61,125 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
policy_label = f" [dim]allow:{','.join(categories)}[/dim]" if categories else ""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
args = {"pubkey": pubkey, "name": name}
if categories is not None:
args["allow"] = categories
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
args=args,
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]{policy_label}")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey, name)
added = add_authorized_key(path, pubkey, name, categories)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]{policy_label}")
console.print(f" File: {path}")
console.print("\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("policy")
@click.argument("identifier", required=False)
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
@command_policy_options
@click.pass_context
@handle_errors
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Change a trusted key's per-key policy.
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
an interactive terminal to pick a key first, then edit the policy with real
checkbox prompts. Use --safe for an explicit safe-only override,
--server-default to remove the override, or one or more --allow-* flags for
scriptable/non-interactive usage.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
if modes > 1:
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
sys.exit(1)
is_interactive = click.get_text_stream("stdin").isatty()
current_categories = None
if not identifier:
if not is_interactive:
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
sys.exit(1)
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
identifier = entry.get("pubkey") or entry.get("name") or ""
current_categories = entry.get("allow")
elif modes == 0 and is_interactive:
entry = _find_policy_entry(ctx, keys_file, identifier)
current_categories = entry.get("allow") if entry else None
if server_default:
categories = None
elif safe_only:
categories = []
elif explicit_allow:
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
else:
if not is_interactive:
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
sys.exit(1)
categories = _prompt_policy_categories(identifier, current_categories)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.policy",
args={"identifier": identifier, "allow": categories},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
name = (result or {}).get("name") or ""
pubkey = (result or {}).get("pubkey") or identifier
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
try:
updated = set_authorized_key_policy(path, identifier, categories)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
if updated is None:
console.print(f"[red]Trusted key not found:[/red] {identifier}")
sys.exit(1)
pubkey, name = updated
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
console.print(f" File: {path}")
@auth_group.command("show")
@click.option(
"--key",
@@ -123,6 +222,7 @@ def cmd_auth_show(key_src):
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
@handle_errors
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
@@ -138,9 +238,9 @@ def cmd_auth_keys(ctx, keys_file):
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
entries = [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
source_label = str(path)
if not entries:
@@ -151,7 +251,186 @@ def cmd_auth_keys(ctx, keys_file):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
table.add_column("Policy")
table.add_column("Description")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
allow = entry.get("allow")
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
console.print(table)
def _load_policy_entries(ctx, keys_file):
"""Load trusted-key entries for interactive selection."""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
return send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
) or []
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
def _find_policy_entry(ctx, keys_file, identifier: str):
"""Find the current key entry so the checkbox prompt can preselect values."""
wanted = identifier.strip()
for entry in _load_policy_entries(ctx, keys_file):
pubkey = str(entry.get("pubkey") or "")
name = str(entry.get("name") or "")
if pubkey.lower() == wanted.lower() or (name and name == wanted):
return entry
return None
def _prompt_key_entry(entries):
"""Interactive checkbox flow step 1: choose which key to edit."""
if not entries:
raise click.ClickException("no trusted keys found")
import questionary
choices = []
for entry in entries:
name = entry.get("name") or "unnamed key"
pubkey = entry.get("pubkey") or ""
policy = _plain_policy_label(entry.get("allow"))
choices.append(questionary.Choice(
title=f"{name} [{policy}] {pubkey[:12]}{pubkey[-8:]}",
value=entry,
))
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
if selected is None:
raise click.ClickException("cancelled")
return selected
def _prompt_policy_categories(identifier: str, current_categories=None):
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
import questionary
checked = set(current_categories or [])
special_checked = {
"__server_default__": current_categories is None,
"__safe__": current_categories == [],
"__all__": isinstance(current_categories, list) and "all" in current_categories,
}
choices = [
questionary.Choice(
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
value="read-page",
checked="read-page" in checked,
),
questionary.Choice(
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
value="control",
checked="control" in checked,
),
questionary.Choice(
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
value="dangerous",
checked="dangerous" in checked,
),
questionary.Choice(
title="keys — admin access to key management over --remote: auth keys/trust/policy",
value="keys",
checked="keys" in checked,
),
questionary.Separator(),
questionary.Choice(
title="all — allow everything",
value="__all__",
checked=special_checked["__all__"],
),
questionary.Choice(
title="safe — explicit safe-only override",
value="__safe__",
checked=special_checked["__safe__"],
),
questionary.Choice(
title="server default — remove per-key override and inherit server policy",
value="__server_default__",
checked=special_checked["__server_default__"],
),
]
selected = questionary.checkbox(
f"Policy for {identifier}",
choices=choices,
instruction="(space to toggle, enter to save)",
).ask()
if selected is None:
raise click.ClickException("cancelled")
return _parse_checkbox_policy_selection(selected)
def _parse_checkbox_policy_selection(selected):
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
if len(special) > 1 or (special and normal):
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
if special == ["__server_default__"]:
return None
if special == ["__safe__"]:
return []
if special == ["__all__"]:
return ["all"]
return normal
def _parse_policy_selection(raw: str):
value = raw.strip().lower()
if value in {"default", "server-default", "server default", "inherit", "none"}:
return None
if value in {"safe", "safe-only", ""}:
return []
if value == "all":
return ["all"]
number_map = {
"1": "read-page",
"2": "control",
"3": "dangerous",
"4": "keys",
}
valid = {"read-page", "control", "dangerous", "keys"}
categories = []
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
category = number_map.get(token, token)
if category == "all":
return ["all"]
if category not in valid:
raise click.ClickException(f"unknown policy choice: {token}")
if category not in categories:
categories.append(category)
return categories
def _plain_policy_label(categories) -> str:
"""Plain-text policy label for interactive prompt titles."""
if categories is None:
return "server default"
if "all" in categories:
return "all"
return ", ".join(categories) if categories else "safe"
def _policy_label(categories) -> str:
"""Render an authorized_keys ``allow:`` token for display."""
if categories is None:
return "[dim]server default[/dim]"
if "all" in categories:
return "[yellow]all[/yellow]"
return ", ".join(categories) if categories else "safe"
def _policy_description(categories) -> str:
"""Human-readable explanation for a policy category list."""
if categories is None:
return "Inherits the policy from browser-cli serve"
if "all" in categories:
return "Full access: page reads, browser control, dangerous commands, key admin"
if not categories:
return "Safe status/list commands only"
descriptions = {
"read-page": "read page content",
"control": "control browser/tabs/page input",
"dangerous": "run high-risk commands",
"keys": "manage trusted keys remotely",
}
return "; ".join(descriptions.get(category, category) for category in categories)
+34 -98
View File
@@ -11,11 +11,10 @@ from browser_cli.client import (
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
remote_browser_targets,
remote_target_for_alias,
collect_browser_clients,
send_command,
)
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.registry import load_registry
console = Console()
@@ -36,21 +35,6 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
"""Query clients.list for one target and append each, tagged with *label*."""
if quiet_remote_warning:
result = send_command(
"clients.list",
profile=profile,
remote=remote,
key=key,
suppress_pq_warning=True,
)
else:
result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []):
c["profile"] = label
into.append(c)
@click.group("clients", invoke_without_command=True)
@click.pass_context
@@ -59,18 +43,20 @@ def clients_group(ctx):
if ctx.invoked_subcommand is not None:
return
all_clients = []
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
_collect_remote_alias_clients(all_clients, browser_alias, key)
elif remote:
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
else:
_collect_local_and_saved_remote_clients(all_clients)
try:
all_clients = collect_browser_clients(
browser_alias=browser_alias,
remote=remote,
key=key,
registry_path=REGISTRY_PATH,
)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
@@ -78,77 +64,24 @@ def clients_group(ctx):
_print_clients(all_clients)
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
resolved = remote_target_for_alias(browser_alias)
if not resolved:
return
try:
targets = remote_browser_targets(resolved.remote)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
for target in targets:
try:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
except (BrowserNotConnected, RuntimeError):
continue
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
try:
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for c in (result or []):
c["profile"] = c.get("profile") or browser_alias or "remote"
all_clients.append(c)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
_append_clients(all_clients, display_profile, profile=profile_name)
except (BrowserNotConnected, RuntimeError):
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
targets = active_browser_targets(suppress_pq_warning=True)
for target in targets:
if target.remote is None:
continue
try:
_append_clients(
all_clients,
target.display_name,
profile=target.profile,
remote=target.remote,
quiet_remote_warning=True,
)
except (BrowserNotConnected, RuntimeError):
continue
def _print_clients(all_clients: list) -> None:
from rich.table import Table
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Version")
table.add_column("Extension Version")
for c in all_clients:
table.add_row(
c.get("profile", ""),
c.get("name", ""),
c.get("version", ""),
c.get("extensionVersion", ""),
)
console.print(table)
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
grouped = bool(groups and groups != {"local"})
columns = [
("Browser", lambda item: item.get("name", "")),
("Version", lambda item: item.get("version", "")),
("Extension Version", lambda item: item.get("extensionVersion", "")),
]
print_browser_grouped_table_rows(
all_clients,
columns,
console=console,
empty_message="[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]",
browser_getter=lambda item: item.get("profile", ""),
group_getter=lambda item: item.get("profileGroup", "") if grouped else "",
browser_header="Profile",
)
@clients_group.command("rename")
@click.option(
@@ -159,11 +92,14 @@ def _print_clients(all_clients: list) -> None:
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
)
@click.argument("alias")
def cmd_clients_rename(target_browser, alias):
@click.pass_context
def cmd_clients_rename(ctx, target_browser, alias):
"""Set the profile alias used to identify this browser instance."""
root_obj = ctx.find_root().obj or {}
selected_browser = target_browser or root_obj.get("browser")
try:
_ensure_unique_browser_alias(alias, target_browser)
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
_ensure_unique_browser_alias(alias, selected_browser)
send_command("clients.rename_profile", {"alias": alias}, profile=selected_browser)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
-72
View File
@@ -1,72 +0,0 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
@click.group("cookies")
def cookies_group():
"""Manage browser cookies."""
@cookies_group.command("list")
@click.option("--url", default=None, help="Filter by URL")
@click.option("--domain", default=None, help="Filter by domain")
@click.option("--name", default=None, help="Filter by cookie name")
@handle_errors
def cookies_list(url, domain, name):
"""List cookies, optionally filtered by URL, domain, or name."""
cookies = client_from_ctx().cookies.list(url=url, domain=domain, name=name)
if not cookies:
console.print("[yellow]No cookies found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Value")
table.add_column("Domain")
table.add_column("Path")
table.add_column("Secure", width=7)
table.add_column("HttpOnly", width=9)
for c in cookies:
table.add_row(
c.get("name", ""),
(c.get("value") or "")[:60],
c.get("domain", ""),
c.get("path", ""),
"[green]✓[/green]" if c.get("secure") else "",
"[green]✓[/green]" if c.get("httpOnly") else "",
)
console.print(table)
@cookies_group.command("get")
@click.argument("url")
@click.argument("name")
@handle_errors
def cookies_get(url, name):
"""Get the value of a single cookie by URL and NAME."""
cookie = client_from_ctx().cookies.get(url, name)
if cookie is None:
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
raise SystemExit(1)
console.print(cookie.get("value", ""))
@cookies_group.command("set")
@click.argument("url")
@click.argument("name")
@click.argument("value")
@click.option("--domain", default=None)
@click.option("--path", default=None)
@click.option("--secure", is_flag=True)
@click.option("--http-only", "http_only", is_flag=True)
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
@handle_errors
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
"""Set a cookie on URL."""
client_from_ctx().cookies.set(
url, name, value,
domain=domain, path=path,
secure=secure or None, http_only=http_only or None,
expiration_date=expiration_date, same_site=same_site,
)
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import shutil
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import handle_errors, client_from_ctx
from browser_cli.client import active_browser_targets
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME
from browser_cli.platform import is_windows
from browser_cli.version_manager import project_version
console = Console()
def _status(ok: bool) -> str:
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
@click.command("doctor")
@click.option("--remote", "check_remote", is_flag=True, help="Also probe the configured remote endpoint")
@handle_errors
def cmd_doctor(check_remote):
"""Diagnose browser-cli installation, extension, and connection health."""
rows: list[tuple[str, bool, str]] = []
version = project_version()
rows.append(("Python package", version != "unknown", version))
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
manifest_notes = []
if not is_windows():
import sys
platform = "darwin" if sys.platform == "darwin" else "linux"
for browser, by_platform in NATIVE_HOST_DIRS.items():
for directory in by_platform.get(platform, []):
path = directory / f"{NATIVE_HOST_NAME}.json"
if path.exists():
manifest_notes.append(f"{browser}: {path}")
rows.append(("Native host manifest", bool(manifest_notes), "; ".join(manifest_notes) or "not found for common browsers"))
try:
targets = active_browser_targets(include_remotes=check_remote)
rows.append(("Browser registry", bool(targets), f"{len(targets)} active target(s)"))
except Exception as exc:
rows.append(("Browser registry", False, str(exc)))
client = client_from_ctx()
try:
clients = client.clients()
rows.append(("Connection", True, f"{len(clients)} client(s) responded"))
ext_versions = sorted({str(c.get("extensionVersion", "unknown")) for c in clients if isinstance(c, dict)})
if ext_versions:
rows.append(("Extension version", version in ext_versions, ", ".join(ext_versions)))
except Exception as exc:
rows.append(("Connection", False, str(exc)))
try:
info = client.extension.info()
caps = info.get("capabilities") or []
rows.append(("Extension info", True, f"v{info.get('version', 'unknown')} · {len(caps)} capabilities"))
except Exception as exc:
rows.append(("Extension info", False, f"not available ({exc})"))
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Check")
table.add_column("Status")
table.add_column("Details")
for name, ok, detail in rows:
table.add_row(name, _status(ok), detail)
console.print(table)
failed = [name for name, ok, _ in rows if not ok and name in {"Connection", "Browser registry"}]
if failed:
raise SystemExit(1)
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import json
import time
from dataclasses import asdict, is_dataclass
import click
from rich.console import Console
from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
def _snapshot(client):
tabs = client.tabs.list()
return {str(t.id): asdict(t) if is_dataclass(t) else dict(t) for t in tabs}
def _emit(event, json_output: bool):
if json_output:
click.echo(json.dumps(event, default=str), flush=True)
else:
kind = event.get("type")
tab = event.get("tab") or {}
console.print(f"[cyan]{kind}[/cyan] {tab.get('id', '')} {tab.get('title') or ''} [dim]{tab.get('url') or ''}[/dim]")
@click.command("events")
@click.option("--interval", type=float, default=1.0, show_default=True, help="Polling interval in seconds")
@click.option("--once", is_flag=True, help="Emit initial snapshot and exit")
@click.option("--json", "json_output", is_flag=True, default=True, help="Emit JSON Lines (default)")
@click.option("--pretty", is_flag=True, help="Render human-readable events instead of JSON")
@handle_errors
def cmd_events(interval: float, once: bool, json_output: bool, pretty: bool):
"""Stream tab events as JSON Lines using a lightweight polling watcher."""
json_output = json_output and not pretty
client = client_from_ctx()
previous = _snapshot(client)
for tab in previous.values():
_emit({"type": "tabs.snapshot", "tab": tab}, json_output)
if once:
return
while True:
time.sleep(interval)
current = _snapshot(client)
for tab_id, tab in current.items():
if tab_id not in previous:
_emit({"type": "tabs.created", "tab": tab}, json_output)
elif tab != previous[tab_id]:
_emit({"type": "tabs.updated", "tab": tab, "previous": previous[tab_id]}, json_output)
for tab_id, tab in previous.items():
if tab_id not in current:
_emit({"type": "tabs.closed", "tab": tab}, json_output)
previous = current
+21
View File
@@ -8,6 +8,27 @@ console = Console()
def extension_group():
"""Manage the browser-cli browser extension."""
@extension_group.command("info")
@handle_errors
def extension_info():
"""Show extension version and advertised capabilities."""
info = client_from_ctx().extension.info()
for key in ("name", "version", "id", "platform"):
if key in info:
console.print(f"[bold]{key}:[/bold] {info[key]}")
caps = info.get("capabilities") or []
if caps:
console.print("[bold]capabilities:[/bold]")
for cap in caps:
console.print(f" - {cap}")
@extension_group.command("capabilities")
@handle_errors
def extension_capabilities():
"""Print extension feature capability strings."""
for cap in client_from_ctx().extension.capabilities():
console.print(cap)
@extension_group.command("reload")
@handle_errors
def extension_reload():
+9 -23
View File
@@ -1,33 +1,19 @@
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from rich.console import Console
from rich.table import Table
console = Console()
def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Name")
table.add_column("Color", width=10)
table.add_column("Collapsed", width=10)
table.add_column("Tabs", width=6)
for g in groups:
row = [
(g.browser or "") if show_browser else None,
str(g.id),
g.title or "",
g.color or "",
"yes" if g.collapsed else "no",
str(g.tab_count),
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns = [
("ID", lambda g: g.id),
("Name", lambda g: g.title or ""),
("Color", lambda g: g.color or ""),
("Collapsed", lambda g: "yes" if g.collapsed else "no"),
("Tabs", lambda g: g.tab_count),
]
print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
@click.group("groups")
def group_group():
+66 -21
View File
@@ -9,10 +9,15 @@ import click
from rich.console import Console
from browser_cli.constants import (
ALLOWED_EXTENSION_IDS,
CHROME_WEBSTORE_URL,
EXTENSION_ID,
FIREFOX_ADDON_URL,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS,
NATIVE_HOST_NAME,
SUPPORTED_BROWSERS,
WEBSTORE_EXTENSION_ID,
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
)
from browser_cli.platform import install_base_dir, is_windows
@@ -59,31 +64,18 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
@click.command("install")
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
def cmd_install(browser):
"""Register the native messaging host and print extension load instructions."""
@click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
def cmd_install(browser, dev):
"""Register the native messaging host and print extension install instructions."""
host_exe = native_host_exe()
write_native_host_exe(host_exe)
ext_url = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
}[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
if dev:
_print_dev_instructions(browser)
else:
_print_store_instructions(browser)
manifest = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{EXTENSION_ID}/"],
}
manifest = _native_host_manifest(browser, host_exe)
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
@@ -97,6 +89,59 @@ def cmd_install(browser):
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _print_store_instructions(browser: str) -> None:
console.print("\n[bold]Step 1:[/bold] Install the extension")
if browser == "firefox":
console.print(" Open Firefox Add-ons and click [bold]Add to Firefox[/bold]:")
console.print(f" [cyan]{FIREFOX_ADDON_URL}[/cyan]")
console.print(" [dim]Firefox support is experimental; tab-group commands require browser tab group APIs.[/dim]\n")
else:
console.print(f" Open the Chrome Web Store and click [bold]Add to {browser.capitalize()}[/bold]:")
console.print(f" [cyan]{CHROME_WEBSTORE_URL}[/cyan]")
console.print(" [dim]Brave, Edge, Vivaldi and Chromium can install from the Chrome Web Store too.[/dim]")
console.print(" [dim]Developing the extension? Run 'browser-cli install <browser> --dev' for the unpacked-load steps.[/dim]\n")
def _print_dev_instructions(browser: str) -> None:
ext_url = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[browser]
console.print("\n[bold]Step 1:[/bold] Load the unpacked extension (developer mode)")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox":
repo_root = Path(__file__).parent.parent.parent
firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
console.print(" 2. Build the Firefox-compatible temporary extension:")
console.print(" [cyan]npm run package:extension:firefox[/cyan]")
console.print(" 3. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 4. Select: [cyan]{firefox_manifest}[/cyan]")
console.print(" Do not select extension/manifest.json; Firefox currently rejects background.service_worker there.")
console.print(f" 5. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]")
console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
else:
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
base = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
}
if browser == "firefox":
return {**base, "allowed_extensions": [FIREFOX_EXTENSION_ID]}
return {
**base,
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
}
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
if is_windows():
manifest_dir = host_exe.parent
-163
View File
@@ -1,163 +0,0 @@
"""Expose this browser over a ServiceLink HTTP /rpc endpoint.
This lets the other nodes (picoshare, website) drive the browser through the
shared servicelink envelope, reusing browser-cli's own wire commands verbatim:
a link method like `tabs.list` forwards straight to `send_command_async`.
It is separate from `serve` (the Ed25519/TCP remote-control daemon); use this
when you want a node in the mesh to call the browser with a bearer token.
servicelink (and its httpx dependency) is imported lazily inside the command so
that a missing optional dependency never breaks the rest of the CLI.
"""
import asyncio
import hmac
import sys
from pathlib import Path
import click
from browser_cli.client.core import send_command_async
from browser_cli.errors import BrowserNotConnected
# Curated set of browser commands exposed over the mesh. Method name == command.
EXPOSED_COMMANDS = [
"tabs.list", "tabs.open", "tabs.close", "tabs.active", "tabs.query",
"nav.open", "nav.reload", "nav.back", "nav.forward",
"dom.query", "dom.text", "dom.attr", "dom.exists", "dom.click",
"extract.links", "extract.images", "extract.text", "extract.markdown", "extract.html",
"page.info",
"session.save", "session.load", "session.list",
]
def _import_servicelink():
"""Import servicelink lazily; the flat submodule sits at the repo root."""
repo_root = Path(__file__).resolve().parents[2]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
try:
import servicelink
return servicelink
except ImportError as exc:
raise click.ClickException(
"servicelink could not be imported. Initialise the submodule "
"(`git submodule update --init`) and ensure httpx is installed."
) from exc
def _build_router(sl, profile):
router = sl.Router("browser-cli")
def make_handler(command):
async def handler(params, ctx):
try:
return await send_command_async(command, params or None, profile=profile)
except BrowserNotConnected as exc:
raise sl.Unavailable(f"browser not connected: {exc}")
except (RuntimeError, ConnectionError) as exc:
raise sl.LinkError(str(exc))
return handler
for command in EXPOSED_COMMANDS:
router.register(command, make_handler(command))
return router
def _make_verifier(sl, token):
if not token:
return None
async def verify(authorization, request):
presented = (authorization or "").split(" ", 1)[-1].strip()
# Constant-time compare so a wrong token can't be timed out character by character.
if not presented or not hmac.compare_digest(presented, token):
raise sl.Unauthorized("invalid or missing token")
return sl.Principal(subject="mesh", scopes=frozenset({"all", "mesh"}))
return verify
def _http_response(status, body, content_type):
head = (
f"HTTP/1.1 {status}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
).encode("latin-1")
return head + body
async def _handle_connection(sl, router, verify, reader, writer):
try:
request_line = await reader.readline()
if not request_line:
return
headers = {}
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
key, _, value = line.decode("latin-1").partition(":")
headers[key.strip().lower()] = value.strip()
length = int(headers.get("content-length", "0") or "0")
body = await reader.readexactly(length) if length else b""
if not request_line.upper().startswith(b"POST"):
writer.write(_http_response(405, b'{"error":"only POST /rpc is supported"}', "application/json"))
else:
status, payload, content_type = await sl.handle_envelope(
router,
body,
authorization=headers.get("authorization"),
verify=verify,
content_type=headers.get("content-type", "application/json"),
)
writer.write(_http_response(status, payload, content_type))
await writer.drain()
except Exception: # noqa: BLE001 - never let one connection kill the server
pass
finally:
writer.close()
async def _serve(sl, host, port, profile, token):
router = _build_router(sl, profile)
verify = _make_verifier(sl, token)
server = await asyncio.start_server(
lambda r, w: _handle_connection(sl, router, verify, r, w), host, port
)
click.echo(f"servicelink browser node listening on http://{host}:{port}/rpc")
async with server:
await server.serve_forever()
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
@click.command("link-serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8770, show_default=True, type=int, help="HTTP port for /rpc.")
@click.option("--token", default=None, metavar="SECRET",
help="Shared bearer token required from callers (sent as 'Authorization: Bearer ...').")
@click.option("--insecure", is_flag=True, default=False,
help="Run with NO token. Grants full browser control (cookies, pages) to anyone who can reach the port.")
@click.pass_context
def cmd_link_serve(ctx, host, port, token, insecure):
"""Serve this browser to the ServiceLink mesh over HTTP /rpc.
Exposes the running browser (open/scrape pages, read cookies and storage), so
a token is required by default. Bind to loopback and keep the port off the
public network.
"""
if not token and not insecure:
raise click.ClickException(
"Refusing to start without --token (this endpoint can control your browser "
"and read its cookies). Pass --insecure to override on a trusted host."
)
if host not in _LOOPBACK_HOSTS:
click.echo(
f"WARNING: binding to {host} (not loopback). Anyone who can reach this "
"address may control the browser; ensure it is firewalled.",
err=True,
)
if insecure and not token:
click.echo("WARNING: --insecure set; the endpoint is UNAUTHENTICATED.", err=True)
sl = _import_servicelink()
profile = ctx.obj.get("browser") if ctx.obj else None
try:
asyncio.run(_serve(sl, host, port, profile, token))
except KeyboardInterrupt:
pass
+13 -7
View File
@@ -10,13 +10,16 @@ def nav_group():
@nav_group.command("open")
@click.argument("url")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
@handle_errors
def cmd_open(url, bg, window_name, group_name):
"""Open URL in a new tab."""
client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name)
def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
"""Open URL in a new tab without stealing focus by default."""
client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
suffix = ""
if group_name:
suffix = f" in group '{group_name}'"
@@ -70,13 +73,16 @@ def cmd_focus(pattern):
@nav_group.command("open-wait")
@click.argument("url")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open in tab group")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
@handle_errors
def cmd_open_wait(url, timeout, bg, window_name, group_name):
def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
"""Open URL in a new tab and wait until fully loaded."""
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name)
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
console.print(f"[green]Loaded:[/green] {url}" + (f"{tab.title}" if tab.title else ""))
@nav_group.command("wait")
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import json
import click
from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
@click.command("command")
@click.argument("name")
@click.argument("args_json", required=False, default="{}")
@command_policy_options
@handle_errors
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Send a raw browser-cli wire command and print JSON."""
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
assert_command_allowed(name, policy)
args = json.loads(args_json) if args_json else {}
result = client_from_ctx().command(name, args)
click.echo(json.dumps(result, indent=2, default=str))
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
console = Console()
@click.group("remote")
def remote_group():
"""Manage remembered browser-cli remote endpoints."""
@remote_group.command("status")
@click.argument("endpoint")
@click.option("--key", default=None, help="Key spec/path to use for this probe")
@handle_errors
def remote_status(endpoint, key):
"""Probe a remote endpoint and show server/client status."""
client = BrowserCLI(remote=endpoint, key=key)
clients = [
{**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
for item in client.clients()
]
columns = [
("Browser", lambda item: item.get("name", "")),
("Extension", lambda item: item.get("extensionVersion", "")),
]
print_browser_grouped_table_rows(
clients,
columns,
console=console,
empty_message="[yellow]No browser clients found[/yellow]",
browser_getter=lambda item: item.get("profileLabel", ""),
group_getter=lambda item: item.get("profileGroup", ""),
browser_header="Profile",
)
@remote_group.command("trust")
@click.argument("endpoint")
@click.argument("key_spec")
def remote_trust(endpoint, key_spec):
"""Remember which key spec to use for ENDPOINT."""
save_remote_key(endpoint, key_spec)
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
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)
@remote_group.command("revoke")
@click.argument("endpoint")
def remote_revoke(endpoint):
"""Remove remembered key/config for ENDPOINT."""
remotes = load_remotes()
if endpoint not in remotes:
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
del remotes[endpoint]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
console.print(f"[green]Revoked {endpoint}[/green]")
@@ -0,0 +1,60 @@
"""Reusable rendering helpers for CLI command modules."""
from browser_cli.commands.rendering.common import (
Column,
item_value,
print_browser_grouped_table_rows,
print_table_rows,
print_tree,
shorten,
terminal_width,
tree_title_limit,
tree_url_limit,
)
from browser_cli.commands.rendering.labels import (
BROWSER_FAMILY_STYLES,
DEFAULT_BROWSER_STYLE,
DEFAULT_SCOPE,
browser_label_style,
group_tree_label,
no_wrap_text,
scoped_browser_label,
tab_tree_label,
)
from browser_cli.commands.rendering.tabs_tree import (
TabsTreeBuilder,
browser_label_key,
browser_scope,
build_tabs_tree,
tab_group_id,
tab_sort_key,
tab_window_id,
)
from browser_cli.commands.rendering.windows_tree import build_windows_tree
__all__ = [
"BROWSER_FAMILY_STYLES",
"Column",
"DEFAULT_BROWSER_STYLE",
"DEFAULT_SCOPE",
"TabsTreeBuilder",
"browser_label_key",
"browser_label_style",
"browser_scope",
"build_tabs_tree",
"build_windows_tree",
"group_tree_label",
"item_value",
"no_wrap_text",
"print_browser_grouped_table_rows",
"print_table_rows",
"print_tree",
"scoped_browser_label",
"shorten",
"tab_group_id",
"tab_sort_key",
"tab_tree_label",
"tab_window_id",
"terminal_width",
"tree_title_limit",
"tree_url_limit",
]
+137
View File
@@ -0,0 +1,137 @@
"""Common Rich rendering helpers for CLI command modules."""
from __future__ import annotations
import shutil
from collections.abc import Callable, Mapping, Sequence
from typing import TypeVar, cast
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
Row = object
CellValue = object
Column = tuple[str, Callable[[Row], CellValue]]
T = TypeVar("T")
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
"""Read *name* from a dict-like or attribute object."""
if isinstance(item, Mapping):
return cast(Mapping[str, CellValue], item).get(name, default)
return getattr(item, name, default)
def text_value(value: CellValue | None, default: str = "") -> str:
"""Coerce a nullable cell value to display text."""
return default if value is None else str(value)
def int_value(value: CellValue | None, default: int = 0) -> int:
"""Coerce a cell value to int, falling back when conversion is not possible."""
try:
return int(cast(int | str | float | bool, value))
except (TypeError, ValueError):
return default
def shorten(value: str | None, limit: int) -> str:
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
value = value or ""
return value if len(value) <= limit else value[:max(0, limit - 1)] + ""
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
"""Best-effort terminal width for interactive and redirected output.
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
and prefer the wider value.
"""
rich_width = (console.width if console is not None else 0) or 0
shell_width = shutil.get_terminal_size((fallback, 20)).columns
return max(rich_width, shell_width)
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
reserve = 48 if show_urls else 32
if show_browser:
reserve += 4
return max(50, terminal_width(console) - reserve)
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
"""URL width for tree labels when URLs are displayed."""
return max(35, terminal_width(console) - title_limit - 40)
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
"""Render a Rich tree using the detected full terminal width."""
Console(width=terminal_width(console)).print(tree)
def print_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render a small Rich table from arbitrary row objects."""
if not rows:
console.print(empty_message)
return
table = Table(show_header=show_header, header_style=header_style)
for header, _getter in columns:
table.add_column(header)
for row in rows:
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
def print_browser_grouped_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
browser_header: str = "Browser",
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render rows with optional local/remote browser grouping.
Rows without a browser label are rendered as a normal table. Rows with
``browser_group``/``browserGroup`` get a group header (for example ``local``
or a remote host) and a short indented profile label below it.
"""
if not rows:
console.print(empty_message)
return
show_browser = any(bool(browser_getter(row)) for row in rows)
if not show_browser:
print_table_rows(
rows,
columns,
console=console,
empty_message=empty_message,
show_header=show_header,
header_style=header_style,
)
return
table = Table(show_header=show_header, header_style=header_style)
table.add_column(browser_header, no_wrap=True)
for header, _getter in columns:
table.add_column(header)
rendered_groups: set[str] = set()
for row in rows:
browser = text_value(browser_getter(row))
group = text_value(group_getter(row))
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
rendered_groups.add(group)
browser = browser.removeprefix(f"{group}:")
browser = f" {browser}"
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
+63
View File
@@ -0,0 +1,63 @@
"""Rich label helpers for tab/window tree renderers."""
from __future__ import annotations
from rich.text import Text
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
BROWSER_FAMILY_STYLES = {
"firefox": "orange1",
"chrome": "cyan",
"chromium": "cyan",
"brave": "cyan",
"edge": "cyan",
"vivaldi": "cyan",
}
DEFAULT_SCOPE = "local"
DEFAULT_BROWSER_STYLE = "bold cyan"
def no_wrap_text() -> Text:
"""Text configured for one-line tree labels with edge ellipsis."""
return Text(no_wrap=True, overflow="ellipsis")
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
"""Reusable one-line label for a browser tab in tree views."""
label = no_wrap_text()
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
if bool(item_value(tab, "active", False)):
label.append(" *", style="green")
url = text_value(item_value(tab, "url"))
if show_urls and url:
label.append("", style="dim")
label.append(shorten(url, url_limit), style="dim")
return label
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
"""Reusable one-line label for a browser tab group in tree views."""
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
label = no_wrap_text()
label.append(shorten(title, title_limit), style="bold")
meta = [color]
if count:
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
if collapsed:
meta.append("collapsed")
label.append(" (" + ", ".join(meta) + ")", style="dim")
return label
def browser_label_style(browser_name: str | None) -> str:
"""Return a Rich style for a browser family label."""
name = (browser_name or "").lower()
for family, style in BROWSER_FAMILY_STYLES.items():
if family in name:
return style
return DEFAULT_BROWSER_STYLE
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
"""Shorten browser labels under a remote/local group node."""
prefix = f"{scope}:"
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
+185
View File
@@ -0,0 +1,185 @@
"""Tabs tree renderer."""
from __future__ import annotations
from collections.abc import Iterable
from rich.console import Console
from rich.text import Text
from rich.tree import Tree
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering.labels import (
DEFAULT_BROWSER_STYLE,
DEFAULT_SCOPE,
browser_label_style,
group_tree_label,
scoped_browser_label,
tab_tree_label,
)
GroupId = object
GroupKey = tuple[str, str, int | None, GroupId]
TreeNodeKey = tuple[str, str]
WindowNodeKey = tuple[str, str, int]
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
def browser_scope(item: Row) -> str:
"""Return the remote/local scope key used by tree renderers."""
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
def browser_label_key(item: Row) -> str:
"""Return the browser/profile key used by tree renderers."""
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
def tab_window_id(tab: Row) -> int:
"""Return a stable window id from object or dict-shaped tab responses."""
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
def tab_group_id(tab: Row) -> GroupId | None:
"""Return a tab group id from object or dict-shaped tab responses."""
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
return None if group_id is None else group_id
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
"""Stable tab ordering across multi-browser responses."""
group_id = tab_group_id(tab)
return (
browser_scope(tab),
browser_label_key(tab),
tab_window_id(tab),
int_value(item_value(tab, "index", 0)),
int_value(group_id, -1) if group_id is not None else -1,
int_value(item_value(tab, "id", 0)),
)
class TabsTreeBuilder:
"""Stateful builder for the browser tabs tree.
The tree has optional scope nodes (remote host/local), then browser/profile,
then window, then browser tab-groups/tabs. Keeping this state in a helper
keeps ``build_tabs_tree`` small while preserving stable node reuse.
"""
tabs: list[Row]
groups: list[Row]
show_urls: bool
show_browser: bool
group_by_scope: bool
title_limit: int
url_limit: int
root: Tree
group_info: dict[GroupKey, Row]
browser_styles: dict[str, str]
scope_nodes: dict[str, Tree]
browser_nodes: dict[TreeNodeKey, Tree]
window_nodes: dict[WindowNodeKey, Tree]
group_nodes: dict[BrowserGroupNodeKey, Tree]
def __init__(
self,
tabs: Iterable[Row],
groups: Iterable[Row],
*,
console: Console,
show_urls: bool = False,
):
self.tabs = sorted(tabs, key=tab_sort_key)
self.groups = list(groups)
self.show_urls = show_urls
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
self.url_limit = tree_url_limit(self.title_limit, console=console)
self.root = Tree("[bold]Tabs[/bold]")
self.group_info = self._group_info()
self.browser_styles = self._browser_styles()
self.scope_nodes = {}
self.browser_nodes = {}
self.window_nodes = {}
self.group_nodes = {}
def build(self) -> Tree:
for tab in self.tabs:
self._add_tab(tab)
return self.root
def _group_info(self) -> dict[GroupKey, Row]:
return {
(
browser_scope(group),
browser_label_key(group),
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
item_value(group, "id"),
): group
for group in self.groups
}
def _browser_styles(self) -> dict[str, str]:
styles: dict[str, str] = {}
for item in self.tabs + self.groups:
key = browser_label_key(item)
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
return styles
def _scope_node(self, scope: str) -> Tree:
if not self.group_by_scope:
return self.root
node = self.scope_nodes.get(scope)
if node is None:
node = self.root.add(Text(scope, style="bold"))
self.scope_nodes[scope] = node
return node
def _browser_node(self, scope: str, browser: str) -> Tree:
key = (scope, browser)
node = self.browser_nodes.get(key)
if node is None:
parent = self._scope_node(scope)
if self.show_browser:
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
else:
node = parent
self.browser_nodes[key] = node
return node
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
key = (scope, browser, window_id)
node = self.window_nodes.get(key)
if node is None:
node = self._browser_node(scope, browser).add(f"Window {window_id}")
self.window_nodes[key] = node
return node
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
key = (scope, browser, window_id, group_id)
node = self.group_nodes.get(key)
if node is None:
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
self.group_nodes[key] = node
return node
def _add_tab(self, tab: Row) -> None:
scope = browser_scope(tab)
browser = browser_label_key(tab)
window_id = tab_window_id(tab)
window_node = self._window_node(scope, browser, window_id)
group_id = tab_group_id(tab)
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
parent.add(tab_tree_label(
tab,
title_limit=self.title_limit,
show_urls=self.show_urls,
url_limit=self.url_limit,
))
def build_tabs_tree(
tabs: Iterable[Row],
groups: Iterable[Row],
*,
console: Console,
show_urls: bool = False,
) -> Tree:
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
@@ -0,0 +1,38 @@
"""Windows tree renderer."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from rich.console import Console
from rich.tree import Tree
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering.labels import tab_tree_label
WindowRow = Mapping[str, object]
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
"""Build a window → tab tree from window and tab responses."""
windows = list(windows)
tabs = list(tabs)
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
url_limit = tree_url_limit(title_limit, console=console)
root = Tree("[bold]Windows[/bold]")
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
window_id = int_value(window.get("id"))
label = f"Window {window_id}"
alias = text_value(window.get("alias"))
browser = text_value(window.get("browser"))
if alias:
label += f" ({alias})"
if browser:
label = f"{browser}: " + label
node = root.add(label)
window_tabs = [
tab for tab in tabs
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
and (not browser or text_value(item_value(tab, "browser")) == browser)
]
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
return root
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import importlib
import json
from pathlib import Path
from typing import Any, cast
import click
from rich.console import Console
from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
console = Console()
def _load_steps(path: Path):
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
try:
yaml = cast(Any, importlib.import_module("yaml"))
except Exception as exc:
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
return yaml.safe_load(text)
return json.loads(text)
def _parse_step(step):
if isinstance(step, str):
return step, {}
if isinstance(step, dict):
if "command" in step:
return step["command"], step.get("args") or {}
if len(step) == 1:
command, args = next(iter(step.items()))
return command, args or {}
raise click.ClickException(f"Invalid script step: {step!r}")
@click.command("script")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
@command_policy_options
@handle_errors
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool, allow_all: bool):
"""Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file)
if not isinstance(steps, list):
raise click.ClickException("Script root must be a list")
client = client_from_ctx()
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
results = []
for index, step in enumerate(steps, start=1):
command, args = _parse_step(step)
try:
assert_command_allowed(command, policy)
result = client.command(command, args)
results.append({"index": index, "command": command, "ok": True, "result": result})
if not json_output:
console.print(f"[green]✓[/green] {index}: {command}")
except Exception as exc:
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
if not continue_on_error:
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
raise
if not json_output:
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
+5 -57
View File
@@ -1,61 +1,10 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from browser_cli.search.engines import DISPLAY_NAMES, SUBCOMMANDS
console = Console()
ENGINES = {
"google": "https://www.google.com/search?q={query}",
"brave": "https://search.brave.com/search?q={query}",
"duckduckgo": "https://duckduckgo.com/?q={query}",
"ddg": "https://duckduckgo.com/?q={query}",
"youtube": "https://www.youtube.com/results?search_query={query}",
"yt": "https://www.youtube.com/results?search_query={query}",
"spotify": "https://open.spotify.com/search/{query}",
"amazon": "https://www.amazon.com/s?k={query}",
"ecosia": "https://www.ecosia.org/search?q={query}",
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
"fa": "https://www.furaffinity.net/search/?q={query}",
"bing": "https://www.bing.com/search?q={query}",
"github": "https://github.com/search?q={query}",
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"reddit": "https://www.reddit.com/search/?q={query}",
"stackoverflow": "https://stackoverflow.com/search?q={query}",
"so": "https://stackoverflow.com/search?q={query}",
}
_DISPLAY_NAMES = {
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
}
_SUBCOMMANDS = [
("google", "Search with Google."),
("brave", "Search with Brave Search."),
("duckduckgo", "Search with DuckDuckGo."),
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
("youtube", "Search YouTube videos."),
("yt", "Search YouTube (alias for youtube)."),
("spotify", "Search Spotify."),
("amazon", "Search Amazon."),
("ecosia", "Search with Ecosia."),
("furaffinity", "Search FurAffinity."),
("fa", "Search FurAffinity (alias for furaffinity)."),
("bing", "Search with Bing."),
("github", "Search GitHub."),
("wikipedia", "Search Wikipedia."),
("wiki", "Search Wikipedia (alias for wikipedia)."),
("reddit", "Search Reddit."),
("stackoverflow", "Search Stack Overflow."),
("so", "Search Stack Overflow (alias for stackoverflow)."),
]
@click.group("search")
def search_group():
"""Search the web — open a query in a search engine."""
@@ -63,18 +12,17 @@ def search_group():
def _build_command(engine_key: str, help_text: str) -> click.Command:
@click.command(engine_key, help=help_text)
@click.argument("query", nargs=-1, required=True)
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window", default=None, help="Open in named window")
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
@handle_errors
def _cmd(query, bg, window, group):
def _cmd(query, window, group):
terms = " ".join(query)
client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group)
client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
display = DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
return _cmd
for _name, _help in _SUBCOMMANDS:
for _name, _help in SUBCOMMANDS:
search_group.add_command(_build_command(_name, _help))
+154 -124
View File
@@ -8,157 +8,187 @@ from pathlib import Path
import click
from browser_cli import transport
from browser_cli.command_security import CommandPolicy
from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.runtime import (
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
)
from browser_cli.serve.security import RateLimiter, ServeSecurity, key_policies_from_authorized_keys
from browser_cli.version_manager import get_installed_version
__all__ = [
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
]
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"}
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option(
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
)
@click.option(
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
)
@click.option(
"--rpc",
is_flag=True,
default=False,
help="Also expose a ServiceLink HTTP /rpc endpoint (for mesh nodes) in the same process.",
)
@click.option("--rpc-port", default=8770, show_default=True, type=int, help="Port for the /rpc endpoint (with --rpc).")
@click.option(
"--rpc-token",
default=None,
metavar="SECRET",
help="Bearer token required on /rpc (with --rpc).",
)
@click.option(
"--rpc-insecure",
is_flag=True,
default=False,
help="Allow --rpc with no token (DANGEROUS: full browser control to anyone who can reach the port).",
"--rate-limit",
default=100.0,
show_default=True,
type=float,
help="Max commands/sec per client key (0 disables).",
)
@command_policy_options
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_port, rpc_token, rpc_insecure):
"""Expose this browser over TCP so remote hosts can control it.
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rate_limit,
allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose this browser over TCP so remote hosts can control it.
With --rpc, additionally serve the ServiceLink mesh over HTTP /rpc on
--rpc-port, so the native TCP protocol and the node mesh share one daemon.
"""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
Commands are gated by a safe-only policy by default; remote clients can only
run read-only status/listing commands. Open more with --allow-read-page,
--allow-control, --allow-dangerous, or --allow-all (full control). Per-key
overrides come from an ``allow:`` token in authorized_keys (set via
``auth trust --allow-*``), and --rate-limit throttles each client key.
"""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
if rpc and not rpc_token and not rpc_insecure:
console.print(
"[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your "
"browser and read its cookies). Use --rpc-insecure to override on a trusted host."
)
sys.exit(1)
_print_startup(host, port, profile, auth_keys_path, compress)
if rpc:
console.print(f" Mesh: [green]ServiceLink HTTP[/green] [cyan]{host}:{rpc_port}/rpc[/cyan]")
if not rpc_token:
console.print("[yellow] /rpc auth disabled (--rpc-insecure)[/yellow]")
try:
if rpc:
asyncio.run(_serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token))
else:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
async def _serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token):
"""Run the native TCP server and the ServiceLink HTTP /rpc server together."""
from browser_cli.commands import link_serve
sl = link_serve._import_servicelink()
await asyncio.gather(
_serve_async(host, port, profile, auth_keys_path, compress),
link_serve._serve(sl, host, rpc_port, profile, rpc_token),
if no_auth and not _is_loopback(host):
console.print(
"[red]Error:[/red] --no-auth is only allowed on loopback hosts "
"(127.0.0.1, localhost, ::1). Use --authorized-keys to expose this browser to the network."
)
sys.exit(1)
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
policy = command_policy_from_options(
allow_read_page=allow_read_page,
allow_control=allow_control,
allow_dangerous=allow_dangerous,
allow_keys=allow_keys,
allow_all=allow_all,
)
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
security = _build_security(policy, auth_keys_path, rate_limit)
_print_startup(host, port, profile, auth_keys_path, compress, security)
try:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress, security))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
def _build_security(policy, auth_keys_path, rate_limit) -> ServeSecurity:
"""Assemble the serve-time security context from the authorized_keys file."""
key_policies: dict = {}
key_names: dict = {}
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys_with_names
key_names = {pk.strip().lower(): name for pk, name in load_authorized_keys_with_names(auth_keys_path)}
key_policies = key_policies_from_authorized_keys(auth_keys_path)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
return ServeSecurity(policy=policy, key_policies=key_policies, key_names=key_names, rate_limiter=rate_limiter)
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool, security: ServeSecurity | None = None) -> None:
current_ver = get_installed_version()
security = security if security is not None else ServeSecurity()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
_print_policy_status(security.policy)
if security.key_policies:
console.print(f" Per-key: [green]{len(security.key_policies)} override(s)[/green] [dim](allow: in authorized_keys)[/dim]")
if security.rate_limiter is not None:
console.print(f" Rate: [green]{security.rate_limiter.rate:g}/s per key[/green] [dim](burst {security.rate_limiter.capacity:g})[/dim]")
else:
console.print(" Rate: [yellow]unlimited[/yellow] [dim](--rate-limit 0)[/dim]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
def _print_policy_status(policy: CommandPolicy | None) -> None:
if policy is None or policy == CommandPolicy.unrestricted():
console.print(" Policy: [yellow]unrestricted (--allow-all)[/yellow] [dim](every command allowed, incl. dom.eval/storage)[/dim]")
return
allowed = ["safe"]
if policy.allow_read_page:
allowed.append("read-page")
if policy.allow_control:
allowed.append("control")
if policy.allow_dangerous:
allowed.append("dangerous")
if policy.allow_keys:
allowed.append("keys")
console.print(f" Policy: [green]restricted[/green] [dim](allowed: {', '.join(allowed)})[/dim]")
def _print_encoding_status(compress: bool) -> None:
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import json
import secrets
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import click
from rich.console import Console
from browser_cli import BrowserCLI
from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.security import RateLimiter
console = Console()
# Hard cap on request body size so a bogus Content-Length can't exhaust memory.
MAX_BODY_BYTES = 8 * 1024 * 1024
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"}
class _Handler(BaseHTTPRequestHandler):
client: BrowserCLI
token: str | None = None
policy: CommandPolicy = CommandPolicy()
rate_limiter: RateLimiter | None = None
def _authorized(self) -> bool:
if self.token is None:
return True
bearer = self.headers.get("Authorization", "")
if bearer.startswith("Bearer ") and secrets.compare_digest(bearer[len("Bearer "):], self.token):
return True
header = self.headers.get("X-Browser-CLI-Token")
return header is not None and secrets.compare_digest(header, self.token)
def _require_auth(self) -> bool:
if self._authorized():
return True
self._send(401, {"error": "missing or invalid token"})
return False
def _within_rate_limit(self) -> bool:
if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]):
return True
self._send(429, {"error": "rate limit exceeded; slow down and retry"})
return False
def _send(self, status: int, payload):
raw = json.dumps(payload, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
path = urlparse(self.path).path
try:
if path != "/health":
if not self._require_auth():
return
if not self._within_rate_limit():
return
if path == "/tabs":
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
elif path == "/clients":
self._send(200, self.client.clients())
elif path == "/health":
self._send(200, {"ok": True})
else:
self._send(404, {"error": "not found"})
except Exception as exc:
self._send(500, {"error": str(exc)})
def do_POST(self):
path = urlparse(self.path).path
try:
if path != "/command":
self._send(404, {"error": "not found"})
return
if not self._require_auth():
return
if not self._within_rate_limit():
return
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY_BYTES:
self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"})
return
body = json.loads(self.rfile.read(length) or b"{}")
command = body.get("command")
assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
except PermissionError as exc:
self._send(403, {"error": str(exc)})
except Exception as exc:
self._send(500, {"error": str(exc)})
def log_message(self, fmt, *args):
console.print(f"[dim]http[/dim] {self.address_string()} {fmt % args}")
@click.command("serve-http")
@click.option("--host", default="127.0.0.1", show_default=True)
@click.option("--port", type=int, default=8766, show_default=True)
@click.option("--browser", default=None, help="Browser alias to target")
@click.option("--remote", default=None, help="Remote endpoint to target")
@click.option("--key", default=None, help="Remote auth key spec")
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)")
@command_policy_options
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
Auth is enabled by default. Pass the printed token as either
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
This gateway speaks plain HTTP the token is sent in clear text. Keep it on
loopback, or put a TLS-terminating reverse proxy in front before exposing it.
"""
if no_auth and not _is_loopback(host):
raise click.ClickException("--no-auth is only allowed on loopback hosts")
if not _is_loopback(host):
console.print(
"[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the "
"token travels in clear text. Put a TLS-terminating reverse proxy in front, or use "
"[bold]browser-cli serve[/bold] (encrypted) instead."
)
auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
handler = type(
"BrowserCLIHTTPHandler",
(_Handler,),
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter},
)
server = ThreadingHTTPServer((host, port), handler)
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
if auth_token:
console.print(f"[yellow]Token:[/yellow] {auth_token}")
try:
server.serve_forever()
except KeyboardInterrupt:
console.print("\n[yellow]Stopping HTTP gateway[/yellow]")
+39 -14
View File
@@ -1,3 +1,5 @@
import json
from pathlib import Path
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
from rich.console import Console
@@ -44,6 +46,33 @@ def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, b
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
@session_group.command("export")
@click.argument("name", required=False)
@click.option("-o", "output", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Write JSON to file instead of stdout")
@handle_errors
def session_export(name, output):
"""Export one saved session, or all sessions as JSON."""
data = client_from_ctx().session.export(name)
text = json.dumps(data, indent=2, sort_keys=True)
if output:
output.write_text(text + "\n", encoding="utf-8")
console.print(f"[green]Exported session data to {output}[/green]")
else:
click.echo(text)
@session_group.command("import")
@click.argument("name")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--overwrite", is_flag=True, help="Replace an existing saved session")
@handle_errors
def session_import(name, file, overwrite):
"""Import a saved session JSON file."""
payload = json.loads(file.read_text(encoding="utf-8"))
session = payload.get("session", payload) if isinstance(payload, dict) else payload
result = client_from_ctx().session.import_(name, session, overwrite=overwrite)
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Imported session '{name}'[/green] ({count} tabs)")
@session_group.command("diff")
@click.argument("name_a")
@click.argument("name_b")
@@ -76,24 +105,20 @@ def session_diff(name_a, name_b):
def session_list():
"""List all saved sessions."""
from datetime import datetime
from rich.table import Table
from browser_cli.commands.rendering import print_browser_grouped_table_rows
sessions = client_from_ctx().session.list()
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
show_browser = any("browser" in s for s in sessions)
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("Name")
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
def saved_at(session):
return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
columns = [
("Name", lambda session: session["name"]),
("Tabs", lambda session: session["tabs"]),
("Saved at", saved_at),
]
print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
@session_group.command("remove")
@click.argument("name")
+97 -104
View File
@@ -2,49 +2,42 @@ import base64
import binascii
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console
from rich.table import Table
console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs:
console.print("[yellow]No tabs found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser", no_wrap=True)
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7)
table.add_column("Muted", width=7)
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [
(t.browser or "") if show_browser else None,
str(t.id),
str(t.window_id),
active,
muted,
(t.title or "")[:60],
(t.url or "")[:80],
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns = [
("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]),
("URL", lambda tab: (tab.url or "")[:80]),
]
print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
@click.group("tabs")
def tabs_group():
"""Manage browser tabs."""
"""Manage browser tabs."""
@tabs_group.command("list")
@handle_errors
def tabs_list():
"""List all open tabs across all windows."""
tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
"""List all open tabs across all windows."""
tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("tree")
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
@handle_errors
def tabs_tree(show_urls):
"""Show tabs grouped as a window/group tree."""
client = client_from_ctx()
root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
print_tree(root, console=console)
@tabs_group.command("close")
@click.argument("tab_id", type=int, required=False)
@@ -53,9 +46,9 @@ def tabs_list():
@gentle_mode_option("Throttle mode for large close operations.")
@handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs."""
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} tab(s)[/green]")
"""Close a tab, all inactive tabs, or all duplicate tabs."""
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@@ -68,123 +61,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
@click.option("--index", type=int, default=None, help="Absolute position index in target")
@handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index,
)
console.print("[green]Tab moved[/green]")
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index,
)
console.print("[green]Tab moved[/green]")
@tabs_group.command("active")
@click.argument("tab_id", type=int)
@handle_errors
def tabs_active(tab_id):
"""Switch browser focus to a tab."""
client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]")
"""Switch browser focus to a tab."""
client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "")
console.print(table)
"""Show status for the active tab or a specific tab."""
tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
@handle_errors
def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN."""
_print_tabs(client_from_ctx().tabs.filter(pattern))
"""List tabs whose URL contains PATTERN."""
_print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count")
@click.argument("pattern", required=False)
@handle_errors
def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN."""
label = f" matching '{pattern}'" if pattern else ""
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
"""Count open tabs, optionally filtered by URL PATTERN."""
label = f" matching '{pattern}'" if pattern else ""
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query")
@click.argument("search")
@handle_errors
def tabs_query(search):
"""Search tabs by URL or title."""
_print_tabs(client_from_ctx().tabs.query(search))
"""Search tabs by URL or title."""
_print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_html(tab_id):
"""Print the full HTML of a tab."""
console.print(client_from_ctx().tabs.html(tab_id))
"""Print the full HTML of a tab."""
console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe")
@gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL)."""
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
"""Close duplicate tabs (keep the first occurrence of each URL)."""
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window."""
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]")
"""Sort tabs within each window."""
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows")
@gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window."""
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
"""Move all tabs into the focused window."""
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_mute(tab_id):
"""Mute the active tab or a specific tab."""
target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]")
"""Mute the active tab or a specific tab."""
target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab."""
target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]")
"""Unmute the active tab or a specific tab."""
target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_pin(tab_id):
"""Pin the active tab or a specific tab."""
target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]")
"""Pin the active tab or a specific tab."""
target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab."""
target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]")
"""Unpin the active tab or a specific tab."""
target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url")
@click.argument("pattern")
@@ -192,9 +185,9 @@ def tabs_unpin(tab_id):
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors
def tabs_watch_url(pattern, tab_id, timeout):
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.url}")
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.url}")
@tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE")
@@ -203,21 +196,21 @@ def tabs_watch_url(pattern, tab_id, timeout):
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
@handle_errors
def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab.
"""Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL.
"""
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output:
header = f"data:image/{fmt};base64,"
if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try:
raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f:
f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}")
else:
console.print(data_url)
Saves to FILE if given, otherwise prints the base64 data URL.
"""
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output:
header = f"data:image/{fmt};base64,"
if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try:
raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f:
f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}")
else:
console.print(data_url)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import time
import click
from browser_cli.commands import client_from_ctx, handle_errors
@click.group("watch")
def watch_group():
"""Watch browser state and print changes."""
@watch_group.command("tabs")
@click.option("--interval", type=float, default=1.0, show_default=True)
@click.option("--once", is_flag=True)
@handle_errors
def watch_tabs(interval, once):
"""Watch the tab list as JSON snapshots."""
client = client_from_ctx()
previous = None
while True:
current = [t.__dict__ for t in client.tabs.list()]
if current != previous:
click.echo(json.dumps({"type": "tabs", "tabs": current}, default=str), flush=True)
previous = current
if once:
return
time.sleep(interval)
@watch_group.command("page")
@click.option("--field", default=None, help="Only print a single page.info field")
@click.option("--interval", type=float, default=1.0, show_default=True)
@handle_errors
def watch_page(field, interval):
"""Watch page.info for the active tab."""
client = client_from_ctx()
previous = object()
while True:
info = client.page.info()
current = info.get(field) if field else info
if current != previous:
click.echo(json.dumps({"type": "page", "field": field, "value": current}, default=str), flush=True)
previous = current
time.sleep(interval)
@watch_group.command("dom")
@click.argument("selector")
@click.option("--interval", type=float, default=1.0, show_default=True)
@handle_errors
def watch_dom(selector, interval):
"""Watch textContent for a selector."""
client = client_from_ctx()
previous = object()
while True:
current = client.dom.text(selector)
if current != previous:
click.echo(json.dumps({"type": "dom", "selector": selector, "text": current}, default=str), flush=True)
previous = current
time.sleep(interval)
+30 -35
View File
@@ -1,65 +1,60 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console
from rich.table import Table
console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows:
console.print("[yellow]No windows found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Alias", width=20)
table.add_column("Tabs", width=6)
table.add_column("State", width=12)
for w in windows:
row = [
w.get("browser", "") if show_browser else None,
str(w.get("id", "")),
w.get("alias") or "",
str(w.get("tabCount", "")),
w.get("state") or "",
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns = [
("ID", lambda window: window.get("id", "")),
("Alias", lambda window: window.get("alias") or ""),
("Tabs", lambda window: window.get("tabCount", "")),
("State", lambda window: window.get("state") or ""),
]
print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
@click.group("windows")
def windows_group():
"""Manage browser windows."""
"""Manage browser windows."""
@windows_group.command("list")
@handle_errors
def windows_list():
"""List all browser windows."""
windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows))
"""List all browser windows."""
windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows))
@windows_group.command("tree")
@handle_errors
def windows_tree():
"""Show windows and their tabs as a tree."""
client = client_from_ctx()
root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
print_tree(root, console=console)
@windows_group.command("rename")
@click.argument("window_id", type=int)
@click.argument("name")
@handle_errors
def windows_rename(window_id, name):
"""Give a window a local alias NAME (stored in native host)."""
client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
"""Give a window a local alias NAME (stored in native host)."""
client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
@windows_group.command("close")
@click.argument("window_id", type=int)
@handle_errors
def windows_close(window_id):
"""Close a browser window."""
client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]")
"""Close a browser window."""
client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]")
@windows_group.command("open")
@click.argument("url", required=False)
@handle_errors
def windows_open(url):
"""Open a new browser window."""
result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
"""Open a new browser window."""
result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import json
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.constants import CONFIG_DIR
console = Console()
WORKSPACES_PATH = CONFIG_DIR / "workspaces.json"
def _load() -> dict:
try:
return json.loads(WORKSPACES_PATH.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
def _save(data: dict) -> None:
WORKSPACES_PATH.parent.mkdir(parents=True, exist_ok=True)
WORKSPACES_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
@click.group("workspace")
def workspace_group():
"""Named browser workspaces built on top of sessions."""
@workspace_group.command("save")
@click.argument("name")
@click.option("--session", "session_name", default=None, help="Session name to save/use (default: workspace name)")
@click.option("--profile", default=None, help="Performance profile to remember")
@handle_errors
def workspace_save(name, session_name, profile):
session_name = session_name or name
result = client_from_ctx().session.save(session_name)
data = _load()
data[name] = {"session": session_name, "profile": profile}
_save(data)
console.print(f"[green]Workspace '{name}' saved[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
@workspace_group.command("load")
@click.argument("name")
@click.option("--lazy", is_flag=True, help="Lazy-restore tabs")
@click.option("--eager-tabs", type=int, default=10, show_default=True)
@handle_errors
def workspace_load(name, lazy, eager_tabs):
data = _load()
ws = data.get(name)
if not ws:
raise click.ClickException(f"Workspace '{name}' not found")
client = client_from_ctx()
if ws.get("profile"):
client.perf.set_profile(ws["profile"])
result = client.session.load(ws["session"], lazy=lazy, eager_tabs=eager_tabs)
console.print(f"[green]Workspace '{name}' loaded[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
@workspace_group.command("switch")
@click.argument("name")
@click.option("--lazy", is_flag=True)
@handle_errors
def workspace_switch(name, lazy):
"""Load a workspace. Alias for workspace load."""
ctx = click.get_current_context()
ctx.invoke(workspace_load, name=name, lazy=lazy, eager_tabs=10)
@workspace_group.command("list")
def workspace_list():
"""List configured workspaces."""
data = _load()
if not data:
console.print("[yellow]No workspaces[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Session")
table.add_column("Profile")
for name, ws in sorted(data.items()):
table.add_row(name, ws.get("session", ""), ws.get("profile") or "")
console.print(table)
@workspace_group.command("remove")
@click.argument("name")
def workspace_remove(name):
data = _load()
if name not in data:
raise click.ClickException(f"Workspace '{name}' not found")
del data[name]
_save(data)
console.print(f"[green]Workspace '{name}' removed[/green]")
+8 -2
View File
@@ -20,11 +20,17 @@ def _auth_0_9_3(msg: dict) -> dict:
pk = msg.get("pubkey")
if isinstance(pk, str) and pk:
changed["pubkey"] = pk.lower()
if msg.get("command") == "browser-cli.auth.trust":
if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
identifier = args.get("identifier")
patched = dict(args)
if isinstance(trust_pk, str) and trust_pk:
changed["args"] = {**args, "pubkey": trust_pk.lower()}
patched["pubkey"] = trust_pk.lower()
if isinstance(identifier, str) and identifier and len(identifier) == 64:
patched["identifier"] = identifier.lower()
if patched != args:
changed["args"] = patched
return {**msg, **changed} if changed else msg
+30 -4
View File
@@ -9,20 +9,42 @@ import os
from pathlib import Path
APP_NAME = "browser-cli"
PYPI_PACKAGE_NAME = "real-browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"]
WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
# Public store listings — the default install path now that the extension is
# published. Chromium-family browsers (Brave/Edge/Vivaldi/Chromium) can all
# install from the Chrome Web Store.
CHROME_WEBSTORE_URL = f"https://chromewebstore.google.com/detail/browser-cli/{WEBSTORE_EXTENSION_ID}"
FIREFOX_ADDON_URL = "https://addons.mozilla.org/firefox/addon/browser-cli/"
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
DEFAULT_REMOTE_PORT = 443
DEFAULT_PAGE_SIZE = 100
# Count cap requested per page. The extension fills each page up to this many
# items OR a byte budget (whichever comes first), so large items (e.g. data-URI
# favicons) stay under the 1MB native-messaging limit while small items pack
# into far fewer roundtrips.
DEFAULT_PAGE_SIZE = 1000
# Hard upper bound on total items collected across all pages, and the loop-guard
# page count. Kept independent of page size so byte-budgeted small pages don't
# falsely trip the guard.
MAX_PAGED_ITEMS = 10_000
DEFAULT_TRANSPORT_THRESHOLD = 512
# How long a remote serve connection stays open waiting for the next command on
# an established encrypted session before closing. Lets the client reuse one
# authenticated connection for multiple commands instead of re-handshaking.
REMOTE_SESSION_IDLE_TIMEOUT = 30
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
PAGEABLE_COMMANDS = {
@@ -39,7 +61,6 @@ PAGEABLE_COMMANDS = {
"extract.links",
"extract.images",
"extract.json",
"cookies.list",
"session.list",
}
@@ -64,6 +85,10 @@ NATIVE_HOST_DIRS = {
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
},
"firefox": {
"linux": [Path.home() / ".mozilla/native-messaging-hosts"],
"darwin": [Path.home() / "Library/Application Support/Mozilla/NativeMessagingHosts"],
},
}
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
@@ -72,6 +97,7 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
"firefox": [r"Software\Mozilla\NativeMessagingHosts"],
}
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
+26 -24
View File
@@ -4,26 +4,6 @@ from __future__ import annotations
import re
from html.parser import HTMLParser
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
@@ -31,6 +11,13 @@ class _HtmlNode:
self.text = text
self.children = []
# Cap how deep the parsed tree may nest. Hostile page content (thousands of
# nested elements) would otherwise blow Python's recursion limit in the
# depth-first render walkers below. Bounding here protects every walker at once.
# 200 levels is far beyond any real document; deeper content is flattened, not
# dropped (its text still reaches the output).
_MAX_TREE_DEPTH = 200
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
@@ -42,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
# Only descend while under the depth cap; beyond it, children of this node
# attach to the current (capped) parent — flattened but preserved.
if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
@@ -77,6 +66,14 @@ def _collapse_blank_lines(value):
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
# Schemes that are dangerous if the produced markdown is later rendered as HTML
# by a downstream consumer. The output is plain text here, but neutralising them
# keeps the converter from laundering an XSS payload through to such a consumer.
_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE)
def _safe_url(url):
return "" if _UNSAFE_URL_SCHEME.match(url or "") else url
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
@@ -106,14 +103,14 @@ def _inline_text(node):
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
src = _safe_url(node.attrs.get("src") or "")
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
href = _safe_url(node.attrs.get("href") or "")
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
@@ -255,5 +252,10 @@ def _block_to_markdown(node):
def convert_html_to_markdown(html, clean_markdown_output):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
try:
markdown = _block_to_markdown(parser.root)
except RecursionError:
# The depth cap should prevent this, but never let hostile page content
# crash the caller: fall back to a flat, tag-stripped text extraction.
markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or ""))
return clean_markdown_output(markdown)
+26 -23
View File
@@ -31,6 +31,7 @@ class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
browser_groups: dict[str, str] = field(default_factory=dict)
# ── Tab ───────────────────────────────────────────────────────────────────────
@@ -44,7 +45,10 @@ class Tab:
title: str = ""
url: str = ""
group_id: int | None = None
index: int = 0
browser: str | None = None
browser_name: str | None = None
browser_group: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser:
@@ -58,27 +62,27 @@ class Tab:
def close(self) -> None:
"""Close this tab."""
self._command("tabs.close", {"tabId": self.id})
self._b().tabs.close(self.id)
def activate(self) -> None:
"""Switch browser focus to this tab."""
self._command("tabs.active", {"tabId": self.id})
self._b().tabs.activate(self.id)
def mute(self) -> None:
"""Mute this tab."""
self._command("tabs.mute", {"tabId": self.id})
self._b().tabs.mute(self.id)
def unmute(self) -> None:
"""Unmute this tab."""
self._command("tabs.unmute", {"tabId": self.id})
self._b().tabs.unmute(self.id)
def reload(self) -> None:
"""Reload this tab."""
self._command("navigate.reload", {"tabId": self.id})
self._b().nav.reload(self.id)
def hard_reload(self) -> None:
"""Hard-reload this tab (bypass cache)."""
self._command("navigate.hard_reload", {"tabId": self.id})
self._b().nav.hard_reload(self.id)
def move(
self, *,
@@ -97,18 +101,18 @@ class Tab:
window_id: Move to the window with this ID.
index: Absolute position index in the target window.
"""
self._command("tabs.move", {
"tabId": self.id,
"forward": forward,
"backward": backward,
"groupId": group_id,
"windowId": window_id,
"index": index,
})
self._b().tabs.move(
self.id,
forward=forward,
backward=backward,
group_id=group_id,
window_id=window_id,
index=index,
)
def html(self) -> str:
"""Return the full HTML source of this tab."""
return self._command("tabs.html", {"tabId": self.id})
return self._b().tabs.html(self.id)
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
@@ -116,11 +120,11 @@ class Tab:
def pin(self) -> None:
"""Pin this tab."""
self._command("tabs.pin", {"tabId": self.id})
self._b().tabs.pin(self.id)
def unpin(self) -> None:
"""Unpin this tab."""
self._command("tabs.unpin", {"tabId": self.id})
self._b().tabs.unpin(self.id)
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
@@ -149,7 +153,10 @@ class Group:
color: str
collapsed: bool
tab_count: int
window_id: int | None = None
browser: str | None = None
browser_name: str | None = None
browser_group: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser:
@@ -163,7 +170,7 @@ class Group:
def close(self) -> None:
"""Ungroup (and close) this tab group."""
self._command("group.close", {"groupId": self.id})
self._b().groups.close(self.id)
def tabs(self) -> list[Tab]:
"""Return all tabs inside this group."""
@@ -171,11 +178,7 @@ class Group:
def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups."""
self._command("group.move", {
"group": str(self.id),
"forward": forward,
"backward": backward,
})
self._b().groups.move(str(self.id), forward=forward, backward=backward)
def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID."""
+6 -4
View File
@@ -7,7 +7,6 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import math
import os
import queue
import socket
@@ -17,7 +16,7 @@ import uuid
from pathlib import Path
from browser_cli.native import local_server, protocol
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, MAX_PAGED_ITEMS, PAGEABLE_COMMANDS
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.registry import update_registry
@@ -126,7 +125,10 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
offset = 0
items = []
total = None
max_pages = math.ceil(10_000 / PAGE_SIZE)
# Independent of PAGE_SIZE: the extension may return fewer items per page than
# requested (byte budget), so a page-count guard derived from PAGE_SIZE would
# falsely trip. Bound the page count by the absolute item cap instead.
max_pages = MAX_PAGED_ITEMS
pages_fetched = 0
while True:
@@ -154,7 +156,7 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
items.extend(page_items)
total = data.get("total", total)
next_offset = data.get("nextOffset")
if next_offset is None:
if next_offset is None or len(items) >= MAX_PAGED_ITEMS:
break
offset = int(next_offset)
+145
View File
@@ -0,0 +1,145 @@
"""Challenge/response auth helpers for remote TCP transport."""
from __future__ import annotations
import asyncio
import json
import sys
from collections.abc import Callable
from typing import TypeVar
from browser_cli.errors import BrowserNotConnected
from browser_cli.version_manager import USER_AGENT
T = TypeVar("T")
AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
def parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
try:
challenge = json.loads(raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
return challenge, nonce_hex
except (json.JSONDecodeError, AttributeError):
return None, None
def check_min_client_version(challenge: dict | None) -> None:
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if not min_ver:
return
from browser_cli.version_manager import parse_version
try:
client_ver = USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
def clean_message(msg: dict) -> dict:
return {key: value for key, value in msg.items() if key not in AUTH_FIELDS}
def get_pq_public_key(challenge: dict | None) -> str | None:
if not isinstance(challenge, dict):
return None
from browser_cli.auth import PQ_KEX_ALG
kex = challenge.get("pq_kex")
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
return str(kex["public_key"])
return None
def signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
from browser_cli.auth import pq_encrypt, public_key_hex, sign
nonce = bytes.fromhex(nonce_hex)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
pubkey = public_key_hex(private_key)
if pq_shared_secret is None:
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
return {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": pubkey,
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
def emit_no_pq_warning(enabled: bool) -> None:
if enabled:
sys.stderr.write(PQ_WARNING)
def build_auth_message(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
encapsulate: Callable[[str], tuple[str, bytes]],
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
if not nonce_hex or private_key is None:
emit_no_pq_warning(warn_no_pq)
return msg, None
clean_msg = clean_message(msg)
pq_shared_secret = None
pq_public_key = get_pq_public_key(challenge)
if pq_public_key:
from browser_cli.auth import PQ_KEX_ALG
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
emit_no_pq_warning(warn_no_pq)
return signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
async def build_auth_message_async(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
def encapsulate(public_key: str) -> tuple[str, bytes]:
from browser_cli.auth import pq_kex_client_encapsulate
return pq_kex_client_encapsulate(public_key)
return await asyncio.to_thread(
build_auth_message,
msg,
challenge,
nonce_hex,
private_key,
encapsulate,
warn_no_pq=warn_no_pq,
)
def decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
if response is None or pq_shared_secret is None:
return response
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as exc:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {exc}") from exc
return response
def with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
if challenge_raw is None:
raise BrowserNotConnected("No challenge received from remote endpoint")
challenge, nonce_hex = parse_challenge(challenge_raw)
check_min_client_version(challenge)
return build_auth(msg, challenge, nonce_hex, private_key)
def should_warn_no_pq(msg: dict) -> bool:
return not bool(msg.pop("_suppress_pq_warning", False))
+103
View File
@@ -0,0 +1,103 @@
"""Per-process pool of authenticated remote connections for reuse.
A ``browser-cli serve`` connection stays open after its first (encrypted)
command, so the client can send further commands over it without re-running the
TCP/TLS/challenge/auth handshake (~hundreds of ms each). Only encrypted (PQ)
sessions are pooled plaintext/legacy sessions stay one-shot, matching the
server, which only loops for encrypted sessions.
Connections are checked out exclusively (never shared between threads at once),
returned on success, and dropped on any I/O error or once older than an idle
bound (kept below the server's idle timeout so we don't reuse a connection the
server has already closed).
"""
from __future__ import annotations
import atexit
import json
import socket
import threading
import time
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
from browser_cli.framing import frame
# Retire a pooled connection a few seconds before the server would, so we never
# hand back one the server has just timed out and closed.
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
_MAX_PER_ENDPOINT = 8
class PooledConnection:
__slots__ = ("sock", "secret", "last_used")
def __init__(self, sock: socket.socket, secret: bytes) -> None:
self.sock = sock
self.secret = secret
self.last_used = time.monotonic()
_POOL: dict[str, list[PooledConnection]] = {}
_LOCK = threading.Lock()
def _close(sock: socket.socket) -> None:
try:
sock.close()
except OSError:
pass
def checkout(endpoint: str) -> PooledConnection | None:
"""Take an idle authenticated connection for *endpoint*, or None."""
now = time.monotonic()
with _LOCK:
conns = _POOL.get(endpoint)
while conns:
conn = conns.pop()
if now - conn.last_used <= _MAX_IDLE_SECONDS:
return conn
_close(conn.sock) # too old — assume the server has dropped it
return None
def checkin(endpoint: str, conn: PooledConnection) -> None:
"""Return a still-healthy connection to the pool for reuse."""
conn.last_used = time.monotonic()
with _LOCK:
bucket = _POOL.setdefault(endpoint, [])
if len(bucket) >= _MAX_PER_ENDPOINT:
_close(conn.sock)
return
bucket.append(conn)
def discard(conn: PooledConnection) -> None:
"""Drop a connection that errored or is no longer usable."""
_close(conn.sock)
def close_all() -> None:
"""Close every pooled connection (process exit / test isolation)."""
with _LOCK:
for bucket in _POOL.values():
for conn in bucket:
_close(conn.sock)
_POOL.clear()
def session_inner_message(msg: dict) -> dict:
"""Strip auth/transport fields, leaving the command for an established session."""
keep = {"id", "command", "args", "user_agent", "accept_encoding", "_route", "_suppress_pq_warning"}
return {k: v for k, v in msg.items() if k in keep}
def send_over(conn: PooledConnection, msg: dict) -> bytes | None:
"""Send one command over an existing encrypted session. Raises on I/O error."""
from browser_cli.auth import pq_encrypt
from browser_cli.remote.socket import recv_all
from browser_cli.remote.transport import _decode_pq_response
inner = json.dumps(session_inner_message(msg)).encode("utf-8")
envelope = json.dumps({"encrypted": pq_encrypt(conn.secret, "request", inner)}).encode("utf-8")
conn.sock.sendall(frame(envelope))
response = recv_all(conn.sock)
if not response:
# EOF — an older server (no session loop) closed after one command. Treat as
# a transport failure so the caller re-handshakes; never as an app error,
# which could double-execute a non-idempotent command on retry.
raise EOFError("remote closed the pooled connection")
return _decode_pq_response(response, conn.secret)
atexit.register(close_all)
+57
View File
@@ -0,0 +1,57 @@
"""Socket helpers for remote TCP/TLS transport."""
from __future__ import annotations
import asyncio
import socket
from contextlib import contextmanager
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.framing import async_recv_exact, async_recv_frame, recv_exact, recv_frame
def recv_exact_bytes(sock: socket.socket, n: int) -> bytes:
return recv_exact(sock, n) or b""
def recv_all(sock: socket.socket) -> bytes:
return recv_frame(sock, label="Response") or b""
async def async_recv_exact_bytes(reader: asyncio.StreamReader, n: int) -> bytes:
return await async_recv_exact(reader, n) or b""
async def async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader, label="Response") or b""
def split_endpoint(endpoint: str) -> tuple[str, int]:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
def connect_socket(endpoint: str) -> socket.socket:
"""Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
host, port = split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
return sock
@contextmanager
def open_socket(endpoint: str):
sock = connect_socket(endpoint)
with sock:
yield sock
async def open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host, port = split_endpoint(endpoint)
ssl_ctx = None
if port == 443:
import ssl
ssl_ctx = ssl.create_default_context()
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
+50 -195
View File
@@ -1,203 +1,70 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
complete the signed challenge/response handshake with an optional post-quantum
key exchange, frame the request, and read the framed (possibly encrypted)
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client.core`.
This module keeps the public/private compatibility surface used by older tests
and callers, while delegating socket mechanics and auth-handshake details to
focused helper modules.
"""
from __future__ import annotations
import asyncio
import json
import socket
import sys
from collections.abc import Callable
from contextlib import contextmanager
from typing import TypeVar
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
T = TypeVar("T")
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
_PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
from browser_cli.framing import async_send_frame, frame
from browser_cli.remote.auth import (
build_auth_message as _build_auth_message,
build_auth_message_async as _build_auth_message_async,
decode_pq_response as _decode_pq_response,
parse_challenge as _parse_challenge,
should_warn_no_pq as _should_warn_no_pq,
with_challenge as _with_challenge,
)
from browser_cli.remote.socket import (
async_recv_all as _async_recv_all,
async_recv_exact_bytes as _async_recv_exact,
connect_socket as _connect_socket,
open_async_connection as _open_async_connection,
open_socket as _open_socket,
recv_all as _recv_all,
recv_exact_bytes as _recv_exact,
split_endpoint as _split_endpoint,
)
from browser_cli.remote import pool as _pool
def _recv_exact(sock: socket.socket, n: int) -> bytes:
return recv_exact(sock, n) or b""
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
# Reuse an already-authenticated connection when one is idle for this endpoint.
conn = _pool.checkout(endpoint)
if conn is not None:
try:
response = _pool.send_over(conn, msg)
_pool.checkin(endpoint, conn)
return response
except (OSError, ConnectionError, ValueError, EOFError):
_pool.discard(conn) # stale/closed — fall through to a fresh handshake
def _recv_all(sock: socket.socket) -> bytes:
return recv_frame(sock, label="Response") or b""
return _send_remote_handshake(endpoint, msg, private_key, warn_no_pq=warn_no_pq)
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
return await async_recv_exact(reader, n) or b""
def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader, label="Response") or b""
def _split_endpoint(endpoint: str) -> tuple[str, int]:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
@contextmanager
def _open_socket(endpoint: str):
host, port = _split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
yield sock
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host, port = _split_endpoint(endpoint)
ssl_ctx = None
if port == 443:
import ssl
ssl_ctx = ssl.create_default_context()
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
try:
challenge = json.loads(raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
return challenge, nonce_hex
except (json.JSONDecodeError, AttributeError):
return None, None
def _check_min_client_version(challenge: dict | None) -> None:
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if not min_ver:
return
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
def _clean_message(msg: dict) -> dict:
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
def _get_pq_public_key(challenge: dict | None) -> str | None:
if not isinstance(challenge, dict):
return None
from browser_cli.auth import PQ_KEX_ALG
kex = challenge.get("pq_kex")
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
return str(kex["public_key"])
return None
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
nonce = bytes.fromhex(nonce_hex)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
pubkey = public_key_hex(private_key)
if pq_shared_secret is None:
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
return {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": pubkey,
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
def _warn_no_pq(enabled: bool) -> None:
if enabled:
sys.stderr.write(_PQ_WARNING)
def _build_auth_message(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
encapsulate: Callable[[str], tuple[str, bytes]],
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
if not nonce_hex or private_key is None:
_warn_no_pq(warn_no_pq)
return msg, None
clean_msg = _clean_message(msg)
pq_shared_secret = None
pq_public_key = _get_pq_public_key(challenge)
if pq_public_key:
from browser_cli.auth import PQ_KEX_ALG
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
_warn_no_pq(warn_no_pq)
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
async def _build_auth_message_async(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
def encapsulate(public_key: str) -> tuple[str, bytes]:
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate
return pq_kex_client_encapsulate(public_key)
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
return await asyncio.to_thread(
_build_auth_message,
msg,
challenge,
nonce_hex,
private_key,
encapsulate,
warn_no_pq=warn_no_pq,
)
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
if response is None or pq_shared_secret is None:
return response
sock = _connect_socket(endpoint)
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
except BaseException:
_pool._close(sock)
raise
# Only encrypted sessions are reusable — the server keeps those open, and a
# fresh AEAD nonce per frame keeps reuse of the shared secret safe.
if pq_shared_secret is not None:
_pool.checkin(endpoint, _pool.PooledConnection(sock, pq_shared_secret))
else:
_pool._close(sock)
return response
def _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
if challenge_raw is None:
raise BrowserNotConnected("No challenge received from remote endpoint")
challenge, nonce_hex = _parse_challenge(challenge_raw)
_check_min_client_version(challenge)
return build_auth(msg, challenge, nonce_hex, private_key)
def _should_warn_no_pq(msg: dict) -> bool:
return not bool(msg.pop("_suppress_pq_warning", False))
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
reader, writer = await _open_async_connection(endpoint)
try:
@@ -216,15 +83,3 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
await writer.wait_closed()
except Exception:
pass
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
with _open_socket(endpoint) as sock:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
+1 -3
View File
@@ -4,7 +4,7 @@ Each namespace groups related browser commands under a short accessor on the
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
in the browser extension.
"""
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
from browser_cli.sdk.browser_data import StorageNS
from browser_cli.sdk.decorators import DecoratorsNS
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
from browser_cli.sdk.extension import ExtensionNS
@@ -24,7 +24,6 @@ NAMESPACE_SPECS = (
("extract", ExtractNS),
("page", PageNS),
("storage", StorageNS),
("cookies", CookiesNS),
("session", SessionNS),
("perf", PerfNS),
("extension", ExtensionNS),
@@ -40,7 +39,6 @@ __all__ = [
"ExtractNS",
"PageNS",
"StorageNS",
"CookiesNS",
"SessionNS",
"PerfNS",
"ExtensionNS",
+3 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any, TypeVar
from typing import Any, TypeVar, cast
F = TypeVar("F", bound=Callable)
_MISSING = object()
@@ -54,8 +54,8 @@ def sdk_command(
return _clone_default(default)
return result
wrapper._browser_cli_command = name # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
setattr(wrapper, "_browser_cli_command", name)
return cast(F, wrapper)
return decorator
+1 -49
View File
@@ -1,4 +1,4 @@
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
"""Storage namespace: ``b.storage.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace, sdk_command
@@ -35,51 +35,3 @@ class StorageNS(Namespace):
tab_id: int | None = None,
) -> None:
"""Set a localStorage/sessionStorage entry."""
class CookiesNS(Namespace):
"""List, get, and set cookies."""
@sdk_command("cookies.list", lambda self, *, url=None, domain=None, name=None: {
"url": url,
"domain": domain,
"name": name,
}, default=[])
def list(
self,
*,
url: str | None = None,
domain: str | None = None,
name: str | None = None,
) -> list[dict]:
"""List cookies, optionally filtered by url, domain, or name."""
@sdk_command("cookies.get", lambda self, url, name: {"url": url, "name": name})
def get(self, url: str, name: str) -> dict | None:
"""Get a single cookie by url and name."""
@sdk_command("cookies.set", lambda self, url, name, value, *, domain=None, path=None, secure=None,
http_only=None, expiration_date=None, same_site=None: {
"url": url,
"name": name,
"value": value,
"domain": domain,
"path": path,
"secure": secure,
"httpOnly": http_only,
"expirationDate": expiration_date,
"sameSite": same_site,
})
def set(
self,
url: str,
name: str,
value: str,
*,
domain: str | None = None,
path: str | None = None,
secure: bool | None = None,
http_only: bool | None = None,
expiration_date: float | None = None,
same_site: str | None = None,
) -> dict:
"""Set a cookie. Returns the created cookie dict."""
+5 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import functools
import inspect
from collections.abc import Callable
from typing import TypeVar
from typing import TypeVar, cast
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
@@ -53,7 +53,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally:
if cleanup is not None:
await asyncio.to_thread(cleanup, value)
return async_wrapper # type: ignore[return-value]
return cast(F, async_wrapper)
return WorkflowDecoratorsMixin._value_decorator(
self, fn, get_value, keyword=keyword, cleanup=cleanup
)
@@ -74,7 +74,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally:
if previous:
await asyncio.to_thread(self._c.perf.set_profile, previous)
return async_wrapper # type: ignore[return-value]
return cast(F, async_wrapper)
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
return decorator
@@ -101,7 +101,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
raise
if delay > 0:
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
return async_wrapper # type: ignore[return-value]
raise cast(BaseException, last_error)
return cast(F, async_wrapper)
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
return decorator
+8
View File
@@ -6,6 +6,14 @@ from browser_cli.sdk.base import Namespace, sdk_command
class ExtensionNS(Namespace):
"""Control the browser-cli extension itself."""
@sdk_command("extension.info", default={})
def info(self) -> dict:
"""Return extension version, runtime metadata, and capabilities."""
@sdk_command("extension.capabilities", default=[])
def capabilities(self) -> list[str]:
"""Return feature capability strings advertised by the extension."""
@sdk_command("extension.reload")
def reload(self) -> None:
"""Reload the browser-cli extension service worker.
+101 -80
View File
@@ -11,93 +11,114 @@ from typing import Any, Protocol, cast
from browser_cli.models import Group, Tab
def _target_group(target) -> str | None:
if target is None:
return None
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
class _FactoryClient(Protocol):
_key: str | None
_key: str | None
class FactoryMixin:
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
browser=browser_name,
)
client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0,
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self.tab_from(data)
def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self.tab_from(data)
def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
browser=browser_name,
)
client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"),
browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
)
client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.tab_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
)
def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.tab_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.group_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
)
def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.group_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
@staticmethod
def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name}
@staticmethod
def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with browser metadata in multi-browser mode."""
if target is None:
return item
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
+58 -9
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from browser_cli.models import Tab
from browser_cli.sdk.base import Namespace, sdk_command
def _open_args(self, url, *, background=False, window=None, group=None):
return {"url": url, "background": background, "window": window, "group": group}
def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
def _tab_args(self, tab_id=None):
return {"tabId": tab_id}
@@ -13,9 +13,31 @@ def _tab_args(self, tab_id=None):
class NavigationNS(Namespace):
"""Open URLs, navigate history, and focus tabs."""
@sdk_command("navigate.open", _open_args)
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
"""Open *url* in a new tab."""
def open(
self,
url: str,
*,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
reuse: bool = False,
reuse_domain: bool = False,
reuse_title: str | None = None,
) -> None:
"""Open *url* in a new tab without stealing OS focus by default.
``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
instead of creating a new one.
"""
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
if tab is not None:
self.to(tab.id, url)
if focus:
self._c.tabs.activate(tab.id)
return None
self.command("navigate.open", _open_args(self, url, background=background, focus=focus, window=window, group=group))
return None
def open_wait(
self,
@@ -23,14 +45,24 @@ class NavigationNS(Namespace):
*,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
reuse: bool = False,
reuse_domain: bool = False,
reuse_title: str | None = None,
) -> Tab:
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
if tab is not None:
self.to(tab.id, url)
if focus:
self._c.tabs.activate(tab.id)
return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
return self.require_tab(
self.command("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
"background": background or not focus, "focus": focus, "window": window, "group": group,
}),
"navigate.open_wait returned unexpected data",
)
@@ -59,15 +91,32 @@ class NavigationNS(Namespace):
def to(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url* in place."""
def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
if not (reuse or reuse_domain or reuse_title):
return None
from urllib.parse import urlparse
wanted = urlparse(url)
wanted_host = wanted.netloc.lower()
for tab in self._c.tabs.list():
tab_url = tab.url or ""
parsed = urlparse(tab_url)
if reuse and tab_url == url:
return tab
if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
return tab
if reuse_title and reuse_title.lower() in (tab.title or "").lower():
return tab
return None
def search(
self, engine: str, query: str, *,
background: bool = False, window: str | None = None, group: str | None = None,
background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None,
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
from browser_cli.search.engines import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
url = template.format(query=quote_plus(query))
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group})
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group})
+50 -14
View File
@@ -7,12 +7,14 @@ helpers; single-browser mode falls straight through to ``_cmd``.
"""
from __future__ import annotations
import asyncio
import importlib
import sys
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Protocol, cast
from browser_cli.client import BrowserTarget
from browser_cli.client.core import _run_concurrent
from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Tab
@@ -37,6 +39,20 @@ _UNSET = object()
def _browser_cli_package():
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]:
"""Use profile-only labels when a command is already scoped to one remote."""
return [
BrowserTarget(
profile=target.profile,
display_name=target.profile if target.remote else target.display_name,
socket_path=target.socket_path,
remote=target.remote,
browser_name=target.browser_name,
display_group=None,
)
for target in targets
]
class RoutingMixin:
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
@@ -51,10 +67,15 @@ class RoutingMixin:
def _multi_browser_targets(self) -> list[BrowserTarget]:
client = self._client
package = _browser_cli_package()
if client._browser is not None:
if client._browser is not None and not client._remote:
targets = package.remote_targets_for_alias(client._browser, key=client._key)
if len(targets) <= 1:
return []
targets = _with_profile_display(targets)
elif client._browser is not None:
return []
if client._remote:
targets = package.remote_browser_targets(client._remote, key=client._key)
elif client._remote:
targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
else:
targets = package.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -62,18 +83,28 @@ class RoutingMixin:
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = _browser_cli_package().send_command(
command, args, profile=target.profile, remote=target.remote, key=self._client._key
)
else:
data = _browser_cli_package().send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
def _send(target: BrowserTarget):
package = _browser_cli_package()
if target.remote:
return package.send_command(
command, args, profile=target.profile, remote=target.remote, key=self._client._key
)
return package.send_command(command, args, profile=target.profile)
# Run per-target roundtrips concurrently — each is a blocking, network-bound
# send_command, so offloading to threads gives real overlap while still
# invoking the (test-patchable) sync entry point.
raw = _run_concurrent([
(lambda t=t: asyncio.to_thread(_send, t)) for t in targets
])
results = []
for target, data in zip(targets, raw):
if isinstance(data, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(data, BaseException):
raise data
results.append((target, data))
if results:
return results
@@ -107,7 +138,12 @@ class RoutingMixin:
if not multi_results:
return self._client.dispatch(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
browser_groups = {
target.display_name: target.display_group or "local"
for target, _count in multi_results
if target.display_group or target.remote is None
}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
def multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode.
+8
View File
@@ -48,6 +48,14 @@ class SessionNS(Namespace):
def diff(self, name_a: str, name_b: str) -> dict:
"""Diff two saved sessions."""
@sdk_command("session.export", lambda self, name=None: {"name": name}, default={})
def export(self, name: str | None = None) -> dict:
"""Export one saved session, or all sessions when *name* is omitted."""
@sdk_command("session.import", lambda self, name, session, overwrite=False: {"name": name, "session": session, "overwrite": overwrite}, default={})
def import_(self, name: str, session: dict, *, overwrite: bool = False) -> dict:
"""Import a saved session payload under *name*."""
def list(self) -> list[dict]:
"""Return saved sessions.
+23 -2
View File
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace
# Keep SDK-driven bulk closes comfortably below the native-host response
# timeout. The extension can close larger batches, but real browsers may take
# much longer when hundreds of visible tabs are involved.
BULK_CLOSE_CHUNK_SIZE = 50
class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs."""
@@ -24,17 +29,19 @@ class TabsNS(Namespace):
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and return a bound :class:`Tab`.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
Pass ``focus=True`` to explicitly bring the created tab/window forward.
"""
if wait:
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
return self.require_tab(
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
"navigate.open returned unexpected data",
)
@@ -73,6 +80,20 @@ class TabsNS(Namespace):
ids = None
if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None:
closed = 0
for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE):
chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE]
result = self.command("tabs.close", {
"tabId": None,
"tabIds": chunk,
"inactive": False,
"duplicates": False,
"gentleMode": gentle_mode,
})
closed += self.field(result, "closed", len(chunk))
return closed
result = self.command("tabs.close", {
"tabId": tab_id,
"tabIds": ids,
+37 -14
View File
@@ -4,11 +4,32 @@ from __future__ import annotations
import functools
import time
from collections.abc import Callable
from typing import TypeVar
from typing import Protocol, TypeVar, cast
F = TypeVar("F", bound=Callable)
_NO_INJECT = object()
class _WorkflowTabs(Protocol):
def active(self): ...
def open(self, *args, **kwargs): ...
def watch_url(self, *args, **kwargs): ...
class _WorkflowDom(Protocol):
def wait_for(self, *args, **kwargs): ...
class _WorkflowPerf(Protocol):
def status(self): ...
def set_profile(self, profile: str): ...
class _WorkflowSession(Protocol):
def save(self, name: str): ...
class _WorkflowClient(Protocol):
tabs: _WorkflowTabs
dom: _WorkflowDom
perf: _WorkflowPerf
session: _WorkflowSession
class WorkflowDecoratorsMixin:
"""Shared implementation for sync and async workflow decorators.
@@ -17,7 +38,7 @@ class WorkflowDecoratorsMixin:
in lockstep.
"""
_c: object
_c: _WorkflowClient
@staticmethod
def _inject(kwargs: dict, keyword: str | None, value):
@@ -62,7 +83,7 @@ class WorkflowDecoratorsMixin:
finally:
if cleanup is not None:
self._run(cleanup, value)
return wrapper # type: ignore[return-value]
return cast(F, wrapper)
return decorator(func) if func is not None else decorator
@@ -72,7 +93,7 @@ class WorkflowDecoratorsMixin:
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
pass it as the first positional argument instead.
"""
return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined]
return self._value_decorator(func, self._c.tabs.active, keyword=keyword)
def new_tab(
self,
@@ -81,6 +102,7 @@ class WorkflowDecoratorsMixin:
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
close: bool = False,
@@ -92,11 +114,12 @@ class WorkflowDecoratorsMixin:
wrapped function returns or raises.
"""
def open_tab():
return self._c.tabs.open( # type: ignore[attr-defined]
return self._c.tabs.open(
url,
wait=wait,
timeout=timeout,
background=background,
focus=focus,
window=window,
group=group,
)
@@ -122,7 +145,7 @@ class WorkflowDecoratorsMixin:
the wrapped function. By default the result is not injected.
"""
def wait():
return self._c.dom.wait_for( # type: ignore[attr-defined]
return self._c.dom.wait_for(
selector,
timeout=timeout,
visible=visible,
@@ -143,7 +166,7 @@ class WorkflowDecoratorsMixin:
):
"""Wait until a tab URL matches *pattern* before calling the function."""
def wait():
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined]
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
inject = keyword if keyword is not None else _NO_INJECT
return self._value_decorator(None, wait, keyword=inject)
@@ -155,19 +178,19 @@ class WorkflowDecoratorsMixin:
def wrapper(*args, **kwargs):
previous = None
if restore:
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined]
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined]
previous = self._run(self._c.perf.status).get("performanceProfile")
self._run(self._c.perf.set_profile, profile)
try:
return self._call_wrapped(fn, *args, **kwargs)
finally:
if previous:
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
self._run(self._c.perf.set_profile, previous)
return cast(F, wrapper)
return decorator
def save_session_before(self, name: str):
"""Save the current browser session before running the function."""
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined]
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT)
def retry(
self,
@@ -192,7 +215,7 @@ class WorkflowDecoratorsMixin:
raise
if delay > 0:
self._sleep(delay)
raise last_error # type: ignore[misc]
return wrapper # type: ignore[return-value]
raise cast(BaseException, last_error)
return cast(F, wrapper)
return decorator
+1
View File
@@ -0,0 +1 @@
"""Search metadata and helpers shared by SDK and CLI layers."""
+53
View File
@@ -0,0 +1,53 @@
"""Shared search-engine metadata for SDK and CLI search commands."""
from __future__ import annotations
ENGINES = {
"google": "https://www.google.com/search?q={query}",
"brave": "https://search.brave.com/search?q={query}",
"duckduckgo": "https://duckduckgo.com/?q={query}",
"ddg": "https://duckduckgo.com/?q={query}",
"youtube": "https://www.youtube.com/results?search_query={query}",
"yt": "https://www.youtube.com/results?search_query={query}",
"spotify": "https://open.spotify.com/search/{query}",
"amazon": "https://www.amazon.com/s?k={query}",
"ecosia": "https://www.ecosia.org/search?q={query}",
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
"fa": "https://www.furaffinity.net/search/?q={query}",
"bing": "https://www.bing.com/search?q={query}",
"github": "https://github.com/search?q={query}",
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
"reddit": "https://www.reddit.com/search/?q={query}",
"stackoverflow": "https://stackoverflow.com/search?q={query}",
"so": "https://stackoverflow.com/search?q={query}",
}
DISPLAY_NAMES = {
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
}
SUBCOMMANDS = [
("google", "Search with Google."),
("brave", "Search with Brave Search."),
("duckduckgo", "Search with DuckDuckGo."),
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
("youtube", "Search YouTube videos."),
("yt", "Search YouTube (alias for youtube)."),
("spotify", "Search Spotify."),
("amazon", "Search Amazon."),
("ecosia", "Search with Ecosia."),
("furaffinity", "Search FurAffinity."),
("fa", "Search FurAffinity (alias for furaffinity)."),
("bing", "Search with Bing."),
("github", "Search GitHub."),
("wikipedia", "Search Wikipedia."),
("wiki", "Search Wikipedia (alias for wikipedia)."),
("reddit", "Search Reddit."),
("stackoverflow", "Search Stack Overflow."),
("so", "Search Stack Overflow (alias for stackoverflow)."),
]
+31
View File
@@ -0,0 +1,31 @@
"""Challenge-frame helpers for ``browser-cli serve``."""
from __future__ import annotations
import asyncio
import secrets
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
async def load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
if auth_keys_path is None:
return None
from browser_cli.auth import load_authorized_keys
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
return nonce, pq_private_key, challenge_msg
+80 -13
View File
@@ -10,34 +10,53 @@ class ServeControlMixin:
addr: tuple
command: str
auth_keys_path: Path | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_ok(self, payload, command: str | None = None) -> None: ...
async def handle_control_command(self, msg: dict) -> bool:
if self.command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
from browser_cli.client import active_browser_targets, send_command
targets = []
for target in active_browser_targets(include_remotes=False):
item = {"profile": target.profile, "displayName": target.display_name}
try:
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
if clients:
# Carry the full client info so a remote `clients` command can render
# from this single roundtrip instead of issuing another clients.list.
info = clients[0]
for src, dst in (("name", "browserName"), ("version", "version"), ("extensionVersion", "extensionVersion")):
value = info.get(src)
if value:
item[dst] = value
except Exception:
pass
targets.append(item)
await self.send_ok(targets, self.command)
log_request(self.addr, self.command, None, "OK")
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
if self.command == "browser-cli.auth.keys":
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file", identity=self.auth_label)
return True
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
from browser_cli.auth import load_authorized_keys_with_policies
entries = [
{"pubkey": pk, "name": name, "allow": cats}
for pk, name, cats in load_authorized_keys_with_policies(self.auth_keys_path)
]
await self.send_ok(entries, self.command)
log_request(self.addr, self.command, None, "OK")
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg)
if self.command == "browser-cli.auth.policy":
return await self._handle_policy(msg)
return False
async def _handle_trust(self, msg: dict) -> bool:
@@ -49,11 +68,59 @@ class ServeControlMixin:
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
categories = args.get("allow")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name)
if not await self._validate_categories(categories):
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
return True
async def _handle_policy(self, msg: dict) -> bool:
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import set_authorized_key_policy
args = msg.get("args") or {}
identifier = str(args.get("identifier") or "")
categories = args.get("allow")
if not identifier.strip():
await self.send_error("missing key identifier")
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
return True
if not await self._validate_categories(categories):
return True
try:
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
return True
if updated is None:
await self.send_error(f"trusted key not found: {identifier}")
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
return True
pubkey, name = updated
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
async def _validate_categories(self, categories) -> bool:
if categories is not None and not isinstance(categories, list):
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return False
if categories is not None:
from browser_cli.serve.security import policy_from_categories
try:
policy_from_categories(categories) # validate before persisting
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
return False
return True
+11 -3
View File
@@ -6,11 +6,19 @@ from rich.console import Console
console = Console()
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
def log_request(
addr: tuple,
command: str,
profile: str | None,
status: str,
error: str | None = None,
identity: str | None = None,
) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
identity_str = f"[magenta]{identity}[/magenta] " if identity else ""
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
+5 -4
View File
@@ -18,6 +18,7 @@ class ServeProxyMixin:
command: str
compress: bool
accept_encoding: dict | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_payload(self, data: bytes) -> None: ...
@@ -35,7 +36,7 @@ class ServeProxyMixin:
sock_path = resolve_socket(resolved_profile)
except BrowserNotConnected as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected", identity=self.auth_label)
return
try:
@@ -46,7 +47,7 @@ class ServeProxyMixin:
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
except (OSError, json.JSONDecodeError, ConnectionError) as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e), identity=self.auth_label)
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
from multiprocessing.connection import Client as PipeClient
@@ -74,6 +75,6 @@ class ServeProxyMixin:
else:
await self.send_payload(resp_payload)
if resp_data.get("success", True):
log_request(self.addr, self.command, resolved_profile, "OK")
log_request(self.addr, self.command, resolved_profile, "OK", identity=self.auth_label)
else:
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""), identity=self.auth_label)
+88 -31
View File
@@ -8,19 +8,21 @@ from __future__ import annotations
import asyncio
import json
import secrets
import socket
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from browser_cli import transport
from browser_cli.command_security import assert_command_allowed
from browser_cli.compat import adapt_auth
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
from browser_cli.serve.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
from browser_cli.serve.security import ServeSecurity
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data)
@@ -39,12 +41,15 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
nonce: str
pq_private_key: object | None = None
compress: bool = True
security: ServeSecurity = field(default_factory=ServeSecurity)
response_secret: bytes | None = None
accept_encoding: dict | None = None
client_ver: str = "0"
msg_id: object = None
command: str = "?"
auth_pubkey: str | None = None
auth_label: str | None = None
async def send_payload(self, data: bytes) -> None:
if self.response_secret is not None:
@@ -90,11 +95,73 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
msg = await self.authenticate(msg)
if msg is None:
return
self._apply_identity(msg)
await self._dispatch(msg)
# Once an encrypted session is established, keep serving further commands on
# the same connection — the client may reuse it without re-authenticating.
# Safe because every frame carries a fresh AEAD nonce (see pq_encrypt).
while self.response_secret is not None:
nxt = await self._read_session_message()
if nxt is None:
return
await self._dispatch(nxt)
def _apply_identity(self, msg: dict) -> None:
"""Record the authenticated pubkey (if any) for per-key policy and audit logs."""
pub = (msg.get("pubkey") or "").strip().lower()
self.auth_pubkey = pub or None
self.auth_label = self.security.label_for(self.auth_pubkey)
async def _enforce_rate_limit(self) -> bool:
limiter = self.security.rate_limiter
if limiter is None or limiter.allow(self.auth_pubkey or str(self.addr[0])):
return True
await self.send_error("rate limit exceeded; slow down and retry")
log_request(self.addr, self.command, None, "DENIED", "rate limit exceeded", identity=self.auth_label)
return False
async def _dispatch(self, msg: dict) -> None:
self.accept_encoding = msg.get("accept_encoding")
if not await self._enforce_rate_limit():
return
# Gate every command — including server control commands like the key-management
# ones — so the policy is enforced before handle_control_command acts on it.
try:
assert_command_allowed(self.command, self.security.effective_policy(self.auth_pubkey))
except PermissionError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "DENIED", "blocked by command policy", identity=self.auth_label)
return
if await self.handle_control_command(msg):
return
await self.forward_to_browser(msg)
async def _read_session_message(self) -> dict | None:
"""Read the next command on an established encrypted session, or None to close."""
try:
payload = await asyncio.wait_for(_async_recv_all(self.reader), timeout=REMOTE_SESSION_IDLE_TIMEOUT)
except (asyncio.TimeoutError, ConnectionError, OSError):
return None
if not payload:
return None
try:
outer = json.loads(payload)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(outer, dict) or "encrypted" not in outer:
return None # an authenticated session only accepts encrypted frames
from browser_cli.auth import pq_decrypt
try:
inner = json.loads(pq_decrypt(self.response_secret, "request", outer["encrypted"]))
except Exception:
return None
if not isinstance(inner, dict):
return None
inner = adapt_auth(inner, self.client_ver)
self.msg_id = inner.get("id")
self.command = inner.get("command", "?")
return inner
async def _async_proxy_request(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
@@ -105,8 +172,12 @@ async def _async_proxy_request(
nonce: str,
pq_private_key=None,
compress: bool = True,
security: ServeSecurity | None = None,
) -> None:
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
await ServeRequest(
reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress,
security if security is not None else ServeSecurity(),
).run()
async def _async_handle_client(
reader: asyncio.StreamReader,
@@ -116,6 +187,7 @@ async def _async_handle_client(
auth_keys_path: Path | None,
compress: bool = True,
conn_limit: asyncio.Semaphore | None = None,
security: ServeSecurity | None = None,
) -> None:
if conn_limit is None:
conn_limit = asyncio.Semaphore(64)
@@ -131,7 +203,7 @@ async def _async_handle_client(
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
except OSError:
return
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress, security)
finally:
conn_limit.release()
writer.close()
@@ -140,41 +212,19 @@ async def _async_handle_client(
except Exception:
pass
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
if auth_keys_path is None:
return None
from browser_cli.auth import load_authorized_keys
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
return nonce, pq_private_key, challenge_msg
def _handle_client(
client_sock: socket.socket,
addr: tuple,
profile: str | None,
auth_keys_path: Path | None,
compress: bool = True,
security: ServeSecurity | None = None,
) -> None:
"""Run one accepted socket through the async serve pipeline."""
async def _run() -> None:
reader, writer = await asyncio.open_connection(sock=client_sock)
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress, None, security)
try:
asyncio.run(_run())
@@ -184,12 +234,19 @@ def _handle_client(
except OSError:
pass
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
async def _serve_async(
host: str,
port: int,
profile: str | None,
auth_keys_path: Path | None,
compress: bool,
security: ServeSecurity | None = None,
) -> None:
conn_limit = asyncio.Semaphore(64)
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername") or ("?", 0)
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit, security)
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
async with server:
+115
View File
@@ -0,0 +1,115 @@
"""Server-side authorization, per-key policy and rate limiting for ``browser-cli serve``.
This bundles the three serve-time security concerns that travel together through
the connection-handling chain:
- ``policy`` the server-wide default ``CommandPolicy`` (from ``--allow-*``)
- ``key_policies`` optional per-pubkey overrides parsed from the ``allow:`` token
in the ``authorized_keys`` file
- ``key_names`` pubkey -> friendly name (from authorized_keys), for audit logs
- ``rate_limiter`` optional per-identity token-bucket throttle
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from browser_cli.command_security import CommandPolicy
# ── per-key authorization ───────────────────────────────────────────────────────
_CATEGORY_FLAGS = {
"read-page": "allow_read_page",
"control": "allow_control",
"dangerous": "allow_dangerous",
"keys": "allow_keys",
}
def policy_from_categories(categories) -> CommandPolicy:
"""Build a CommandPolicy from category strings (``all``/``safe``/``read-page``/``control``/``dangerous``)."""
cats = [str(c).strip().lower() for c in categories]
if "all" in cats:
return CommandPolicy.unrestricted()
kwargs: dict[str, bool] = {}
for cat in cats:
if cat in ("", "safe"):
continue
flag = _CATEGORY_FLAGS.get(cat)
if flag is None:
raise ValueError(
f"unknown command category {cat!r}; expected one of: all, safe, read-page, control, dangerous"
)
kwargs[flag] = True
return CommandPolicy(**kwargs)
def key_policies_from_authorized_keys(path: Path | str | None) -> dict[str, CommandPolicy]:
"""Build ``{pubkey: CommandPolicy}`` from the ``allow:`` tokens in authorized_keys.
Only keys that carry an explicit ``allow:`` token get an entry; keys without
one fall back to the server-wide default policy. Pubkeys are normalised to
lowercase hex. Raises ``ValueError`` on an unknown category so the server fails
loudly at startup rather than silently mis-gating.
"""
if path is None:
return {}
from browser_cli.auth import load_authorized_keys_with_policies
out: dict[str, CommandPolicy] = {}
for pubkey, _name, categories in load_authorized_keys_with_policies(Path(path)):
if categories is not None:
out[pubkey.strip().lower()] = policy_from_categories(categories)
return out
# ── per-identity rate limiting ───────────────────────────────────────────────────
class RateLimiter:
"""Token bucket keyed by identity (pubkey, or client address when unauthenticated).
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
Thread-safe so it can be shared across all connections of one serve process.
"""
def __init__(self, rate: float, burst: float | None = None) -> None:
self.rate = float(rate)
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
self._buckets: dict[str, tuple[float, float]] = {}
self._lock = threading.Lock()
def allow(self, key: str) -> bool:
if self.rate <= 0:
return True
now = time.monotonic()
with self._lock:
tokens, last = self._buckets.get(key, (self.capacity, now))
tokens = min(self.capacity, tokens + (now - last) * self.rate)
if tokens < 1.0:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1.0, now)
return True
# ── bundled server security context ──────────────────────────────────────────────
@dataclass(frozen=True)
class ServeSecurity:
policy: CommandPolicy = field(default_factory=CommandPolicy.unrestricted)
key_policies: dict[str, CommandPolicy] = field(default_factory=dict)
key_names: dict[str, str] = field(default_factory=dict)
rate_limiter: RateLimiter | None = None
def effective_policy(self, pubkey: str | None) -> CommandPolicy:
"""Per-key override if one exists for this pubkey, else the server default."""
if pubkey and pubkey in self.key_policies:
return self.key_policies[pubkey]
return self.policy
def label_for(self, pubkey: str | None) -> str | None:
"""Audit label for log lines: ``<name> <short-pubkey>…`` or just the short pubkey."""
if not pubkey:
return None
short = f"{pubkey[:8]}"
name = self.key_names.get(pubkey, "")
return f"{name} {short}".strip() if name else short
-214
View File
@@ -1,214 +0,0 @@
"""Response payload encoding for the TCP serve <-> client leg.
The wire frame stays ``4-byte LE length + payload``. The payload is made
self-describing so old peers keep working unchanged:
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
format). Old clients and old servers only ever produce/consume this.
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
The tag's high nibble selects serialization, the low nibble compression::
tag = (serialization << 4) | compression
This is only ever emitted toward a peer that advertised support for it, so it
is fully backward compatible: clients announce what they can decode via the
``accept_encoding`` field in their request, and the server encodes the
response accordingly. Requests themselves stay plain JSON (they are tiny).
Compression is the big win response payloads (``extract.html``,
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
image as raw bytes instead of a base64 data URL (~33% smaller before
compression); the client transparently rebuilds the data URL so the SDK/CLI
API is unchanged.
"""
from __future__ import annotations
import base64
import gzip
import json
import re
import zlib
from browser_cli.constants import (
COMP_GZIP,
COMP_NONE,
COMP_ZLIB,
COMP_ZSTD,
DEFAULT_TRANSPORT_THRESHOLD,
SER_JSON,
SER_MSGPACK,
)
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
# ── codec ids ────────────────────────────────────────────────────────────────
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
_SER_ID = {v: k for k, v in _SER_NAME.items()}
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
# JSON top-level values always start with one of these bytes; a tag byte never does.
_JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
# ── compression primitives ────────────────────────────────────────────────────
def _compress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def _decompress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
# ── codec negotiation ──────────────────────────────────────────────────────────
def _choose(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
ser = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
ser = SER_MSGPACK
comp = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
comp = _COMP_ID[name]
break
return ser, comp
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
_B64_MARKER = "__b64__"
def _hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
m = _DATA_URL_RE.match(url)
if not m:
return obj
try:
raw = base64.b64decode(m.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
return {**obj, "data": new_data}
def _unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {k: _unhoist_binary(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_unhoist_binary(v) for v in obj]
return obj
# ── encode / decode ─────────────────────────────────────────────────────────────
def encode_response(obj, accept: dict | None = None, command: str | None = None,
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+72
View File
@@ -0,0 +1,72 @@
"""Response payload encoding for the TCP serve <-> client leg.
The wire frame stays ``4-byte LE length + payload``. Payloads are plain JSON
for legacy peers, or a 1-byte codec tag followed by serialized/compressed data
when the peer advertised support for it.
"""
from __future__ import annotations
import json
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, DEFAULT_TRANSPORT_THRESHOLD, SER_JSON, SER_MSGPACK
from browser_cli.transport.binary import hoist_screenshot as _hoist_screenshot, unhoist_binary as _unhoist_binary
from browser_cli.transport.codecs import (
JSON_FIRST_BYTES as _JSON_FIRST_BYTES,
_msgpack,
choose_codec as _choose,
client_accept_encoding,
compress_payload as _compress,
decompress_payload as _decompress,
msgpack_available,
supported_compression,
supported_serialization,
zstd_available,
)
def encode_response(
obj,
accept: dict | None = None,
command: str | None = None,
threshold: int = DEFAULT_TRANSPORT_THRESHOLD,
) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+44
View File
@@ -0,0 +1,44 @@
"""Raw-binary hoisting helpers for encoded response payloads."""
from __future__ import annotations
import base64
import re
DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
B64_MARKER = "__b64__"
def hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so arbitrary page-derived data is never touched.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
match = DATA_URL_RE.match(url)
if not match:
return obj
try:
raw = base64.b64decode(match.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {B64_MARKER: True, "mime": match.group(1), "raw": raw}
return {**obj, "data": new_data}
def unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {key: unhoist_binary(value) for key, value in obj.items()}
if isinstance(obj, list):
return [unhoist_binary(value) for value in obj]
return obj
+84
View File
@@ -0,0 +1,84 @@
"""Serialization/compression primitives for TCP response payloads."""
from __future__ import annotations
import gzip
import zlib
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, SER_JSON, SER_MSGPACK
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
SERIALIZATION_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
SERIALIZATION_ID = {value: key for key, value in SERIALIZATION_NAME.items()}
COMPRESSION_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
COMPRESSION_ID = {value: key for key, value in COMPRESSION_NAME.items()}
JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
def compress_payload(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def decompress_payload(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
def choose_codec(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
serialization = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
serialization = SER_MSGPACK
compression = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
compression = COMPRESSION_ID[name]
break
return serialization, compression
+26 -10
View File
@@ -1,17 +1,33 @@
from importlib.metadata import version as _pkg_version
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version("browser-cli")
except Exception:
return "0.0.0"
try:
return _pkg_version(PYPI_PACKAGE_NAME)
except Exception:
return "0.0.0"
def project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
for line in content.splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except OSError:
pass
try:
return _pkg_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
USER_AGENT = f"browser-cli/{get_installed_version()}"
+2 -2
View File
@@ -43,8 +43,8 @@ pause
header "3/8 · Create 'research' group and open URLs into it"
$CLI groups create research
echo ""
$CLI nav open https://example.com --group research --bg
$CLI nav open https://wikipedia.org --group research --bg
$CLI nav open https://example.com --group research
$CLI nav open https://wikipedia.org --group research
echo ""
echo " Tabs are now open inside the 'research' group in your browser."
pause
+17 -14
View File
@@ -1,28 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title>browser-cli icon</title>
<defs>
<linearGradient id="bg" x1="12" y1="10" x2="116" y2="118" gradientUnits="userSpaceOnUse">
<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="28" y1="24" x2="100" y2="104" gradientUnits="userSpaceOnUse">
<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>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)" />
<rect x="25" y="25" width="78" height="66" rx="14" fill="url(#panel)" />
<rect x="25" y="25" width="78" height="15" rx="14" fill="#94a3b8" />
<circle cx="36" cy="32.5" r="2.5" fill="#f8fafc" />
<circle cx="44" cy="32.5" r="2.5" fill="#f8fafc" opacity="0.85" />
<circle cx="52" cy="32.5" r="2.5" fill="#f8fafc" opacity="0.7" />
<!-- 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" />
<path d="M46 56 35 64l11 8" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" />
<path d="M62 52h19" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="8" />
<path d="M62 65h26" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="8" />
<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" />
<rect x="69" y="77" width="26" height="17" rx="6" fill="#14b8a6" />
<rect x="56" y="84" width="26" height="17" rx="6" fill="#2dd4bf" />
<rect x="43" y="91" width="26" height="17" rx="6" fill="#99f6e4" />
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+8 -3
View File
@@ -1,8 +1,14 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.12.1",
"version": "0.16.3",
"description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"gecko": {
"id": "browser-cli@yiprawr.dev",
"strict_min_version": "120.0"
}
},
"permissions": [
"tabs",
"tabGroups",
@@ -10,8 +16,7 @@
"windows",
"storage",
"alarms",
"nativeMessaging",
"cookies"
"nativeMessaging"
],
"host_permissions": [
"<all_urls>"
+31
View File
@@ -0,0 +1,31 @@
/**
* Cross-browser WebExtension API entry point.
*
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
* Chromium exposes the same extension API as `chrome.*`.
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
* native `browser` object and Chromium uses its native `chrome` object. No
* browser-specific global is faked or overwritten.
*/
import type { WebExtensionApi } from './types';
type WebExtensionGlobal = {
browser?: typeof browser;
chrome?: typeof chrome;
};
function currentApi(): typeof browser | typeof chrome {
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
if (!api) {
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
}
return api;
}
export const webExtApi = new Proxy({}, {
get(_target: object, property: string | symbol) {
return currentApi()[property as keyof ReturnType<typeof currentApi>];
},
}) as object as WebExtensionApi;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface CommandContext { jobs: JobManager; }
/**
* A command group bundles a set of related subcommands. `commands` is keyed by
* the FULL command id (e.g. "tabs.close") so groups spanning multiple
* namespaces (dom/extract/page, storage/cookies, session/clients) register
* namespaces (dom/extract/page, storage, session/clients) register
* uniformly. `namespace` is documentation/grouping only.
*/
export abstract class CommandGroup {
+21 -4
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from './CommandGroup';
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
import { NavigationCommands } from '../commands/navigation';
@@ -16,17 +17,33 @@ function isCommandSpec(entry: CommandEntry): entry is CommandSpec {
return typeof entry !== "function";
}
// Fill each page up to a byte budget kept safely under the 1MB native-messaging
// limit (extension → host). This makes paging adaptive: many small items pack
// into one page, while a few oversized items (e.g. data-URI favicons) split
// across pages instead of overflowing the limit.
const PAGE_BYTE_BUDGET = 768 * 1024;
export function makePagedData(items: Serializable[], page: PageRequest) {
const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0);
const requestedLimit = Math.max(1, Number(page.limit) || 100);
const limit = Math.min(requestedLimit, 1000);
const end = Math.min(offset + limit, total);
const maxCount = Math.min(requestedLimit, 1000);
let end = offset;
let bytes = 0;
while (end < total && end - offset < maxCount) {
const itemBytes = JSON.stringify(items[end]).length + 1; // +1 ≈ separator
// Always include at least one item so a single oversized item still advances.
if (end > offset && bytes + itemBytes > PAGE_BYTE_BUDGET) break;
bytes += itemBytes;
end++;
}
return {
__browserCliPage: true,
items: items.slice(offset, end),
offset,
limit,
limit: maxCount,
total,
nextOffset: end < total ? end : null,
};
@@ -74,7 +91,7 @@ export class CommandRegistry {
/**
* Builds the registry and registers every command group. The SessionCommands
* instance is returned alongside because index.ts wires its lifecycle methods
* (chrome.tabs.onActivated activateLazyTab) and NativeConnection references it
* (api.tabs.onActivated activateLazyTab) and NativeConnection references it
* for the clients.rename_profile reconnect side-effect.
*/
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
+6 -5
View File
@@ -1,7 +1,8 @@
import { webExtApi as api } from '../browser-api';
/**
* Background-job retention helpers + the JobManager that owns the live job map.
*
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* /
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
* service-worker side effects so the retention logic (memory-leak guard) can be
* unit-tested in isolation.
*/
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead
// of writing to chrome.storage.local every second forever.
// of writing to api.storage.local every second forever.
export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
/**
@@ -65,11 +66,11 @@ export class JobManager {
const running = all.filter(job => job.status === "running");
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
await chrome.storage.local.set({ recentJobs });
await api.storage.local.set({ recentJobs });
}
// Evict the oldest finished jobs once their count exceeds the retention cap.
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs)
// Recent finished jobs remain queryable via api.storage.local (persistJobs)
// even after eviction from the in-memory Map.
private pruneJobs() {
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
@@ -143,7 +144,7 @@ export class JobManager {
async status({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId);
if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored;
+15 -14
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
/**
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
* message router that hands commands to the CommandRegistry.
@@ -6,7 +7,7 @@
import { getErrorMessage, getProfileAlias } from '../core';
import type { CommandRegistry } from './CommandRegistry';
import type { SessionCommands } from '../commands/session';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
const NATIVE_HOST = "com.browsercli.host";
const DEBUG_LOG = false;
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
}
export class NativeConnection {
private port: chrome.runtime.Port | null = null;
private port: RuntimePort | null = null;
private keepaliveEnabled = true;
constructor(
@@ -26,17 +27,17 @@ export class NativeConnection {
/** Registers all runtime listeners and opens the initial connection. */
start() {
chrome.runtime.onInstalled.addListener(() => this.connect());
chrome.runtime.onStartup.addListener(() => this.connect());
chrome.runtime.onSuspend.addListener(() => {
api.runtime.onInstalled.addListener(() => this.connect());
api.runtime.onStartup.addListener(() => this.connect());
api.runtime.onSuspend.addListener(() => {
this.disconnectPort({ sendBye: true });
});
chrome.windows.onCreated.addListener(() => {
api.windows.onCreated.addListener(() => {
this.keepaliveEnabled = true;
if (!this.port) this.connect();
});
chrome.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({});
api.windows.onRemoved.addListener(async () => {
const windows = await api.windows.getAll({});
if (windows.length > 0) return;
this.keepaliveEnabled = false;
@@ -46,15 +47,15 @@ export class NativeConnection {
// Reconnect poll — wakes the worker to re-establish the native port if it
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
// are silently clamped and log a warning, so we set it explicitly.
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
api.alarms.create("keepalive", { periodInMinutes: 0.5 });
api.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") {
if (!this.port && this.keepaliveEnabled) this.connect();
}
});
}
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) {
private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
if (!targetPort) return;
try {
targetPort.postMessage(message);
@@ -63,7 +64,7 @@ export class NativeConnection {
}
}
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) {
private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
if (!targetPort) return;
try {
targetPort.postMessage(message);
@@ -90,12 +91,12 @@ export class NativeConnection {
private async connect() {
if (this.port || !this.keepaliveEnabled) return;
try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
const nativePort = api.runtime.connectNative(NATIVE_HOST);
this.port = nativePort;
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
nativePort.onDisconnect.addListener(() => {
if (this.port === nativePort) this.port = null;
const err = chrome.runtime.lastError;
const err = api.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
});
// Send hello so native host knows which profile/alias this is
+23 -21
View File
@@ -1,4 +1,6 @@
import { getSessions, runLargeOperation } from '../core';
import type { TabUpdateInfo } from '../types';
import { webExtApi as api } from '../browser-api';
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
import { captureCurrentSession } from './session-snapshot';
// Debounce window for autosave. A full-tab snapshot + storage write runs on
@@ -16,44 +18,44 @@ export class AutoSaveManager {
readonly autoSaveHandler = async (): Promise<void> => {
await this.scheduleAutoSave();
};
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => {
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return;
await this.scheduleAutoSave();
};
async setEnabled(enabled: boolean) {
await chrome.storage.local.set({ autoSave: enabled });
chrome.tabs.onCreated.removeListener(this.autoSaveHandler);
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler);
chrome.tabs.onMoved.removeListener(this.autoSaveHandler);
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler);
await api.storage.local.set({ autoSave: enabled });
api.tabs.onCreated.removeListener(this.autoSaveHandler);
api.tabs.onRemoved.removeListener(this.autoSaveHandler);
api.tabs.onMoved.removeListener(this.autoSaveHandler);
api.tabs.onAttached.removeListener(this.autoSaveHandler);
api.tabs.onDetached.removeListener(this.autoSaveHandler);
api.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null;
this.autoSavePending = false;
if (enabled) {
chrome.tabs.onCreated.addListener(this.autoSaveHandler);
chrome.tabs.onRemoved.addListener(this.autoSaveHandler);
chrome.tabs.onMoved.addListener(this.autoSaveHandler);
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
api.tabs.onCreated.addListener(this.autoSaveHandler);
api.tabs.onRemoved.addListener(this.autoSaveHandler);
api.tabs.onMoved.addListener(this.autoSaveHandler);
api.tabs.onAttached.addListener(this.autoSaveHandler);
api.tabs.onDetached.addListener(this.autoSaveHandler);
api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
}
return { enabled };
}
private async saveAutoSessionIfChanged() {
const { session, signature, tabCount } = await captureCurrentSession();
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
const { autoSaveSignature } = await api.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
const sessions = await getSessions();
sessions.__auto__ = session;
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
await api.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: tabCount };
}
@@ -64,7 +66,7 @@ export class AutoSaveManager {
}
this.autoSaveInFlight = true;
try {
const { autoSave } = await chrome.storage.local.get("autoSave");
const { autoSave } = await api.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
} finally {
this.autoSaveInFlight = false;
@@ -76,7 +78,7 @@ export class AutoSaveManager {
}
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
const { autoSave } = await chrome.storage.local.get("autoSave");
const { autoSave } = await api.storage.local.get("autoSave");
if (!autoSave) return;
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
+1 -26
View File
@@ -1,16 +1,13 @@
import { executeScript, isScriptableUrl, resolveTabUrl } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types';
import type { Json, StorageGetArgs, StorageSetArgs } from '../types';
export class BrowserDataCommands extends CommandGroup {
readonly namespace = "storage";
readonly commands: Record<string, CommandEntry> = {
"storage.get": (a: StorageGetArgs) => this.storageGet(a),
"storage.set": (a: StorageSetArgs) => this.storageSet(a),
"cookies.list": (a: CookiesListArgs) => this.cookiesList(a),
"cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a),
"cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a),
};
private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) {
@@ -49,26 +46,4 @@ export class BrowserDataCommands extends CommandGroup {
return results[0]?.result ?? false;
}
private async cookiesList({ url, domain, name }: CookiesListArgs = {}) {
const details: chrome.cookies.GetAllDetails = {};
if (url) details.url = url;
if (domain) details.domain = domain;
if (name) details.name = name;
return await chrome.cookies.getAll(details);
}
private async cookiesGet({ url, name }: CookiesGetArgs) {
return await chrome.cookies.get({ url, name });
}
private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) {
const details: chrome.cookies.SetDetails = { url, name, value };
if (domain != null) details.domain = domain;
if (path != null) details.path = path;
if (secure != null) details.secure = secure;
if (httpOnly != null) details.httpOnly = httpOnly;
if (expirationDate != null) details.expirationDate = expirationDate;
if (sameSite != null) details.sameSite = sameSite;
return await chrome.cookies.set(details);
}
}
+7 -2
View File
@@ -1,9 +1,11 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable {
function fallbackForErrorPageDomOp(funcName: string, tab: Tab): Serializable {
switch (funcName) {
case "domExists":
return false;
@@ -105,7 +107,10 @@ export class DomCommands extends CommandGroup {
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (c: string) => (0, eval)(c),
func: (c: string) => {
const evaluate = globalThis["eval" as keyof typeof globalThis] as (source: string) => unknown;
return evaluate(c);
},
args: [code],
});
return results[0]?.result ?? null;
+33 -1
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
@@ -5,8 +6,39 @@ export class ExtensionCommands extends CommandGroup {
readonly namespace = "extension";
readonly commands: Record<string, CommandEntry> = {
"extension.reload": () => {
setTimeout(() => chrome.runtime.reload(), 200);
setTimeout(() => api.runtime.reload(), 200);
return { reloading: true };
},
"extension.info": () => this.extensionInfo(),
"extension.capabilities": () => this.capabilities(),
};
private capabilities() {
return [
"extension.info",
"extension.capabilities",
"navigate.open.focus",
"navigate.open.background",
"tabs.close.tabIds",
"tabs.merge_windows.audibleAware",
"session.export",
"session.import",
"jobs.progress",
"jobs.cancel",
"content-dispatch.bundle",
];
}
private extensionInfo() {
const manifest = api.runtime.getManifest();
return {
id: api.runtime.id,
name: manifest.name,
version: manifest.version,
manifestVersion: manifest.manifest_version,
browser: navigator.userAgent,
platform: navigator.platform,
capabilities: this.capabilities(),
};
}
}
+20 -19
View File
@@ -1,4 +1,5 @@
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core';
import { webExtApi as api } from '../browser-api';
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
@@ -17,8 +18,8 @@ export class GroupsCommands extends CommandGroup {
};
private async groupList() {
const groups = await chrome.tabGroups.query({});
const all = await chrome.tabs.query({});
const groups = await queryTabGroups({});
const all = await api.tabs.query({});
return groups.map(g => ({
id: g.id,
title: g.title,
@@ -30,58 +31,58 @@ export class GroupsCommands extends CommandGroup {
}
private async groupTabs({ groupId }: GroupTabsArgs) {
const all = await chrome.tabs.query({});
const all = await api.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo);
}
private async groupCount() {
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
return groups.length;
}
private async groupQuery({ search }: GroupQueryArgs) {
const q = search.toLowerCase();
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
}
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({});
const tabs = await api.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId);
const tabIds = groupTabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
await processInBatches(tabIds, throttle, batch => ungroupTabs(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
});
}
private async groupOpen({ name }: GroupOpenArgs) {
const tab = await chrome.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
await chrome.tabGroups.update(groupId, { title: name });
const tab = await api.tabs.create({ active: true });
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
await updateTabGroup(groupId, { title: name });
return { id: groupId, name };
}
private async groupAddTab({ group, url }: GroupAddTabArgs) {
const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId });
const existingTabs = await api.tabs.query({ groupId });
const tab = await api.tabs.create({ url: url || "chrome://newtab/", active: true });
await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
// If a URL was provided, close any blank placeholder tabs left from group creation
if (url) {
const placeholders = existingTabs.filter(t =>
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
);
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
}
return { tabId: tab.id, groupId };
}
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
const groupId = await resolveGroupId(group);
const groupInfo = await chrome.tabGroups.get(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
const groupInfo = await getTabGroup(groupId);
const allTabs = await api.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index);
const blocks = buildTabBlocks(allTabs);
@@ -98,7 +99,7 @@ export class GroupsCommands extends CommandGroup {
nextBlock.groupId === null
? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
} else if (backward) {
const previousBlock = blocks[currentIdx - 1];
if (!previousBlock) return { groupId, moved: false };
@@ -106,7 +107,7 @@ export class GroupsCommands extends CommandGroup {
previousBlock.groupId === null
? currentBlock.startIndex - 1
: previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
}
return { groupId, moved: true };
+47 -22
View File
@@ -1,4 +1,6 @@
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
@@ -17,7 +19,7 @@ export class NavigationCommands extends CommandGroup {
"navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a),
};
private async navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
private async navOpen({ url, background, focus, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
let windowId: number | undefined;
if (explicitWindowId != null) {
windowId = explicitWindowId;
@@ -26,34 +28,57 @@ export class NavigationCommands extends CommandGroup {
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]);
}
const tab = await chrome.tabs.create({ url, active: !background, windowId });
const tab = await api.tabs.create({ url, active: Boolean(focus) && !background, windowId });
if (groupNameOrId != null) {
let groupId;
try {
groupId = await resolveGroupId(groupNameOrId);
// Close any blank placeholder tabs that were created when the group was made
const groupTabs = await chrome.tabs.query({ groupId });
const groupTabs = await api.tabs.query({ groupId });
const placeholders = groupTabs.filter(t =>
t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
);
await chrome.tabs.group({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
await groupTabIds({ tabIds: [tab.id], groupId });
if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} catch (e) {
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
// Group doesn't exist — create it with the tab already in it
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
groupId = await groupTabIds({ tabIds: [tab.id] });
await updateTabGroup(groupId, { title: String(groupNameOrId) });
}
}
return { id: tab.id, url: tab.url };
const loadedTab = await this.waitForOpenedTabUrl(tab.id, url, tab);
return { id: loadedTab.id, url: loadedTab.url || loadedTab.pendingUrl || url };
}
private async waitForOpenedTabUrl(tabId: number, targetUrl: string, initialTab: Tab): Promise<Tab> {
const initialUrl = initialTab.url || initialTab.pendingUrl || "";
if (this.isOpenedTabUrlReady(initialUrl, targetUrl)) return initialTab;
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || "";
if (this.isOpenedTabUrlReady(currentUrl, targetUrl)) return current;
await new Promise(r => setTimeout(r, 50));
}
return api.tabs.get(tabId);
}
private isOpenedTabUrlReady(currentUrl: string, targetUrl: string): boolean {
if (!currentUrl) return false;
if (currentUrl === targetUrl || currentUrl.startsWith(targetUrl)) return true;
if (targetUrl === "about:blank" || targetUrl === "chrome://newtab/") return currentUrl === targetUrl;
return currentUrl !== "about:blank" && currentUrl !== "chrome://newtab/";
}
private async navTo({ tabId, url }: NavToArgs) {
const tab = await chrome.tabs.update(tabId, { url });
const tab = await api.tabs.update(tabId, { url });
const deadline = Date.now() + 1000;
while (tabId && Date.now() < deadline) {
const current = await chrome.tabs.get(tabId);
const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || "";
if (currentUrl === url || currentUrl.startsWith(url)) {
return { id: current.id, url: currentUrl };
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache });
await api.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id };
}
private async navBack({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id);
await api.tabs.goBack(tab.id);
return { tabId: tab.id };
}
private async navForward({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id);
await api.tabs.goForward(tab.id);
return { tabId: tab.id };
}
private async navFocus({ pattern }: NavFocusArgs) {
// If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern);
let match: chrome.tabs.Tab | undefined;
let match: Tab | undefined;
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt);
match = await api.tabs.get(asInt);
} else {
const all = await chrome.tabs.query({});
const all = await api.tabs.query({});
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
}
if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: true });
await api.windows.update(match.windowId, { focused: true });
await api.tabs.update(match.id, { active: true });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
}
@@ -102,7 +127,7 @@ export class NavigationCommands extends CommandGroup {
const deadline = Date.now() + timeout;
const interval = 200;
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const t = await api.tabs.get(tab.id);
const currentUrl = t.url || t.pendingUrl || "";
if (isBrowserErrorUrl(currentUrl)) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
@@ -115,8 +140,8 @@ export class NavigationCommands extends CommandGroup {
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
}
private async navOpenWait({ url, timeout = 30000, background, window: windowName, group }: NavOpenWaitArgs = {}) {
const opened = await this.navOpen({ url, background, window: windowName, group });
private async navOpenWait({ url, timeout = 30000, background, focus, window: windowName, group }: NavOpenWaitArgs = {}) {
const opened = await this.navOpen({ url, background, focus, window: windowName, group });
return await this.navWait({ tabId: opened.id, timeout });
}
}
+6 -4
View File
@@ -1,7 +1,9 @@
import { normalizeGroupColor } from '../core';
import { webExtApi as api } from '../browser-api';
import type { Tab, TabGroup } from '../types';
import { normalizeGroupColor, queryTabGroups } from '../core';
import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] {
export function buildSessionSnapshot(tabs: Tab[], groups: TabGroup[]): SessionTab[] {
const groupById = new Map(groups.map(group => [group.id, group]));
return tabs
.filter(tab => Boolean(tab.url || tab.pendingUrl))
@@ -27,8 +29,8 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
* its change-detection signature. Shared by session.save and the autosave path.
*/
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const tabs = await api.tabs.query({});
const groups = await queryTabGroups({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const session: StoredSession = {

Some files were not shown because too many files have changed in this diff Show More