feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Testing / test (push) Successful in 26s
Package Extension / package-extension (push) Successful in 22s
Build & Publish Package / publish (push) Successful in 27s

Security:
- serve.py: server now sends nonce challenge before accepting any command;
  clients sign nonce + SHA256(canonical_payload) with Ed25519 key
- New --authorized-keys FILE option for serve; token auth still works as fallback
- Connection limit: BoundedSemaphore(64) in serve.py
- Secure file creation with os.open(..., 0o600) for token/key files
- New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python),
  sign/verify helpers compatible with both file keys and agent-held keys (YubiKey,
  TPM, gpg-agent)

Features:
- YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK
- New `browser-cli auth` command group: keygen, trust, show, keys
- Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key;
  pass "agent" or "agent:<selector>" to use SSH agent key
- BrowserCLI Python API gains key= parameter

Bug fixes (11 issues across two review passes):
- client.py: check response is not None before json.loads
- native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows
  Listener leak on accept error
- __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None
- extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback
- extension/session.ts: removeListener before addListener prevents duplicate handlers

Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:20:39 +02:00
parent 9f03e29807
commit 4b2abbbfc5
14 changed files with 735 additions and 121 deletions
+44 -44
View File
@@ -65,7 +65,7 @@ class TestBrowserCLIInit:
assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", token="secret")
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
assert b._browser == "work"
assert b._remote == "host:8765"
assert b._token == "secret"
@@ -129,7 +129,7 @@ class TestNavigation:
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": False, "window": None, "group": None},
profile=None, remote=None, token=None,
profile=None, remote=None, token=None, key=None,
)
def test_open_background(self, b, mock_send):
@@ -143,38 +143,38 @@ class TestNavigation:
def test_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None, key=None)
def test_hard_reload(self, b, mock_send):
b.hard_reload(tab_id=7)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None, key=None)
def test_back(self, b, mock_send):
b.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
def test_forward(self, b, mock_send):
b.forward(tab_id=3)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
def test_focus_url(self, b, mock_send):
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None, key=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None, key=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", token="secret")
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
b.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret")
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret", key=None)
# ── Search ────────────────────────────────────────────────────────────────────
@@ -207,12 +207,12 @@ class TestExtract:
result = b.extract_markdown()
assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None, key=None)
def test_extract_markdown_selector(self, b, mock_send):
b.extract_markdown("article")
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None, key=None)
# ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -247,7 +247,7 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": 10, "inactive": False, "duplicates": False},
profile=None, remote=None, token=None,
profile=None, remote=None, token=None, key=None,
)
def test_tabs_move(self, b, mock_send):
@@ -255,19 +255,19 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None,
profile=None, remote=None, token=None, key=None,
)
def test_tabs_active(self, b, mock_send):
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None, key=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
@@ -319,11 +319,11 @@ class TestTabs:
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None, key=None),
]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret")
b = BrowserCLI(remote="host:8765", token="secret", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
@@ -337,8 +337,8 @@ class TestTabs:
assert [tab.browser for tab in tabs] == ["host:work"]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", token="secret"),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret"),
call("tabs.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret", key=None),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -381,7 +381,7 @@ class TestTabs:
def test_tabs_sort(self, b, mock_send):
b.tabs_sort(by="title")
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None, key=None)
def test_tabs_merge_windows(self, b, mock_send):
mock_send.return_value = {"moved": 4}
@@ -414,7 +414,7 @@ class TestGroups:
mock_send.return_value = [TAB_DATA]
tabs = b.group_tabs(42)
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
def test_group_count(self, b, mock_send):
mock_send.return_value = 7
@@ -442,11 +442,11 @@ class TestGroups:
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None),
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None, key=None),
]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret")
b = BrowserCLI(remote="host:8765", token="secret", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
@@ -460,8 +460,8 @@ class TestGroups:
assert [group.browser for group in groups] == ["host:work"]
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", token="secret"),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret"),
call("group.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret", key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -484,7 +484,7 @@ class TestGroups:
def test_group_close(self, b, mock_send):
b.group_close(42)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
def test_group_create_dict_response(self, b, mock_send):
mock_send.return_value = GROUP_DATA
@@ -504,7 +504,7 @@ class TestGroups:
tab_id = b.group_add_tab(42, "https://example.com")
assert tab_id == 55
mock_send.assert_called_once_with(
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
)
def test_group_add_tab_non_dict_response(self, b, mock_send):
@@ -514,7 +514,7 @@ class TestGroups:
def test_group_move_forward(self, b, mock_send):
b.group_move(42, forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
)
@@ -544,7 +544,7 @@ class TestWindows:
result = b.windows_open()
assert result == {"id": 5}
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None, key=None)
def test_windows_open_with_url(self, b, mock_send):
mock_send.return_value = {"id": 9}
@@ -552,7 +552,7 @@ class TestWindows:
result = b.windows_open("https://example.com")
assert result == {"id": 9}
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None, key=None)
class TestSession:
@@ -562,7 +562,7 @@ class TestSession:
result = b.session_list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None, key=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
@@ -597,26 +597,26 @@ class TestTabModel:
def test_close(self, tab, mock_send):
tab.close()
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_hard_reload(self, tab, mock_send):
tab.hard_reload()
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_move_forward(self, tab, mock_send):
tab.move(forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None,
profile=None, remote=None, token=None, key=None,
)
def test_move_to_group(self, tab, mock_send):
@@ -626,12 +626,12 @@ class TestTabModel:
def test_html(self, tab, mock_send):
mock_send.return_value = "<html/>"
assert tab.html() == "<html/>"
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None, key=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
@@ -639,7 +639,7 @@ class TestTabModel:
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None, remote=None, token=None,
profile=None, remote=None, token=None, key=None,
)
def test_unbound_raises(self):
@@ -657,18 +657,18 @@ class TestGroupModel:
def test_close(self, group, mock_send):
group.close()
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = group.tabs()
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
def test_move_forward(self, group, mock_send):
group.move(forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
)
def test_move_backward(self, group, mock_send):
+3 -3
View File
@@ -168,7 +168,7 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None, remote=None, token=None):
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
@@ -194,7 +194,7 @@ def test_clients_remote_respects_global_browser_route():
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None)
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None, key=None)
def test_clients_browser_alias_resolves_to_remote():
@@ -211,7 +211,7 @@ def test_clients_browser_alias_resolves_to_remote():
)
all_remote_targets = [resolved_target]
def fake_send_command(command, args=None, profile=None, remote=None, token=None):
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
assert command == "clients.list"
assert profile == "automatisation"
assert remote == "192.168.188.104:8765"
+5 -11
View File
@@ -118,15 +118,13 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent.update(msg)
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list", remote="host:8765", token="secret") == "ok"
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
@@ -142,9 +140,7 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
@@ -198,9 +194,7 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
@@ -246,7 +240,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
def fake_send_command(command, args=None, profile=None, remote=None, token=None):
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
assert command == "browser-cli.targets"
assert remote == endpoint
assert token == "secret-token"