diff --git a/.gitea/workflows/testing.yml b/.gitea/workflows/testing.yml index f928f3a..b3bd5c1 100644 --- a/.gitea/workflows/testing.yml +++ b/.gitea/workflows/testing.yml @@ -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 diff --git a/.python-version b/.python-version index 24ee5b1..93a848f 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.15 diff --git a/browser_cli/commands/auth.py b/browser_cli/commands/auth.py index 146ea14..ceae8ac 100644 --- a/browser_cli/commands/auth.py +++ b/browser_cli/commands/auth.py @@ -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: diff --git a/browser_cli/commands/install.py b/browser_cli/commands/install.py index 7c3a471..8880382 100644 --- a/browser_cli/commands/install.py +++ b/browser_cli/commands/install.py @@ -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]") diff --git a/browser_cli/markdown/html.py b/browser_cli/markdown/html.py index feaeacb..65fa20c 100644 --- a/browser_cli/markdown/html.py +++ b/browser_cli/markdown/html.py @@ -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 "" diff --git a/browser_cli/mcp/server.py b/browser_cli/mcp/server.py index 8efaee0..31a3744 100644 --- a/browser_cli/mcp/server.py +++ b/browser_cli/mcp/server.py @@ -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)) diff --git a/browser_cli/registry.py b/browser_cli/registry.py index d8beb03..cfacb5b 100644 --- a/browser_cli/registry.py +++ b/browser_cli/registry.py @@ -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 diff --git a/extension/manifest.json b/extension/manifest.json index 8c90a12..cea1723 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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": { diff --git a/pyproject.toml b/pyproject.toml index d069bd2..c7b4c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_auth.py b/tests/test_auth.py index e1e75df..d3edeae 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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): diff --git a/tests/test_cli.py b/tests/test_cli.py index c166e34..0972236 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index 42c4f48..6ffc1ba 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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) diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index 9a50fd9..686586f 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 341de73..b790153 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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": ""}) == "" diff --git a/tests/test_native_host.py b/tests/test_native_host.py index 5b154f2..5e80e00 100644 --- a/tests/test_native_host.py +++ b/tests/test_native_host.py @@ -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 = [] diff --git a/tests/test_new_feature_commands.py b/tests/test_new_feature_commands.py index 17f7151..68e1b92 100644 --- a/tests/test_new_feature_commands.py +++ b/tests/test_new_feature_commands.py @@ -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 diff --git a/tests/test_serve.py b/tests/test_serve.py index 1e067db..67ea6b1 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -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) diff --git a/tests/test_serve_security.py b/tests/test_serve_security.py index b3d2330..608e01b 100644 --- a/tests/test_serve_security.py +++ b/tests/test_serve_security.py @@ -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"])] diff --git a/uv.lock b/uv.lock index c3b5bdc..4083c59 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },