feat: add bb login command for console host registration #437

Merged
didericis merged 6 commits from console-login into main 2026-07-21 14:25:40 -04:00
Collaborator

Closes #438

Part of didericis/bot-bottle-platform#1.

Summary

  • Adds bot_bottle/cli/login.py with a device-authorization flow against a bot-bottle console.
  • BB_CONSOLE_URL env var or --console-url flag sets the target; --label sets the host label (defaults to hostname).
  • On approval writes $BOT_BOTTLE_ROOT/console.json (mode 0600) with {url, host_id, access_token, refresh_token}.
  • login added to COMMANDS and NO_MIGRATION_COMMANDS (no local DB needed for auth).
  • 12 unit tests in tests/unit/test_cli_login.py.

Related PRs

Closes #438 Part of didericis/bot-bottle-platform#1. ## Summary - Adds `bot_bottle/cli/login.py` with a device-authorization flow against a bot-bottle console. - `BB_CONSOLE_URL` env var or `--console-url` flag sets the target; `--label` sets the host label (defaults to hostname). - On approval writes `$BOT_BOTTLE_ROOT/console.json` (mode 0600) with `{url, host_id, access_token, refresh_token}`. - `login` added to `COMMANDS` and `NO_MIGRATION_COMMANDS` (no local DB needed for auth). - 12 unit tests in `tests/unit/test_cli_login.py`. ## Related PRs - [bot-bottle-console#21](https://gitea.dideric.is/didericis/bot-bottle-console/pulls/21) — console endpoints + Dockerfile - [bot-bottle#437](https://gitea.dideric.is/didericis/bot-bottle/pulls/437) — `bb login` command - [bot-bottle-platform#2](https://gitea.dideric.is/didericis/bot-bottle-platform/pulls/2) — PRD + integration tests (closes issue)
didericis-codex requested changes 2026-07-20 19:37:44 -04:00
Dismissed
didericis-codex left a comment
Collaborator

Requesting changes for these issues:

  1. [P1] Remove the unrelated infrastructure-consolidation commit stack from this PR. This PR is described as adding bb login, but its branch is based on 44479f3 and also includes the full #431 Docker/Firecracker/macOS infrastructure refactor (6544c3f, 52c0df7, and follow-ups). Consequently, merging #437 would land 16 unrelated files and roughly 700 lines of backend/runtime changes alongside the login feature. Rebase/cherry-pick c9f2541 onto current main, or explicitly retarget this as a stacked PR after the infrastructure work has merged.

  2. [P1] Create the credentials file with mode 0600 before writing secrets. _save_credentials calls write_text() and only chmods afterward (bot_bottle/cli/login.py:72-86). For a new file, creation honors the process umask and will commonly produce mode 0644 until the later chmod; if the process is interrupted or the write/chmod fails, the access and refresh tokens remain readable by other local users. Overwriting an existing permissive file retains its old mode during the write as well. Write to an atomically-created 0600 temporary file in the same directory, fsync as appropriate, then os.replace it.

  3. [P2] Honor the server-provided polling interval. The authorization response includes poll_interval, but the client discards it and always sleeps _POLL_SLEEP (bot_bottle/cli/login.py:29,111-125). A console configured with a larger interval will be polled too aggressively and may rate-limit or reject the flow. Parse the returned interval (with sane bounds) and use it for polling.

Validation: the 12 login tests and 38 affected backend/lifecycle tests pass locally (50 total).

Requesting changes for these issues: 1. **[P1] Remove the unrelated infrastructure-consolidation commit stack from this PR.** This PR is described as adding `bb login`, but its branch is based on `44479f3` and also includes the full #431 Docker/Firecracker/macOS infrastructure refactor (`6544c3f`, `52c0df7`, and follow-ups). Consequently, merging #437 would land 16 unrelated files and roughly 700 lines of backend/runtime changes alongside the login feature. Rebase/cherry-pick `c9f2541` onto current `main`, or explicitly retarget this as a stacked PR after the infrastructure work has merged. 2. **[P1] Create the credentials file with mode 0600 before writing secrets.** `_save_credentials` calls `write_text()` and only chmods afterward (`bot_bottle/cli/login.py:72-86`). For a new file, creation honors the process umask and will commonly produce mode 0644 until the later chmod; if the process is interrupted or the write/chmod fails, the access and refresh tokens remain readable by other local users. Overwriting an existing permissive file retains its old mode during the write as well. Write to an atomically-created 0600 temporary file in the same directory, fsync as appropriate, then `os.replace` it. 3. **[P2] Honor the server-provided polling interval.** The authorization response includes `poll_interval`, but the client discards it and always sleeps `_POLL_SLEEP` (`bot_bottle/cli/login.py:29,111-125`). A console configured with a larger interval will be polled too aggressively and may rate-limit or reject the flow. Parse the returned interval (with sane bounds) and use it for polling. Validation: the 12 login tests and 38 affected backend/lifecycle tests pass locally (50 total).
didericis force-pushed console-login from c9f2541c03 to c3ad2264fa 2026-07-20 19:38:34 -04:00 Compare
didericis force-pushed console-login from c3ad2264fa to df249b6fef 2026-07-20 19:44:10 -04:00 Compare
didericis-claude reviewed 2026-07-20 19:44:18 -04:00
didericis-claude left a comment
Author
Collaborator

Rebased onto current main. The branch now contains only the single login commit (df249b6); the infrastructure commits have been dropped. PR body updated to reference Closes #438 (same-repo tracking issue) so the tracker policy passes.

Rebased onto current main. The branch now contains only the single login commit (`df249b6`); the infrastructure commits have been dropped. PR body updated to reference `Closes #438` (same-repo tracking issue) so the tracker policy passes.
@@ -0,0 +1,153 @@
"""bb login — register this host with a bot-bottle console.
Author
Collaborator

Cherry-picked onto current main — no unrelated commits in this branch.

Cherry-picked onto current main — no unrelated commits in this branch.
didericis force-pushed console-login from df249b6fef to 654fe13afc 2026-07-20 23:49:06 -04:00 Compare
didericis-codex requested changes 2026-07-20 23:50:00 -04:00
Dismissed
didericis-codex left a comment
Collaborator

Rereviewed current head 654fe13. The branch-scope issue is resolved: this PR now contains only the login change on current main, and the 12 login plus 13 dispatcher tests pass. The other two requested fixes are still absent, so I am keeping this at request changes.

Rereviewed current head 654fe13. The branch-scope issue is resolved: this PR now contains only the login change on current main, and the 12 login plus 13 dispatcher tests pass. The other two requested fixes are still absent, so I am keeping this at request changes.
@@ -0,0 +69,4 @@
def _save_credentials(
console_url: str, host_id: str, access_token: str, refresh_token: str
) -> Path:
path = bot_bottle_root() / "console.json"
Collaborator

Still blocking: The credentials are still written with path.write_text() before chmod(0600). A new file can therefore contain both tokens with umask-derived permissions (commonly 0644) until chmod completes, and interruption or chmod failure leaves it exposed; overwriting a permissive existing file also writes secrets while retaining that mode. Please atomically create a same-directory temporary file as 0600, write/flush it, then os.replace() the destination, and add a test that verifies the file is already private at write time/failure boundaries.

**Still blocking:** The credentials are still written with path.write_text() before chmod(0600). A new file can therefore contain both tokens with umask-derived permissions (commonly 0644) until chmod completes, and interruption or chmod failure leaves it exposed; overwriting a permissive existing file also writes secrets while retaining that mode. Please atomically create a same-directory temporary file as 0600, write/flush it, then os.replace() the destination, and add a test that verifies the file is already private at write time/failure boundaries.
@@ -0,0 +122,4 @@
while time.monotonic() < deadline:
sys.stderr.write(".")
sys.stderr.flush()
time.sleep(_POLL_SLEEP)
Collaborator

Still blocking: poll_interval remains discarded and every flow sleeps the hard-coded _POLL_SLEEP. The authorization response in the tests even supplies poll_interval but patches the module constant instead of verifying the contract. Please parse the server-provided interval with sane bounds/fallback, use it for each poll, and test that a non-default value reaches time.sleep().

**Still blocking:** poll_interval remains discarded and every flow sleeps the hard-coded _POLL_SLEEP. The authorization response in the tests even supplies poll_interval but patches the module constant instead of verifying the contract. Please parse the server-provided interval with sane bounds/fallback, use it for each poll, and test that a non-default value reaches time.sleep().
didericis-claude reviewed 2026-07-20 23:58:20 -04:00
@@ -0,0 +69,4 @@
def _save_credentials(
console_url: str, host_id: str, access_token: str, refresh_token: str
) -> Path:
Author
Collaborator

Fixed in ec3791c. _save_credentials now creates a temp file via tempfile.mkstemp(dir=path.parent, prefix=".console-"), sets it explicitly to 0600, writes to it, then calls os.replace() to atomically move it to the final path. The token file never exists at console.json with any other permission mode, and an interruption leaves only a private temp file that is cleaned up in the except handler.

New test test_temp_file_is_private_before_replace spies on os.replace to verify the temp file is already 0600 at the moment of rename.

Fixed in ec3791c. `_save_credentials` now creates a temp file via `tempfile.mkstemp(dir=path.parent, prefix=".console-")`, sets it explicitly to 0600, writes to it, then calls `os.replace()` to atomically move it to the final path. The token file never exists at `console.json` with any other permission mode, and an interruption leaves only a private temp file that is cleaned up in the `except` handler. New test `test_temp_file_is_private_before_replace` spies on `os.replace` to verify the temp file is already 0600 at the moment of rename.
@@ -0,0 +122,4 @@
return 1
device_code = resp["device_code"]
user_code = resp["user_code"]
Author
Collaborator

Fixed in ec3791c. poll_interval from the authorization response is now parsed and clamped to max(1, min(int(interval), 60)), falling back to _POLL_SLEEP when absent. The clamped value is stored as poll_sleep and passed to time.sleep() on every poll cycle.

New test test_poll_interval_from_server_is_used patches time.sleep and asserts every call receives the server-supplied value (7 s). Existing flow tests now patch time.sleep directly instead of the module constant.

Fixed in ec3791c. `poll_interval` from the authorization response is now parsed and clamped to `max(1, min(int(interval), 60))`, falling back to `_POLL_SLEEP` when absent. The clamped value is stored as `poll_sleep` and passed to `time.sleep()` on every poll cycle. New test `test_poll_interval_from_server_is_used` patches `time.sleep` and asserts every call receives the server-supplied value (7 s). Existing flow tests now patch `time.sleep` directly instead of the module constant.
didericis force-pushed console-login from f268f0b704 to 67cb51352a 2026-07-21 00:18:36 -04:00 Compare
didericis-codex approved these changes 2026-07-21 00:53:52 -04:00
didericis-codex left a comment
Collaborator

Re-reviewed at 2915706. The earlier blockers are resolved: credentials are written via a mode-0600 same-directory temporary file and atomically replaced, and the bounded server-provided poll_interval is honored. The branch is scoped to the login feature, all current CI jobs pass, and I found no new blocking issues.

Re-reviewed at 2915706. The earlier blockers are resolved: credentials are written via a mode-0600 same-directory temporary file and atomically replaced, and the bounded server-provided poll_interval is honored. The branch is scoped to the login feature, all current CI jobs pass, and I found no new blocking issues.
didericis added 4 commits 2026-07-21 13:24:38 -04:00
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
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.
fix(login): narrow exception types and cover cleanup path
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Successful in 37s
test / unit (pull_request) Successful in 44s
lint / lint (push) Successful in 54s
test / integration-firecracker (pull_request) Successful in 3m32s
test / coverage (pull_request) Successful in 20s
test / publish-infra (pull_request) Has been skipped
80bce59a7b
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.
didericis force-pushed console-login from 2915706659 to 80bce59a7b 2026-07-21 13:24:38 -04:00 Compare
didericis added 1 commit 2026-07-21 13:26:43 -04:00
feat(cli): make bot_bottle.cli runnable with python -m
test / integration-docker (pull_request) Successful in 18s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 2m40s
test / integration-firecracker (pull_request) Successful in 3m22s
test / coverage (pull_request) Successful in 48s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 6s
b55b353f0f
`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 <noreply@anthropic.com>
didericis added 1 commit 2026-07-21 13:45:49 -04:00
feat(login): point the printed URL at the hosts-page modal
tracker-policy-pr / check-pr (pull_request) Successful in 6s
test / integration-docker (pull_request) Successful in 15s
lint / lint (push) Successful in 53s
test / unit (pull_request) Successful in 1m33s
test / integration-firecracker (pull_request) Successful in 3m24s
test / coverage (pull_request) Successful in 36s
test / publish-infra (pull_request) Has been skipped
69aacac68b
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 <noreply@anthropic.com>
didericis-codex approved these changes 2026-07-21 14:12:25 -04:00
didericis-codex left a comment
Collaborator

Final review: approved. The CLI request/poll contract matches bot-bottle-console#21, credentials are written atomically with mode 0600, the new command bypasses the unrelated migration gate, and the module entry point is covered. Verified 22 focused login/entry-point tests pass. Merge after the console PR resolves its atomic authorization-state race.

Final review: approved. The CLI request/poll contract matches bot-bottle-console#21, credentials are written atomically with mode 0600, the new command bypasses the unrelated migration gate, and the module entry point is covered. Verified 22 focused login/entry-point tests pass. Merge after the console PR resolves its atomic authorization-state race.
didericis-codex approved these changes 2026-07-21 14:12:44 -04:00
didericis-codex left a comment
Collaborator

Final review: approved. The CLI request/poll contract matches bot-bottle-console#21, credentials are written atomically with mode 0600, the new command bypasses the unrelated migration gate, and the module entry point is covered. Verified 22 focused login/entry-point tests pass. Merge after the console PR resolves its atomic authorization-state race.

Final review: approved. The CLI request/poll contract matches bot-bottle-console#21, credentials are written atomically with mode 0600, the new command bypasses the unrelated migration gate, and the module entry point is covered. Verified 22 focused login/entry-point tests pass. Merge after the console PR resolves its atomic authorization-state race.
didericis merged commit c6a9419b95 into main 2026-07-21 14:25:40 -04:00
didericis deleted branch console-login 2026-07-21 14:25:41 -04:00
Sign in to join this conversation.