fix: restore Python 3.10/3.11 support and report MCP version
Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s

- Drop backslash from f-string expression in markdown/html.py
- That syntax needs 3.12+ (PEP 701) and broke import on 3.10/3.11
- Add Python matrix 3.10/3.12/3.14/3.15 to the Gitea testing workflow
- Pass project_version() to MCPServer; serverInfo.version was empty
- Cover the reported version with an in-memory MCP client test
- Swap deprecated click.get_text_stream for sys.stdin.isatty()
- Add explicit encoding to native host shim and manifest writes
- Add explicit encoding to the registry lock file open
- Add explicit encoding to every read_text/write_text call in tests
- Bump version to 0.16.10 in pyproject.toml and extension/manifest.json
- Move .python-version to 3.15 and refresh uv.lock

Extension code is unchanged; only the manifest version string moved.

Verified with 778 tests on 3.10 through 3.15, plus a strict run using -X warn_default_encoding with EncodingWarning and DeprecationWarning as errors.
This commit is contained in:
2026-10-20 12:41:25 +02:00
parent 7cb034dfc2
commit 69387c1054
19 changed files with 155 additions and 88 deletions
+10 -2
View File
@@ -8,6 +8,14 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.12"
- "3.14"
- "3.15"
steps:
- name: Checkout
@@ -17,10 +25,10 @@ jobs:
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --group dev --managed-python
run: uv sync --group dev --managed-python --python ${{ matrix.python-version }}
- name: Run tests
run: uv run pytest
run: uv run --python ${{ matrix.python-version }} pytest
remote-protocol-compat:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1 +1 @@
3.13
3.15
+1 -1
View File
@@ -123,7 +123,7 @@ def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow
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()
is_interactive = bool(sys.stdin is not None and sys.stdin.isatty())
current_categories = None
if not identifier:
if not is_interactive:
+2 -2
View File
@@ -38,7 +38,7 @@ def write_native_host_exe(path: Path) -> None:
encoding="utf-8",
)
else:
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n')
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n', encoding="utf-8")
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views():
@@ -156,7 +156,7 @@ def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
try:
directory.mkdir(parents=True, exist_ok=True)
manifest_path = directory / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {directory}: {e}[/yellow]")
+2 -1
View File
@@ -114,7 +114,8 @@ def _inline_text(node):
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))
return f"`{text.replace('`', r'\\`')}`" if text else ""
escaped = text.replace("`", r"\\`")
return f"`{escaped}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
+2
View File
@@ -18,6 +18,7 @@ from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
from browser_cli.url_match import find_reusable_tab, needs_navigation, tab_id as read_tab_id, tab_url
from browser_cli.version_manager import project_version
ClientFactory = Callable[..., BrowserCLI]
@@ -70,6 +71,7 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
"browser-cli",
description="Control a real running browser through the browser-cli SDK.",
instructions=_SERVER_INSTRUCTIONS,
version=project_version(),
)
@mcp.tool(name=tool_name("tabs_list", prefix))
+1 -1
View File
@@ -15,7 +15,7 @@ def _file_lock(path: Path) -> Iterator[None]:
"""Best-effort cross-process lock for registry read/modify/write updates."""
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
lock_path = path.with_suffix(path.suffix + ".lock")
with lock_path.open("a+") as lock_file:
with lock_path.open("a+", encoding="utf-8") as lock_file:
if os.name == "nt":
try:
import msvcrt
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.16.9",
"version": "0.16.10",
"description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"gecko": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "real-browser-cli"
version = "0.16.9"
version = "0.16.10"
description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md"
license = { file = "LICENSE" }
+1 -1
View File
@@ -163,7 +163,7 @@ class TestAuthorizedKeys:
def test_ignores_comment_lines(self, tmp_path):
path = tmp_path / "authorized_keys"
path.write_text("# this is a comment\n")
path.write_text("# this is a comment\n", encoding="utf-8")
assert load_authorized_keys(path) == []
def test_returns_empty_for_missing_file(self, tmp_path):
+1 -1
View File
@@ -208,7 +208,7 @@ def test_write_native_host_exe_unix(tmp_path):
write_native_host_exe(host)
assert host.exists()
content = host.read_text()
content = host.read_text(encoding="utf-8")
assert content.startswith(f"#!{sys.executable}")
assert "from browser_cli.native.host import main" in content
assert host.stat().st_mode & 0o111 # executable bit set
+5 -5
View File
@@ -41,7 +41,7 @@ def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_path):
socket_path = tmp_path / "browser.sock"
listener = _listening_unix_socket(socket_path)
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
@@ -58,7 +58,7 @@ def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_pat
first_listener = _listening_unix_socket(first_socket)
second_listener = _listening_unix_socket(second_socket)
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}))
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
@@ -87,7 +87,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
stale_listener = _listening_unix_socket(stale_socket)
stale_listener.close()
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
@@ -102,7 +102,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.targets.is_windows", lambda: True)
@@ -174,7 +174,7 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
socket_path = tmp_path / "work.sock"
socket_path.write_text("")
socket_path.write_text("", encoding="utf-8")
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": str(socket_path)}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
+26 -26
View File
@@ -8,14 +8,14 @@ def read_built_background() -> str:
background_path = ROOT / "extension" / "background.js"
if not background_path.exists():
pytest.skip("extension/background.js is a generated build artifact; run npm run build:extension")
return background_path.read_text()
return background_path.read_text(encoding="utf-8")
def test_extension_retries_error_page_script_injection_before_failing():
# core.ts was split into a core/ subfolder during the structure refactor:
# the URL/error classifiers live in core/errors.ts and the executeScript
# retry wrapper (which calls isTransientScriptError) lives in core/scripting.ts.
errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text()
scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text()
errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text(encoding="utf-8")
scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text(encoding="utf-8")
assert "isBrowserErrorUrl" in errors
assert "isErrorPageScriptError" in errors
@@ -27,7 +27,7 @@ def test_extension_retries_error_page_script_injection_before_failing():
assert "isTransientScriptError(e)" in scripting
def test_read_only_dom_commands_have_error_page_fallbacks():
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text(encoding="utf-8")
assert "fallbackForErrorPageDomOp" in dom
assert 'case "domExists":' in dom
@@ -40,8 +40,8 @@ def test_read_only_dom_commands_have_error_page_fallbacks():
def test_navigation_and_tabs_report_browser_error_pages():
# tabs.watch_url (which reports tab error pages) moved into the read-only
# TabsQueryCommands class in tabs-query.ts during the structure refactor.
tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text()
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text(encoding="utf-8")
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text(encoding="utf-8")
assert "lastUrl" in tabs
assert "lastStatus" in tabs
@@ -56,10 +56,10 @@ def test_large_extension_operations_yield_between_batches():
# batch loop (cancel-check -> batch call -> progress -> yield) was then
# centralized into the processInBatches() helper in core/throttle.ts, so the
# command modules call that instead of inlining the loop.
core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text()
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text()
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text(encoding="utf-8")
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text(encoding="utf-8")
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text(encoding="utf-8")
assert "yieldForLargeOperation" in core
assert "getLargeOperationThrottle" in core
@@ -105,10 +105,10 @@ def test_large_extension_operations_yield_between_batches():
# and the command registry/groups during the class-based refactor. The
# behavior is identical; assert the defining patterns still exist in their
# new homes.
jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text()
connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text()
perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text()
tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text(encoding="utf-8")
connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text(encoding="utf-8")
perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text(encoding="utf-8")
tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
assert "background: true" in tabs_module # background command flag (was BACKGROUND_COMMANDS)
assert "persistJobs" in jobs_module
@@ -120,8 +120,8 @@ def test_large_extension_operations_yield_between_batches():
assert "__background" in connection
def test_tab_activation_open_and_merge_do_not_steal_audible_video_window():
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text(encoding="utf-8")
assert "await chrome.windows.update(tab.windowId, { focused: true });" not in tabs
assert "active: Boolean(focus) && !background" in navigation
@@ -151,8 +151,8 @@ def test_session_autosave_is_debounced_and_non_overlapping():
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
# snapshot/signature helpers live in session-snapshot.ts. Behavior is
# identical — the defining patterns just live in their new homes.
autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text()
snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text()
autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text(encoding="utf-8")
snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text(encoding="utf-8")
assert "autoSaveTimer" in autosave
assert "autoSaveInFlight" in autosave
@@ -172,12 +172,12 @@ def test_session_autosave_is_debounced_and_non_overlapping():
assert "clearTimeout(this.autoSaveTimer)" in autosave
def test_cli_and_sdk_expose_gentle_restore_controls():
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text()
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text()
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text()
commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text()
sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text()
sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text()
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text(encoding="utf-8")
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text(encoding="utf-8")
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text(encoding="utf-8")
commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text(encoding="utf-8")
sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text(encoding="utf-8")
sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text(encoding="utf-8")
# The --gentle-mode flag is defined once via the shared gentle_mode_option helper.
assert '"--gentle-mode"' in commands_init
@@ -200,8 +200,8 @@ def test_cli_and_sdk_expose_gentle_restore_controls():
assert "def status" in sdk_perf
assert "def set_profile" in sdk_perf
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text()
root_cli = (ROOT / "browser_cli" / "cli.py").read_text()
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text(encoding="utf-8")
root_cli = (ROOT / "browser_cli" / "cli.py").read_text(encoding="utf-8")
assert "perf_group" in perf_cli
assert "perf.status" in perf_cli
assert "perf.set_profile" in perf_cli
+10
View File
@@ -15,6 +15,7 @@ from browser_cli.mcp.serialization import structured
from browser_cli.mcp.naming import resolve_tool_prefix
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
from browser_cli.models import Tab
from browser_cli.version_manager import project_version
class FakeClient:
instances: list["FakeClient"] = []
@@ -147,6 +148,15 @@ async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
assert FakeClient.instances[-1].target == ("testing", None, None)
@pytest.mark.anyio
async def test_server_reports_the_project_version_to_clients(client):
info = client.server_info
assert info is not None
assert info.name == "browser-cli"
assert info.version == project_version()
assert info.version not in ("", "unknown")
def test_tool_prefix_defaults_to_browser_and_is_configurable():
assert resolve_tool_prefix({}) == "browser_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
+5 -5
View File
@@ -14,9 +14,9 @@ def _raise_system_exit(code: int):
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
alias = "work"
socket_path = tmp_path / "work.sock"
socket_path.write_text("")
socket_path.write_text("", encoding="utf-8")
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}), encoding="utf-8")
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
@@ -25,7 +25,7 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
native_host._cleanup(alias)
assert not socket_path.exists()
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
assert json.loads(registry_path.read_text(encoding="utf-8")) == {"other": str(tmp_path / "other.sock")}
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
cleaned = []
@@ -42,14 +42,14 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}), encoding="utf-8")
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {}
assert json.loads(registry_path.read_text(encoding="utf-8")) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = []
+11 -11
View File
@@ -245,7 +245,7 @@ def test_serve_tcp_rate_limit_zero_disables():
def test_serve_tcp_per_key_policies_loaded_from_authorized_keys(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text("abc123 reader allow:read-page\ndef456 admin allow:all\nghi789 plain\n")
keys.write_text("abc123 reader allow:read-page\ndef456 admin allow:all\nghi789 plain\n", encoding="utf-8")
security = _serve_security_for(["--authorized-keys", str(keys)])
assert security.key_policies["abc123"] == CommandPolicy(allow_read_page=True)
assert security.key_policies["def456"] == CommandPolicy.unrestricted()
@@ -260,7 +260,7 @@ def test_auth_trust_writes_inline_policy_token(tmp_path):
"--allow-read-page", "--allow-control",
])
assert result.exit_code == 0
line = keys.read_text().strip()
line = keys.read_text(encoding="utf-8").strip()
assert line == f"{pub} ci bot allow:read-page,control"
def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
@@ -268,11 +268,11 @@ def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
pub = "b" * 64
result = CliRunner().invoke(main, ["auth", "trust", pub, "--name", "plain", "--file", str(keys)])
assert result.exit_code == 0
assert keys.read_text().strip() == f"{pub} plain"
assert keys.read_text(encoding="utf-8").strip() == f"{pub} plain"
def test_auth_keys_local_shows_policy_column(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} reader allow:read-page\n{'b' * 64} admin allow:all\n{'c' * 64} plain\n")
keys.write_text(f"{'a' * 64} reader allow:read-page\n{'b' * 64} admin allow:all\n{'c' * 64} plain\n", encoding="utf-8")
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
assert result.exit_code == 0
assert "Policy" in result.output
@@ -286,37 +286,37 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
def test_auth_policy_updates_existing_key_policy(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "a" * 64
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n", encoding="utf-8")
result = CliRunner().invoke(main, [
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
])
assert result.exit_code == 0
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
assert keys.read_text(encoding="utf-8").strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
assert "Updated policy" in result.output
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "b" * 64
keys.write_text(f"{pub} laptop allow:all\n")
keys.write_text(f"{pub} laptop allow:all\n", encoding="utf-8")
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
assert safe_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop allow:"
assert keys.read_text(encoding="utf-8").strip() == f"{pub} laptop allow:"
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
assert default_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop"
assert keys.read_text(encoding="utf-8").strip() == f"{pub} laptop"
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
keys.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
assert result.exit_code == 1
assert "Choose a policy mode" in result.output
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
keys.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
assert result.exit_code == 1
assert "Choose exactly one policy mode" in result.output
+10 -10
View File
@@ -74,7 +74,7 @@ class TestChallenge:
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
client, server = _pair()
t = _spawn(server, path)
@@ -106,7 +106,7 @@ class TestRejection:
def test_missing_pubkey_sig_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
client, t, _ = self._connect(path)
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
_send_framed(client, json.dumps(msg).encode())
@@ -119,7 +119,7 @@ class TestRejection:
def test_untrusted_pubkey_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, trusted_pub = generate_keypair()
path.write_text(trusted_pub + "\n")
path.write_text(trusted_pub + "\n", encoding="utf-8")
pem, untrusted_pub = generate_keypair()
key_path = tmp_path / "other.pem"
@@ -140,7 +140,7 @@ class TestRejection:
def test_bad_signature_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
client, t, _ = self._connect(path)
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
_send_framed(client, json.dumps(msg).encode())
@@ -154,7 +154,7 @@ class TestRejection:
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
priv_path = tmp_path / "client.pem"
priv_path.write_bytes(pem)
priv = load_private_key(priv_path)
@@ -200,7 +200,7 @@ class TestAuthSuccess:
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
@@ -236,7 +236,7 @@ class TestAuthSuccess:
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
@@ -276,7 +276,7 @@ class TestAuthSuccess:
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
@@ -411,7 +411,7 @@ class TestPerKeyPolicy:
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
path.write_text(pub + "\n", encoding="utf-8")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
@@ -441,7 +441,7 @@ class TestPerKeyPolicy:
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + " mykey\n")
path.write_text(pub + " mykey\n", encoding="utf-8")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
+11 -10
View File
@@ -64,39 +64,39 @@ def test_full_control_still_cannot_manage_keys():
def test_set_policy_updates_by_pubkey(tmp_path):
path = tmp_path / "authorized_keys"
pub = "a" * 64
path.write_text(f"{pub} laptop\n")
path.write_text(f"{pub} laptop\n", encoding="utf-8")
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
def test_set_policy_by_name_and_remove_with_none(tmp_path):
path = tmp_path / "authorized_keys"
pub = "b" * 64
path.write_text(f"{pub} ci-bot allow:all\n")
path.write_text(f"{pub} ci-bot allow:all\n", encoding="utf-8")
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
def test_set_policy_safe_only_writes_empty_token(tmp_path):
path = tmp_path / "authorized_keys"
pub = "c" * 64
path.write_text(f"{pub} reader\n")
path.write_text(f"{pub} reader\n", encoding="utf-8")
set_authorized_key_policy(path, pub, [])
assert path.read_text().strip() == f"{pub} reader allow:"
assert path.read_text(encoding="utf-8").strip() == f"{pub} reader allow:"
def test_set_policy_not_found_returns_none(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} laptop\n")
path.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
def test_set_policy_ambiguous_name_raises(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n", encoding="utf-8")
with pytest.raises(ValueError, match="ambiguous"):
set_authorized_key_policy(path, "dup", ["control"])
def test_set_policy_preserves_other_lines(tmp_path):
path = tmp_path / "authorized_keys"
a, b = "a" * 64, "b" * 64
path.write_text(f"{a} first\n{b} second allow:read-page\n")
path.write_text(f"{a} first\n{b} second allow:read-page\n", encoding="utf-8")
set_authorized_key_policy(path, a, ["control"])
assert load_authorized_keys_with_policies(path) == [
(a, "first", ["control"]),
@@ -139,7 +139,8 @@ def test_key_policies_from_authorized_keys(tmp_path):
path.write_text(
"AABBCC laptop allow:all\n"
"ddee01 ci-bot allow:read-page,control\n"
"112233 readonly\n" # no allow token → no override entry
"112233 readonly\n", # no allow token → no override entry
encoding="utf-8",
)
policies = key_policies_from_authorized_keys(path)
assert policies["aabbcc"] == CommandPolicy.unrestricted() # normalised to lowercase
@@ -151,13 +152,13 @@ def test_key_policies_none_returns_empty():
def test_key_policies_rejects_unknown_category(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text("abc name allow:bogus\n")
path.write_text("abc name allow:bogus\n", encoding="utf-8")
with pytest.raises(ValueError, match="unknown command category"):
key_policies_from_authorized_keys(path)
def test_load_with_names_ignores_allow_token(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text("abc my laptop allow:control\n")
path.write_text("abc my laptop allow:control\n", encoding="utf-8")
assert load_authorized_keys_with_names(path) == [("abc", "my laptop")]
assert load_authorized_keys_with_policies(path) == [("abc", "my laptop", ["control"])]
Generated
+54 -9
View File
@@ -1,10 +1,10 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
"python_full_version < '3.11'",
]
@@ -396,6 +396,9 @@ wheels = [
name = "httpcore2"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "h11" },
{ name = "truststore" },
@@ -405,21 +408,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" },
]
[[package]]
name = "httpcore2"
version = "2.15.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
]
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/a9/11bc39967bb9dfaab0981ab4d49f519818acbee662a7e76d06813319c838/httpcore2-2.15.1.tar.gz", hash = "sha256:a94b6b3e6260e1a3f035915408d3dcd7dc8e844c2b544b642e227cf98a747af0", size = 69859, upload-time = "2026-10-19T20:14:23.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/e2b556e26fbc67716ab1dfc18832eb6fe196d88363f7094cb1da3d7c0d3e/httpcore2-2.15.1-py3-none-any.whl", hash = "sha256:730f603058c260a3288378217b464713350c23e3b8fe8bba753701054032d98a", size = 85055, upload-time = "2026-10-19T20:14:21.363Z" },
]
[[package]]
name = "httpx2"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" },
]
[[package]]
name = "httpx2"
version = "2.15.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
]
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", version = "2.15.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/85/07978f4afd168d6b408fa75c1dc13e3010653a81e182aa1b6d89ab94d9c6/httpx2-2.15.1.tar.gz", hash = "sha256:ff75485308fbe441e75b1218546e4e78d884df41e1a9741c1f79335ed8fcfef7", size = 101673, upload-time = "2026-10-19T20:14:24.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" },
{ url = "https://files.pythonhosted.org/packages/7a/bf/56068f774620330c8330e4b4915fae889a5b2d4b52667b3b7c8f66c4a9ad/httpx2-2.15.1-py3-none-any.whl", hash = "sha256:75841cd83d16b417f7d6da8f2089ce8120806efdbf96af37d46c5129c49015be", size = 96411, upload-time = "2026-10-19T20:14:22.789Z" },
]
[[package]]
@@ -495,7 +539,8 @@ version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx2" },
{ name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "httpx2", version = "2.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
@@ -954,7 +999,7 @@ wheels = [
[[package]]
name = "real-browser-cli"
version = "0.16.9"
version = "0.16.10"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1158,7 +1203,7 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
]
sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" }
wheels = [
@@ -1328,7 +1373,7 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
]
dependencies = [
{ name = "anyio" },