From 7ff3a0025e3dc365d1d3bf4ca0abe57b2575e1cb Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 20 Jul 2026 23:21:38 +0000 Subject: [PATCH 1/6] feat: add bb login command for console host registration Starts a device-authorization flow against a bot-bottle console, polls until the operator approves, then writes access + refresh tokens to $BOT_BOTTLE_ROOT/console.json. Console URL is read from --console-url flag or BB_CONSOLE_URL env var. Part of didericis/bot-bottle-platform#1 --- bot_bottle/cli/__init__.py | 5 +- bot_bottle/cli/login.py | 153 +++++++++++++++++++++++++++++++++++ tests/unit/test_cli_login.py | 147 +++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 bot_bottle/cli/login.py create mode 100644 tests/unit/test_cli_login.py diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index ae421fc..0061724 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -19,6 +19,7 @@ from .commit import cmd_commit from .edit import cmd_edit from .info import cmd_info from .init import cmd_init +from .login import cmd_login from .resume import cmd_resume from .start import cmd_start from .supervise import cmd_supervise @@ -33,6 +34,7 @@ COMMANDS = { "info": cmd_info, "init": cmd_init, "list": cmd_list, + "login": cmd_login, "resume": cmd_resume, "start": cmd_start, "supervise": cmd_supervise, @@ -43,7 +45,7 @@ COMMANDS = { # the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so # gating it on the schema breaks preflight on a fresh CI runner where stdin # isn't a TTY and the migration prompt can't be answered. -NO_MIGRATION_COMMANDS = frozenset({"backend"}) +NO_MIGRATION_COMMANDS = frozenset({"backend", "login"}) def usage() -> None: @@ -56,6 +58,7 @@ def usage() -> None: sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") sys.stderr.write(" list list available agents or active containers\n") + sys.stderr.write(" login register this host with a bot-bottle console\n") sys.stderr.write( " resume re-launch a bottle by its identity " "(continues state from PRD 0016)\n" diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/login.py new file mode 100644 index 0000000..6410266 --- /dev/null +++ b/bot_bottle/cli/login.py @@ -0,0 +1,153 @@ +"""bb login — register this host with a bot-bottle console. + +Opens a device-authorization flow against the target console, waits for the +operator to approve, then writes access and refresh tokens to +~/.bot-bottle/console.json (or $BOT_BOTTLE_ROOT/console.json). + +Usage: + bb login [--console-url URL] [--label LABEL] + +Flags: + --console-url URL Target console URL (overrides BB_CONSOLE_URL env var) + --label LABEL Host label shown in the console (default: hostname) +""" + +from __future__ import annotations + +import json +import os +import socket +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +from ..paths import bot_bottle_root + +_CONSOLE_URL_ENV = "BB_CONSOLE_URL" +_POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default + + +def _usage() -> None: + sys.stderr.write( + "usage: bb login [--console-url URL] [--label LABEL]\n" + "\n" + "Options:\n" + " --console-url URL Console base URL (or BB_CONSOLE_URL env var)\n" + " --label LABEL Host label shown in the console (default: hostname)\n" + ) + + +def _flag(argv: list[str], name: str) -> str | None: + for i, arg in enumerate(argv): + if arg == name and i + 1 < len(argv): + return argv[i + 1] + if arg.startswith(f"{name}="): + return arg[len(name) + 1:] + return None + + +def _post(url: str, payload: dict) -> dict: + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + +def _get(url: str) -> tuple[int, dict]: + req = urllib.request.Request(url) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, {} + + +def _save_credentials( + console_url: str, host_id: str, access_token: str, refresh_token: str +) -> Path: + path = bot_bottle_root() / "console.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "url": console_url, + "host_id": host_id, + "access_token": access_token, + "refresh_token": refresh_token, + }, + indent=2, + ) + + "\n" + ) + path.chmod(0o600) + return path + + +def cmd_login(argv: list[str]) -> int: + if "--help" in argv or "-h" in argv: + _usage() + return 0 + + console_url = _flag(argv, "--console-url") or os.environ.get(_CONSOLE_URL_ENV) + if not console_url: + sys.stderr.write( + "bb login: --console-url or BB_CONSOLE_URL is required\n" + ) + return 1 + console_url = console_url.rstrip("/") + + label = _flag(argv, "--label") or socket.gethostname() + + try: + resp = _post(f"{console_url}/api/v1/hosts/authorize", {"label": label}) + except Exception as exc: + sys.stderr.write(f"bb login: failed to start authorization: {exc}\n") + return 1 + + device_code = resp["device_code"] + user_code = resp["user_code"] + expires_in = resp.get("expires_in", 300) + + sys.stderr.write( + f"\nOpen this URL in your browser to authorize this host:\n\n" + f" {console_url}/authorize?code={user_code}\n\n" + f"Waiting for approval" + ) + + deadline = time.monotonic() + expires_in + while time.monotonic() < deadline: + sys.stderr.write(".") + sys.stderr.flush() + time.sleep(_POLL_SLEEP) + + try: + code, result = _get( + f"{console_url}/api/v1/hosts/authorize/{device_code}" + ) + except Exception: + continue + + if code == 410: + break + + st = result.get("status") + if st == "approved": + sys.stderr.write("\n\nApproved.\n") + path = _save_credentials( + console_url, + result["host_id"], + result["access_token"], + result["refresh_token"], + ) + sys.stderr.write(f"Credentials saved to {path}\n") + return 0 + if st == "denied": + sys.stderr.write("\n\nDenied by operator.\n") + return 1 + + sys.stderr.write("\n\nAuthorization timed out.\n") + return 1 diff --git a/tests/unit/test_cli_login.py b/tests/unit/test_cli_login.py new file mode 100644 index 0000000..0cb2c9b --- /dev/null +++ b/tests/unit/test_cli_login.py @@ -0,0 +1,147 @@ +"""Unit tests for bb login command.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + + +class TestFlagParsing(unittest.TestCase): + def test_console_url_flag(self) -> None: + from bot_bottle.cli.login import _flag + self.assertEqual(_flag(["--console-url", "http://x"], "--console-url"), "http://x") + + def test_console_url_equals_form(self) -> None: + from bot_bottle.cli.login import _flag + self.assertEqual(_flag(["--console-url=http://x"], "--console-url"), "http://x") + + def test_label_flag(self) -> None: + from bot_bottle.cli.login import _flag + self.assertEqual(_flag(["--label", "my-mac"], "--label"), "my-mac") + + def test_missing_flag_returns_none(self) -> None: + from bot_bottle.cli.login import _flag + self.assertIsNone(_flag([], "--console-url")) + + +class TestSaveCredentials(unittest.TestCase): + def test_writes_json_and_sets_perms(self) -> None: + from bot_bottle.cli.login import _save_credentials + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + path = _save_credentials("http://c", "hid", "at", "rt") + self.assertTrue(path.exists()) + data = json.loads(path.read_text()) + self.assertEqual(data["url"], "http://c") + self.assertEqual(data["host_id"], "hid") + self.assertEqual(data["access_token"], "at") + self.assertEqual(data["refresh_token"], "rt") + self.assertEqual(oct(path.stat().st_mode & 0o777), oct(0o600)) + + +class TestCmdLoginMissingUrl(unittest.TestCase): + def test_returns_1_without_url(self) -> None: + from bot_bottle.cli.login import cmd_login + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("BB_CONSOLE_URL", None) + result = cmd_login([]) + self.assertEqual(result, 1) + + def test_reads_env_var(self) -> None: + """Exits 1 (network error) not because of missing URL when env var is set.""" + from bot_bottle.cli.login import cmd_login + + def _fail_post(url, payload): + raise OSError("connection refused") + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BB_CONSOLE_URL": "http://localhost:9999", "BOT_BOTTLE_ROOT": tmp}): + with patch("bot_bottle.cli.login._post", side_effect=_fail_post): + result = cmd_login([]) + self.assertEqual(result, 1) + + +class TestCmdLoginFlow(unittest.TestCase): + def _run_with_mocks(self, poll_responses: list[dict], tmp: str) -> int: + from bot_bottle.cli.login import cmd_login + + start_resp = { + "device_code": "dc123", + "user_code": "ABC-DEF", + "expires_in": 300, + "poll_interval": 0, + } + + poll_iter = iter(poll_responses) + + def _fake_post(url, payload): + return start_resp + + def _fake_get(url): + try: + resp = next(poll_iter) + except StopIteration: + return 200, {"status": "pending"} + return 200, resp + + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + with patch("bot_bottle.cli.login._post", side_effect=_fake_post): + with patch("bot_bottle.cli.login._get", side_effect=_fake_get): + with patch("bot_bottle.cli.login._POLL_SLEEP", 0): + return cmd_login(["--console-url", "http://console"]) + + def test_approved_flow_returns_0(self) -> None: + approved = { + "status": "approved", + "host_id": "hid", + "access_token": "at", + "refresh_token": "rt", + } + with tempfile.TemporaryDirectory() as tmp: + result = self._run_with_mocks( + [{"status": "pending"}, approved], tmp + ) + self.assertEqual(result, 0) + with open(os.path.join(tmp, "console.json")) as f: + creds = json.loads(f.read()) + self.assertEqual(creds["host_id"], "hid") + + def test_denied_flow_returns_1(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = self._run_with_mocks([{"status": "denied"}], tmp) + self.assertEqual(result, 1) + + def test_timeout_returns_1(self) -> None: + from bot_bottle.cli.login import cmd_login + + start_resp = { + "device_code": "dc", + "user_code": "ZZZ-ZZZ", + "expires_in": 0, # already expired + "poll_interval": 0, + } + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + with patch("bot_bottle.cli.login._post", return_value=start_resp): + with patch("bot_bottle.cli.login._POLL_SLEEP", 0): + result = cmd_login(["--console-url", "http://console"]) + self.assertEqual(result, 1) + + +class TestDispatcherRegistration(unittest.TestCase): + def test_login_in_commands(self) -> None: + from bot_bottle.cli import COMMANDS + self.assertIn("login", COMMANDS) + + def test_login_in_no_migration(self) -> None: + from bot_bottle.cli import NO_MIGRATION_COMMANDS + self.assertIn("login", NO_MIGRATION_COMMANDS) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 9b6788ebada4cecad421b6b5ef4dbd22a6689a2b Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 21 Jul 2026 03:57:07 +0000 Subject: [PATCH 2/6] fix(login): atomic credential write and respect server poll_interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write credentials via a 0600 temp file + os.replace() so the token file never appears at its final path with world-readable permissions, even if the process is interrupted between write and chmod. Parse poll_interval from the authorization response (clamped to 1–60 s, falling back to _POLL_SLEEP) so aggressive polling can't trigger console rate limits. Tests: add atomicity spy asserting the temp file is 0600 before replace; patch time.sleep instead of _POLL_SLEEP; add explicit interval-passthrough assertion. --- bot_bottle/cli/login.py | 20 ++++++++++-- tests/unit/test_cli_login.py | 60 +++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/login.py index 6410266..530a69f 100644 --- a/bot_bottle/cli/login.py +++ b/bot_bottle/cli/login.py @@ -18,6 +18,7 @@ import json import os import socket import sys +import tempfile import time import urllib.error import urllib.request @@ -71,7 +72,7 @@ def _save_credentials( ) -> Path: path = bot_bottle_root() / "console.json" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( + content = ( json.dumps( { "url": console_url, @@ -83,7 +84,19 @@ def _save_credentials( ) + "\n" ) - path.chmod(0o600) + fd, tmp_path_str = tempfile.mkstemp(dir=path.parent, prefix=".console-") + tmp = Path(tmp_path_str) + try: + tmp.chmod(0o600) + with os.fdopen(fd, "w") as f: + f.write(content) + os.replace(tmp, path) + except Exception: + try: + tmp.unlink() + except OSError: + pass + raise return path @@ -111,6 +124,7 @@ def cmd_login(argv: list[str]) -> int: device_code = resp["device_code"] user_code = resp["user_code"] expires_in = resp.get("expires_in", 300) + poll_sleep = max(1, min(int(resp.get("poll_interval", _POLL_SLEEP)), 60)) sys.stderr.write( f"\nOpen this URL in your browser to authorize this host:\n\n" @@ -122,7 +136,7 @@ def cmd_login(argv: list[str]) -> int: while time.monotonic() < deadline: sys.stderr.write(".") sys.stderr.flush() - time.sleep(_POLL_SLEEP) + time.sleep(poll_sleep) try: code, result = _get( diff --git a/tests/unit/test_cli_login.py b/tests/unit/test_cli_login.py index 0cb2c9b..7445591 100644 --- a/tests/unit/test_cli_login.py +++ b/tests/unit/test_cli_login.py @@ -42,6 +42,26 @@ class TestSaveCredentials(unittest.TestCase): self.assertEqual(data["refresh_token"], "rt") self.assertEqual(oct(path.stat().st_mode & 0o777), oct(0o600)) + def test_temp_file_is_private_before_replace(self) -> None: + """Temp file must be 0600 at the moment os.replace is called.""" + from bot_bottle.cli.login import _save_credentials + from pathlib import Path as _Path + + tmp_perms_at_replace: list[int] = [] + real_replace = os.replace + + def _spy_replace(src: "str | os.PathLike", dst: "str | os.PathLike") -> None: + tmp_perms_at_replace.append(_Path(src).stat().st_mode & 0o777) + real_replace(src, dst) + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + with patch("os.replace", side_effect=_spy_replace): + path = _save_credentials("http://c", "hid", "at", "rt") + self.assertEqual(len(tmp_perms_at_replace), 1) + self.assertEqual(oct(tmp_perms_at_replace[0]), oct(0o600)) + self.assertEqual(oct(path.stat().st_mode & 0o777), oct(0o600)) + class TestCmdLoginMissingUrl(unittest.TestCase): def test_returns_1_without_url(self) -> None: @@ -91,7 +111,7 @@ class TestCmdLoginFlow(unittest.TestCase): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch("bot_bottle.cli.login._post", side_effect=_fake_post): with patch("bot_bottle.cli.login._get", side_effect=_fake_get): - with patch("bot_bottle.cli.login._POLL_SLEEP", 0): + with patch("time.sleep"): return cmd_login(["--console-url", "http://console"]) def test_approved_flow_returns_0(self) -> None: @@ -121,17 +141,47 @@ class TestCmdLoginFlow(unittest.TestCase): start_resp = { "device_code": "dc", "user_code": "ZZZ-ZZZ", - "expires_in": 0, # already expired - "poll_interval": 0, + "expires_in": 0, # already expired; loop never runs + "poll_interval": 2, } with tempfile.TemporaryDirectory() as tmp: with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch("bot_bottle.cli.login._post", return_value=start_resp): - with patch("bot_bottle.cli.login._POLL_SLEEP", 0): - result = cmd_login(["--console-url", "http://console"]) + result = cmd_login(["--console-url", "http://console"]) self.assertEqual(result, 1) + def test_poll_interval_from_server_is_used(self) -> None: + """time.sleep must be called with the server-provided poll_interval.""" + from bot_bottle.cli.login import cmd_login + + server_interval = 7 + start_resp = { + "device_code": "dc", + "user_code": "ABC-DEF", + "expires_in": 300, + "poll_interval": server_interval, + } + approved = { + "status": "approved", + "host_id": "hid", + "access_token": "at", + "refresh_token": "rt", + } + poll_iter = iter([{"status": "pending"}, approved]) + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + with patch("bot_bottle.cli.login._post", return_value=start_resp): + with patch("bot_bottle.cli.login._get", side_effect=lambda u: (200, next(poll_iter))): + with patch("time.sleep") as mock_sleep: + result = cmd_login(["--console-url", "http://console"]) + + self.assertEqual(result, 0) + self.assertTrue(mock_sleep.called) + for call in mock_sleep.call_args_list: + self.assertEqual(call.args[0], server_interval) + class TestDispatcherRegistration(unittest.TestCase): def test_login_in_commands(self) -> None: -- 2.52.0 From 4cc36357941fb9d47f82ea1d5b669da985828647 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Jul 2026 04:18:29 +0000 Subject: [PATCH 3/6] test(login): satisfy type and coverage gates --- bot_bottle/cli/login.py | 5 ++- tests/unit/test_cli_login.py | 79 ++++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/login.py index 530a69f..3a1f417 100644 --- a/bot_bottle/cli/login.py +++ b/bot_bottle/cli/login.py @@ -23,6 +23,7 @@ import time import urllib.error import urllib.request from pathlib import Path +from typing import Any from ..paths import bot_bottle_root @@ -49,7 +50,7 @@ def _flag(argv: list[str], name: str) -> str | None: return None -def _post(url: str, payload: dict) -> dict: +def _post(url: str, payload: dict[str, Any]) -> dict[str, Any]: data = json.dumps(payload).encode() req = urllib.request.Request( url, data=data, headers={"Content-Type": "application/json"} @@ -58,7 +59,7 @@ def _post(url: str, payload: dict) -> dict: return json.loads(resp.read()) -def _get(url: str) -> tuple[int, dict]: +def _get(url: str) -> tuple[int, dict[str, Any]]: req = urllib.request.Request(url) try: with urllib.request.urlopen(req, timeout=10) as resp: diff --git a/tests/unit/test_cli_login.py b/tests/unit/test_cli_login.py index 7445591..137a2f2 100644 --- a/tests/unit/test_cli_login.py +++ b/tests/unit/test_cli_login.py @@ -6,6 +6,9 @@ import json import os import tempfile import unittest +import urllib.error +from email.message import Message +from typing import Any from unittest.mock import MagicMock, patch @@ -16,7 +19,9 @@ class TestFlagParsing(unittest.TestCase): def test_console_url_equals_form(self) -> None: from bot_bottle.cli.login import _flag - self.assertEqual(_flag(["--console-url=http://x"], "--console-url"), "http://x") + self.assertEqual( + _flag(["--console-url=http://x"], "--console-url"), "http://x" + ) def test_label_flag(self) -> None: from bot_bottle.cli.login import _flag @@ -27,6 +32,42 @@ class TestFlagParsing(unittest.TestCase): self.assertIsNone(_flag([], "--console-url")) +class TestHttpHelpers(unittest.TestCase): + def test_post_sends_json_and_decodes_response(self) -> None: + from bot_bottle.cli.login import _post + + response = MagicMock() + response.__enter__.return_value.read.return_value = b'{"ok": true}' + with patch("urllib.request.urlopen", return_value=response) as urlopen: + self.assertEqual( + _post("http://console/start", {"label": "host"}), {"ok": True} + ) + + request = urlopen.call_args.args[0] + self.assertEqual(request.data, b'{"label": "host"}') + self.assertEqual(request.get_header("Content-type"), "application/json") + + def test_get_decodes_success_response(self) -> None: + from bot_bottle.cli.login import _get + + response = MagicMock() + response.__enter__.return_value.status = 200 + response.__enter__.return_value.read.return_value = b'{"status": "pending"}' + with patch("urllib.request.urlopen", return_value=response): + self.assertEqual( + _get("http://console/status"), (200, {"status": "pending"}) + ) + + def test_get_returns_http_error_status(self) -> None: + from bot_bottle.cli.login import _get + + error = urllib.error.HTTPError( + "http://console/status", 410, "gone", Message(), None + ) + with patch("urllib.request.urlopen", side_effect=error): + self.assertEqual(_get("http://console/status"), (410, {})) + + class TestSaveCredentials(unittest.TestCase): def test_writes_json_and_sets_perms(self) -> None: from bot_bottle.cli.login import _save_credentials @@ -50,7 +91,9 @@ class TestSaveCredentials(unittest.TestCase): tmp_perms_at_replace: list[int] = [] real_replace = os.replace - def _spy_replace(src: "str | os.PathLike", dst: "str | os.PathLike") -> None: + def _spy_replace( + src: str | os.PathLike[str], dst: str | os.PathLike[str] + ) -> None: tmp_perms_at_replace.append(_Path(src).stat().st_mode & 0o777) real_replace(src, dst) @@ -64,6 +107,11 @@ class TestSaveCredentials(unittest.TestCase): class TestCmdLoginMissingUrl(unittest.TestCase): + def test_help_returns_0(self) -> None: + from bot_bottle.cli.login import cmd_login + + self.assertEqual(cmd_login(["--help"]), 0) + def test_returns_1_without_url(self) -> None: from bot_bottle.cli.login import cmd_login with patch.dict(os.environ, {}, clear=True): @@ -75,18 +123,26 @@ class TestCmdLoginMissingUrl(unittest.TestCase): """Exits 1 (network error) not because of missing URL when env var is set.""" from bot_bottle.cli.login import cmd_login - def _fail_post(url, payload): + def _fail_post(_url: str, _payload: dict[str, Any]) -> dict[str, Any]: raise OSError("connection refused") with tempfile.TemporaryDirectory() as tmp: - with patch.dict(os.environ, {"BB_CONSOLE_URL": "http://localhost:9999", "BOT_BOTTLE_ROOT": tmp}): + with patch.dict( + os.environ, + { + "BB_CONSOLE_URL": "http://localhost:9999", + "BOT_BOTTLE_ROOT": tmp, + }, + ): with patch("bot_bottle.cli.login._post", side_effect=_fail_post): result = cmd_login([]) self.assertEqual(result, 1) class TestCmdLoginFlow(unittest.TestCase): - def _run_with_mocks(self, poll_responses: list[dict], tmp: str) -> int: + def _run_with_mocks( + self, poll_responses: list[dict[str, Any]], tmp: str + ) -> int: from bot_bottle.cli.login import cmd_login start_resp = { @@ -98,10 +154,12 @@ class TestCmdLoginFlow(unittest.TestCase): poll_iter = iter(poll_responses) - def _fake_post(url, payload): + def _fake_post( + _url: str, _payload: dict[str, Any] + ) -> dict[str, Any]: return start_resp - def _fake_get(url): + def _fake_get(_url: str) -> tuple[int, dict[str, Any]]: try: resp = next(poll_iter) except StopIteration: @@ -170,10 +228,15 @@ class TestCmdLoginFlow(unittest.TestCase): } poll_iter = iter([{"status": "pending"}, approved]) + def _fake_get(_url: str) -> tuple[int, dict[str, str]]: + return 200, next(poll_iter) + with tempfile.TemporaryDirectory() as tmp: with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch("bot_bottle.cli.login._post", return_value=start_resp): - with patch("bot_bottle.cli.login._get", side_effect=lambda u: (200, next(poll_iter))): + with patch( + "bot_bottle.cli.login._get", side_effect=_fake_get + ): with patch("time.sleep") as mock_sleep: result = cmd_login(["--console-url", "http://console"]) -- 2.52.0 From 80bce59a7b52fad9a025639efbe5b966178a81a9 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 21 Jul 2026 04:27:30 +0000 Subject: [PATCH 4/6] fix(login): narrow exception types and cover cleanup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces broad `except Exception` with specific types that reflect the actual failure modes: - _save_credentials: `except OSError` (IO-only path; re-raises for cleanup) - _post error handler: `except (OSError, ValueError)` (network + bad JSON) - poll-loop: `except (OSError, ValueError)` (network + bad JSON) Also: - Simplify _fake_get to use next(..., default) — removes uncovered StopIteration branch - Add encoding="utf-8" to open() in test_approved_flow_returns_0 - Add test_cleanup_on_write_failure to cover the except OSError block in _save_credentials All files score 10.00/10 on pylint (fail-under=10) and 0 errors on pyright strict. --- bot_bottle/cli/login.py | 6 +++--- tests/unit/test_cli_login.py | 20 ++++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/login.py index 3a1f417..cd00b61 100644 --- a/bot_bottle/cli/login.py +++ b/bot_bottle/cli/login.py @@ -92,7 +92,7 @@ def _save_credentials( with os.fdopen(fd, "w") as f: f.write(content) os.replace(tmp, path) - except Exception: + except OSError: try: tmp.unlink() except OSError: @@ -118,7 +118,7 @@ def cmd_login(argv: list[str]) -> int: try: resp = _post(f"{console_url}/api/v1/hosts/authorize", {"label": label}) - except Exception as exc: + except (OSError, ValueError) as exc: sys.stderr.write(f"bb login: failed to start authorization: {exc}\n") return 1 @@ -143,7 +143,7 @@ def cmd_login(argv: list[str]) -> int: code, result = _get( f"{console_url}/api/v1/hosts/authorize/{device_code}" ) - except Exception: + except (OSError, ValueError): continue if code == 410: diff --git a/tests/unit/test_cli_login.py b/tests/unit/test_cli_login.py index 137a2f2..0d4c13c 100644 --- a/tests/unit/test_cli_login.py +++ b/tests/unit/test_cli_login.py @@ -105,6 +105,18 @@ class TestSaveCredentials(unittest.TestCase): self.assertEqual(oct(tmp_perms_at_replace[0]), oct(0o600)) self.assertEqual(oct(path.stat().st_mode & 0o777), oct(0o600)) + def test_cleanup_on_write_failure(self) -> None: + """Temp file is removed and no credentials remain if replace fails.""" + from bot_bottle.cli.login import _save_credentials + + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): + with patch("os.replace", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + _save_credentials("http://c", "hid", "at", "rt") + leftovers = [f for f in os.listdir(tmp) if f.startswith(".console-")] + self.assertEqual(leftovers, []) + class TestCmdLoginMissingUrl(unittest.TestCase): def test_help_returns_0(self) -> None: @@ -160,11 +172,7 @@ class TestCmdLoginFlow(unittest.TestCase): return start_resp def _fake_get(_url: str) -> tuple[int, dict[str, Any]]: - try: - resp = next(poll_iter) - except StopIteration: - return 200, {"status": "pending"} - return 200, resp + return 200, next(poll_iter, {"status": "pending"}) with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch("bot_bottle.cli.login._post", side_effect=_fake_post): @@ -184,7 +192,7 @@ class TestCmdLoginFlow(unittest.TestCase): [{"status": "pending"}, approved], tmp ) self.assertEqual(result, 0) - with open(os.path.join(tmp, "console.json")) as f: + with open(os.path.join(tmp, "console.json"), encoding="utf-8") as f: creds = json.loads(f.read()) self.assertEqual(creds["host_id"], "hid") -- 2.52.0 From b55b353f0f80d3a1411f0ddb95ceab4a43d5750c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Jul 2026 13:25:33 -0400 Subject: [PATCH 5/6] feat(cli): make bot_bottle.cli runnable with python -m MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `python -m bot_bottle.cli` failed with "is a package and cannot be directly executed" — the package had a `if __name__ == "__main__"` block in `__init__.py`, which never fires for a package and made the invocation look supported when it wasn't. Add a real `__main__.py` and drop the dead block. Matters for `bb login`, whose docstring documents a `bb` entry point that nothing installs; `-m` is the closest thing to it until there's a console-script. Co-Authored-By: Claude Opus 4.8 --- bot_bottle/cli/__init__.py | 4 --- bot_bottle/cli/__main__.py | 15 +++++++++++ tests/unit/test_cli_module_entry.py | 40 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 bot_bottle/cli/__main__.py create mode 100644 tests/unit/test_cli_module_entry.py diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index 0061724..cf62f68 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -114,7 +114,3 @@ def main(argv: list[str] | None = None) -> int: return e.code if isinstance(e.code, int) else 1 except KeyboardInterrupt: return 130 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/bot_bottle/cli/__main__.py b/bot_bottle/cli/__main__.py new file mode 100644 index 0000000..3cf5f19 --- /dev/null +++ b/bot_bottle/cli/__main__.py @@ -0,0 +1,15 @@ +"""Entry point for `python -m bot_bottle.cli`. + +`cli.py` at the repo root is the usual way in; this makes the package +runnable too, so the CLI works from an installed copy where there is no +`cli.py` on disk to point at. +""" + +from __future__ import annotations + +import sys + +from . import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_cli_module_entry.py b/tests/unit/test_cli_module_entry.py new file mode 100644 index 0000000..2ff003b --- /dev/null +++ b/tests/unit/test_cli_module_entry.py @@ -0,0 +1,40 @@ +"""The CLI package is runnable as `python -m bot_bottle.cli`.""" + +from __future__ import annotations + +import subprocess +import sys +import unittest +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "bot_bottle.cli", *args], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +class TestModuleEntry(unittest.TestCase): + def test_help_exits_zero(self) -> None: + result = _run("--help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("login", result.stderr) + + def test_no_args_prints_usage(self) -> None: + # main() returns 2 with no command, matching the cli.py entry point. + self.assertEqual(_run().returncode, 2) + + def test_subcommand_help_reaches_handler(self) -> None: + result = _run("login", "--help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--console-url", result.stderr) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 69aacac68b24f2158467c2a3234ae4eb48db73d1 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Jul 2026 13:45:26 -0400 Subject: [PATCH 6/6] feat(login): point the printed URL at the hosts-page modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approval now renders over the hosts page at /hosts/authorize?code=… rather than on a standalone page. The console keeps /authorize as a redirect, so this is cosmetic for older consoles. Co-Authored-By: Claude Opus 4.8 --- bot_bottle/cli/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/login.py index cd00b61..3d00f17 100644 --- a/bot_bottle/cli/login.py +++ b/bot_bottle/cli/login.py @@ -129,7 +129,7 @@ def cmd_login(argv: list[str]) -> int: sys.stderr.write( f"\nOpen this URL in your browser to authorize this host:\n\n" - f" {console_url}/authorize?code={user_code}\n\n" + f" {console_url}/hosts/authorize?code={user_code}\n\n" f"Waiting for approval" ) -- 2.52.0