Skip to content

feat(auth): opt-in unattended sign-in with a stored authenticator - #76

Merged
rabesss merged 12 commits into
mainfrom
feat/unattended-relogin
Oct 15, 2026
Merged

rabesss merged 12 commits into
mainfrom
feat/unattended-relogin

Conversation

@rabesss

@rabesss rabesss commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Follows up #52 (closed): what was actually needed was a login that persists, not mail access.

What

Opt-in unattended sign-in with a stored authenticator-app secret, so an agent keeps working past the ~5-day session limit with no password or MFA prompt.

  • auth totp-setup reads an authenticator secret (base32 or otpauth://totp/ URI) from a hidden prompt or stdin, never argv. It seals the secret in authenticator.json, bound to the saved username. auth totp-remove deletes it.
  • auth login uses the stored authenticator when there is no --totp, --mfa-method is unset or app, and the username matches. It runs the existing pure-HTTP flow on PhoneAppOTP: the code is generated after BeginAuth, submitted once, and never shown.
  • A replay-safe read (GET/HEAD, _replay_safe, not read_only_auth, built-in connection) whose session has expired (401, 401/403 from whoami, or a 403 that whoami confirms as signed out) or whose cookies are missing signs in once and retries once. Write requests, quiz-attempt flows and overridden connections never sign in. A write command's earlier reads (for example submit looking up the course) are ordinary reads and can, and the write is still sent once.
  • auth status checks GET /d2l/api/lp/1.47/users/whoami instead of /d2l/api/versions/, so it reports whether the session is really signed in. It adds renewed and unattended_login: {configured, blocked}.

Safety

  • One sign-in at a time. renewal_lock() is a thread lock plus flock on relogin.lock, with a 120 s deadline. Waiters reuse cookies another process saved. The human login tail, import-session, the CDP refresh save and the legacy cookie migration take the same lock only around their writes. Without fcntl (not POSIX), the lock covers only threads and stored-authenticator sign-in is off: totp-setup refuses, auth login keeps the person-driven flow, and reads behave as on main.
  • Replay. The time step is reserved (persisted) before the code is generated, and steps must strictly advance, also across totp-setup with the same key under any username. Setup refuses to replace an authenticator file it can't read. A clock going backwards gives clock_skew. Only totp-remove forgets used steps; the docs give remove plus setup as the recovery from a fast clock, once the clock is right.
  • Breaker. relogin_state.json is plaintext and holds no secrets. Auth-class failures (credentials_rejected, code_rejected, method_unavailable, clock_skew, interrupt, unknown) block automatic attempts until a successful stored login, totp-setup or totp-remove. network gives a 5-minute cooldown. Corrupt state fails closed. Automatic sign-in records itself as blocked (unknown) before contacting Microsoft, and the outcome replaces that, so an unwritable breaker or a process killed mid-sign-in never lets every read send the password again.
  • No secret output. Error strings are fixed, rendered ErrCodes are known codes only, repr=False is set on secret fields, and re-raised errors use from None. Sentinel tests check stdout, stderr, exception text, tracebacks and the config dir for the secret, the decoded key, codes, the password, cookies and the username.
  • Trade-off (documented in docs/auth-unattended.md and the totp-setup help). The password and the second factor share one sealed store. Anyone who can unseal it can sign in to the whole MAHE M365 account.

Deviations from the reviewed plan (v2.5)

  • _CREDENTIAL_ERROR_CODES also counts 50034, 50056, 50133 and 50144 as credentials_rejected, alongside the plan's 50126/50053/50055/50057. They are all password or account refusals.
  • The plan's RenewalUnavailable is named RenewalUnavailableError (ruff N818).
  • A successful stored login clears mfa_pending.json. A deferred two-step challenge would otherwise keep blocking automatic sign-in.
  • totp-setup reads the secret before taking the lock, so a person typing doesn't hold it. It reserves the current step, so the code used to confirm registration in Microsoft is never replayed. The first login after setup can therefore wait up to 30 s.
  • Automatic sign-in records itself as blocked (unknown) in relogin_state.json before the password is sent, and stops with store if it can't. Success clears it, a failure records its category, and a store failure restores the previous state (review rounds 1 and 2).
  • totp-setup with the same key keeps a newer recorded step. Round 1 let it drop a step more than two ahead of the clock; round 2 reverted that because it could resend an accepted code. Round 3 made the step follow the key whatever the username, and setup refuses to replace a file it can't read.
  • New fixed auth messages pass _safe_auth_error_message by exact match, not by prefix.
  • The login policy is a pure predicate (use_stored_authenticator), not a new LoginPlan mode.
  • display can't import ms_errors (layering), so it keeps a mirror of the category set. A test pins it equal to RELOGIN_CATEGORIES.
  • check_auth() keeps its contract: a 503 gives False, and NetworkError still propagates.
  • Plan addendum A (found on MAHE while this was in progress): D2L answers some reads of an expired session with 403, not 401, so courses said "Permission denied (HTTP 403)". On a 403 from a recoverable read, the client now asks whoami once, without recovery. Only a signed-out whoami (401, 403 or a login redirect) turns the 403 into an expiry, and the usual single sign-in and retry follow. Any other answer re-raises the original 403, so genuine denials such as closed-folder history keep it. The check is one attempt with a 15 s timeout and nothing is cached, so each genuine 403 costs one extra request. Without the stored authenticator, this expired-session 403 now tries the CDP refresh and reports "Session expired" like a 401.

Tests

  • New: test_totp.py (RFC 6238 vectors, parsing), test_session_renewal.py (lock, reservation, gates, breaker), test_session_renewal_multiprocess.py (two spawned processes: one sign-in plus one reuse; a failure blocks the second process), test_ms_auth_provider.py (provider mode, failure categories, 5xx), test_auth_totp.py.
  • Extended: test_api_transport.py, test_read_only_auth.py, test_cli_contracts.py, test_assessment_workflows.py, test_secret_absence.py, test_auth_login.py, test_credential_store.py, test_secrets_store.py.
  • Full gate: ruff check, mypy, lint-imports, deptry, xenon, check_secrets and pytest (2695 passed). The .secrets.baseline refresh only moves line numbers of audited entries; new fixture lines use inline pragma: allowlist secret.

Live tests (MAHE, read-only, with Ravish present): passed

Run on 2026-10-15 at head 4db986f. Outputs were checked by field names and counts only; no secrets or personal data were printed.

  • L1: totp-setup from stdin, then auth login --json signed in with no prompt (BeginAuth, EndAuth, ProcessAuth, KMSI, SAML). auth status gave valid: true and unattended_login: {configured: true, blocked: false}.
  • L2: L1 passed with a third-party authenticator app registered as a PhoneAppOTP proof.
  • L3: with cookies.json deleted, courses --json signed in once (one BeginAuth) and returned the course list.
  • L3b: with an expired (not deleted) session, courses --json signed in once and replaced the cookies.
  • L4: two concurrent auth status --json runs while signed out: one signed in (renewed: true, one BeginAuth), the other reused its cookies (renewed: false, no BeginAuth). Both reported valid: true. The second process still printed "Signing in again with the stored authenticator..." although it didn't sign in; fixed in 0ee2e16, so only the process that signs in prints it.
  • L5: every sign-in above passed the post-MFA KMSI interrupt. The clean interrupt failure can't be triggered on demand, so it is covered by unit tests only.

Found along the way: an expired MAHE password made a sign-in with a correct code fail with the generic "Authentication failed". #77 reports it as an expired password.

Not in this PR

  • auth refresh --cdp-port against Helium: its debug port has no /json/version, and Network.getAllCookies is not available on the browser target. Separate follow-up.
  • The quiz commands still fail on non-POSIX systems, as on main: quiz_learner_session.py and quiz_preview_session.py import fcntl at module level. Ordinary commands load them lazily, so only the quiz flows are affected. Separate follow-up.

Review

  • Plan v2.5: 5 review rounds (gpt-6.1-sol), all findings resolved before implementation.
  • Addendum A (403 handling): 1 round, "ship with changes", resolved (see the plan notes above).
  • PR review round 1, reviewed head 55e806b:
    • gpt-6.1-sol — xhigh: block (4 findings, fixed in b61af23)
    • Grok 4.7 — xhigh: block (the P1 duplicates Sol's breaker-write finding, fixed in b61af23; help text fixed in 0962d8b; the rest below)
    • GLM 5.3 — max: ship with follow-ups (doc fix in b61af23; the rest below)
    • Muse Spark 1.3 contributor — xhigh: ship with follow-ups (below)
    • DeepSeek V4.1 Flash — max: ship with follow-ups (one duplicate of Sol's, the rest below)
    • CodeRabbit: 1 finding, fixed in b61af23
    • Factory Droid (hosted): no issues
  • PR review round 2, reviewed head 783702e:
    • gpt-6.1-sol — xhigh: block (2 findings, fixed in 34bf5ca)
    • Grok 4.7 — xhigh, through Cursor because the Grok CLI's sandbox failed to start: block (the P1 duplicates Sol's replay finding; the clock_skew message, fixed in 34bf5ca)
    • GLM 5.3 — max: ship with follow-ups (the replay duplicate; the EndAuth wording, corrected below)
    • Muse Spark 1.3 contributor — xhigh: ship
    • DeepSeek V4.1 Flash — max: ship with follow-ups (the replay duplicate and an unrecordable failure, fixed in 34bf5ca; the cooldown docs; one trade-off below)
    • CodeRabbit: 2 findings, fixed in 34bf5ca and 56673b2
    • Factory Droid (hosted): 1 finding, the write-command docs, fixed in 56673b2
  • PR review round 3, reviewed head 56673b2:
    • gpt-6.1-sol — xhigh: block (1 finding: setup replaced an authenticator file it couldn't read, fixed in ba2abbd)
    • Grok 4.7 — xhigh, through Cursor because the Grok CLI's sandbox failed to start: ship with follow-ups (the unknown login message, fixed in 6256fe0)
    • GLM 5.3 — max: ship with follow-ups (the unknown message and the store docs row, fixed in 6256fe0)
    • Muse Spark 1.3 contributor — xhigh: ship with follow-ups (the unknown message, fixed in 6256fe0)
    • DeepSeek V4.1 Flash — max: ship with follow-ups (the unknown message, fixed in 6256fe0; setup kept the step only for the same username, fixed in ba2abbd)
    • CodeRabbit: no findings
    • Factory Droid (hosted): no issues
  • PR review round 4, reviewed head 6256fe0:
    • gpt-6.1-sol — xhigh: ship
    • Grok 4.7 — xhigh, through Cursor because the Grok CLI's sandbox failed to start: ship
    • GLM 5.3 — max: ship
    • Muse Spark 1.3 contributor — xhigh: ship with follow-ups (the setup message's clock advice and a manually typed app code; trade-offs below)
    • DeepSeek V4.1 Flash — max: ship with follow-ups (a clock corrected within the registration step; trade-off below)
    • CodeRabbit: no findings
    • Factory Droid (hosted): 2 findings: importing fcntl broke ordinary commands on non-POSIX systems (e097ba1), and formatting an error twice dropped the sign-in category (4db986f)
  • PR review round 5, reviewed head 4db986f:
    • gpt-6.1-sol — xhigh: ship
    • Grok 4.7 — xhigh, through Cursor because the Grok CLI's sandbox failed to start: ship
    • GLM 5.3 — max: ship
    • Muse Spark 1.3 contributor — xhigh: ship with follow-ups (the quiz modules' fcntl import, as on main; see "Not in this PR")
    • DeepSeek V4.1 Flash — max: ship
    • CodeRabbit: no findings
    • Factory Droid (hosted): no issues
  • PR review round 6, reviewed head 0ee2e16 (the L4 notice fix):
    • gpt-6.1-sol — xhigh: ship
    • Grok 4.7 — xhigh, through Cursor because the Grok CLI's sandbox failed to start: ship
    • GLM 5.3 — max: ship
    • Muse Spark 1.3 contributor — xhigh: ship
    • DeepSeek V4.1 Flash — max: ship
    • CodeRabbit: no findings
    • Factory Droid (hosted): no issues
  • Hosted CI on 0ee2e16: all required checks passed (lint / types / architecture / deps, tests on py3.10 and py3.13). Mergeable, no conflicts.
  • Fixes: 0ee2e16 (the sign-in notice only comes from the process that signs in, found by live test L4), e097ba1 (no fcntl needed outside unattended sign-in), 4db986f (sign-in category survives a second format), ba2abbd (setup never forgets used steps), 6256fe0 (unknown login message and store docs row), 34bf5ca (no step is ever resent; automatic sign-in blocked while it runs; recovery docs and message), 56673b2 (write-request scope in the docs; login help names the username match), 253a88e and 783702e (gitleaks fixture marker; ignore entry for the fixture in 55e806b), b61af23 (breaker fails closed; fast-clock recovery; docs), 0962d8b (auth login --help explains the stored authenticator).
  • Accepted trade-offs:
    • After a RenewalError (including store, busy, pending_human_login), the CDP browser refresh is not tried. A configured stored authenticator owns recovery, and the CDP save needs the same key source.
    • Each genuine 403 on a recoverable read costs one whoami request (addendum A).
    • If clearing the breaker fails after a successful sign-in, the command reports store and automatic sign-in stays blocked (unknown). The saved cookies keep working, and auth login clears the block.
    • An explicit auth login is not marked as in flight. A caller started it and sees an interruption, and the breaker never refuses it.
    • While an automatic sign-in runs, auth status reports blocked: true.
    • If saving credentials fails after a verified stored auth login --save-credentials, the command reports store; the session is saved and works. The human login path warns and succeeds instead.
    • A network failure recorded while the clock was fast keeps the cooldown until the corrected clock passes it. auth login works meanwhile, and the docs say so.
    • whoami must return a positive integer Identifier to count as signed in. Live tests L1–L3 check this on MAHE.
    • A Playwright failure after navigation (crash, missing $Config) is interrupt and blocks, as the plan specifies: it usually means Microsoft changed the page. Navigation outages are network.
    • unattended_login.configured only checks that both sealed files exist; it does not unseal them to compare usernames. After saving credentials for another account, it stays true, and expiry falls back to the browser refresh.
    • In stored mode, any EndAuth JSON object without Success: true is code_rejected; a reply that is not a JSON object is interrupt. Both block automatic sign-in.
    • The CLI only knows the steps it reserved. A code a person types (the one that confirms registration, or --totp with --mfa-method app) is not recorded. If a slow clock is corrected inside the registration step, or a stored login follows a manual one within the same 30 s, the CLI can send that code again. Microsoft then either accepts it or rejects it as code_rejected, which blocks. Setup step 6 says to wait for a new code first.
    • When totp-setup cannot read the stored authenticator, its message names the clock because totp-remove forgets used steps. The file may also be corrupt or sealed with another key; remove plus setup is the way out in every case, since totp-remove doesn't unseal.

MAHE's Entra sign-in frequency ends the session about every 5 days and
always asks for a second factor again. With an authenticator-app secret
stored by `auth totp-setup`, the CLI completes the PhoneAppOTP step itself,
so an agent keeps working without a person re-entering the password and MFA.

- `auth totp-setup` / `auth totp-remove`: seal or delete the secret
  (hidden prompt or stdin, never argv), bound to the saved username.
- `auth login` uses the stored authenticator when no `--totp` is given and
  `--mfa-method` is unset or `app`; the code is generated after BeginAuth,
  submitted once and never shown.
- Replay-safe reads on the built-in connection sign in once and retry once
  when the session is expired or the cookies are missing. An expired
  session can answer 403, so a 403 counts as expiry only when whoami is
  signed out too. Writes, quiz flows and read-only clients never sign in.
- `session_renewal`: cross-process `renewal_lock`, strictly advancing time
  steps, and the `relogin_state.json` breaker that stops repeated failed
  sign-ins.
- `auth status` checks whoami and reports `renewed` and `unattended_login`.

Docs: docs/auth-unattended.md (setup, failure categories, trade-off).
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@factory-droid

factory-droid Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


No high-confidence actionable issues were identified in the code review.

0 inline comments posted

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — full initial review of all 35 files (one commit), read end-to-end against the diff, with focused specialist passes on the MFA method semantics, the locking/breaker design, and P0 secret hygiene. All three came back clean.

  • totp.py + credential_store.py: RFC 6238 primitives (stdlib-only, RFC 6238 Appendix B vectors pinned) and a sealed authenticator.json holding the secret, its bound username, and the last used time step; strict load validation with fixed-text errors, repr=False on every secret-bearing field, symlink-protected paths.
  • session_renewal.py: renewal_lock (thread lock + flock, one 120 s monotonic deadline, non-re-entrant with a regression test), the strictly-advancing time-step reservation persisted before code generation, waiter reuse of another caller's saved cookies, and the plaintext no-secrets circuit breaker in relogin_state.json (corrupt state fails closed across a 13-case matrix, auth-class failures block, network gets a 5-minute cooldown).
  • ms_auth.py provider mode: code generated only after a successful BeginAuth, exactly one EndAuth submission (single_submission raises code_rejected on any non-success before the AuthenticationPreviouslyCompleted handler can read a foreign checkpoint), human checkpoint writes/reads fully disabled (byte-identical checkpoint survival pinned), banner suppressed, 5xx classified as network before page parsing.
  • api.py recovery: replay-safe GET/HEAD on the built-in connection recover once — stored authenticator first, CDP fallback second; missing cookies recover only via the stored authenticator; the 403-whoami disambiguation (expired session vs genuine denial) is single-attempt with a 15 s timeout and ~20 dedicated tests covering permission-vs-expired, double-sign-in prevention, closed folders, quiz checks, and read-only clients.
  • auth.py / cli.py / commands.py: totp-setup/totp-remove (secret via hidden prompt or stdin, never argv; GetPassWarning becomes an error before anything is echoed), the use_stored_authenticator gate (any --totp, --totp -, or explicit non-app method keeps the person-driven flow — pinned per method), the login tail now save→probe→store under the lock, and auth status on whoami with renewed/unattended_login JSON fields.
  • Docs and tests: new docs/auth-unattended.md (setup, category table, clock, trade-off, revocation), README/AGENTS/ADR/runbook updates; .secrets.baseline changes are line-number moves only; new provider/multiprocess (real spawn + flock contention)/sentinel suites plus rewritten mock seams in the extended suites. Deleted test lines are mock-seam rewrites paired with equivalent-or-stronger replacements, not dropped coverage.

Verification: ruff, mypy, lint-imports, and deptry all pass; pytest is 2655/2658 green — the 3 failures are pre-existing tests/test_quiz_media.py breakage that reproduces on main in this environment and is untouched by this PR. The PR's own L1–L5 live checks against MAHE are listed as pending in the description; everything verifiable locally checks out.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

CI's gitleaks job flagged the OTHER_SECRET base32 fixture in
tests/test_auth_totp.py as a generic API key. It is a test-only RFC 6238
style key, already allowlisted for detect-secrets; an inline
`gitleaks:allow` now covers gitleaks too.
GPT-6.1 Sol found four ways the breaker could let automatic sign-in run
again after a failure:

- A failure that could not be recorded (unwritable config directory) was
  repeated on every read. Automatic sign-in now writes the breaker state
  before Microsoft sees the password, and stops with `store` if it can't.
- A clock that went back ended the 5-minute `network` cooldown at once.
  A negative elapsed time now keeps it.
- A state file whose category was a non-string (a list or object) raised
  instead of failing closed.
- A `network` state missing its timestamps, or a clear state carrying
  them, was read as valid. Both now fail closed as `unknown`.

CodeRabbit found that after a fast clock reserved a future time step,
neither fix in the docs worked: `auth login` kept failing `clock_skew`,
and `totp-setup` with the same key kept the future step. Setup now keeps
a recorded step only up to two steps ahead of the clock, and the docs name
`totp-setup` as the recovery for `code_rejected` and `clock_skew`.

GLM 5.3 found that the docs said an unreadable state file is `unknown`;
only corrupt content is, and a read failure is `store`.
… the chooser

Grok 4.7 found that `auth login --help` still said a plain interactive
login shows Microsoft's verification methods. With a matching stored
authenticator, login uses it instead, with no chooser and no code prompt.
The help for the command and for --mfa-method now says so.
Comment thread lighthouse_cli/session_renewal.py Outdated
if type(doc["v"]) is not int or doc["v"] != _STATE_VERSION or type(blocked) is not bool:
return _CORRUPT_STATE
allowed = BLOCKING_CATEGORIES if blocked else {None, "network"}
if category not in allowed:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Corrupt state with a non-string category raises TypeError instead of failing closed

category comes from unvalidated JSON. If a corrupt or hand-edited relogin_state.json holds "category": [] or {}, the category not in allowed membership test raises TypeError: unhashable type. _load_state calls _state_from_document outside its try (which only guards JSON ValueError), and renew_session calls _load_state before its own try, so the error bypasses the breaker's fail-closed mapping. unattended_status() catches only RenewalError, so lighthouse auth status dies with a raw traceback - violating both the documented "corrupt state fails closed" contract and the no-raw-traceback convention. Guard with isinstance(category, str) before the membership test.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

error = _as_renewal_error(exc)
_record_failure(store, state, error.category)
raise error from None
if result.signed_in:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: A failed post-success clear_breaker() reports a completed sign-in as a failure

By this point _attempt has signed in, saved and validated the new cookies. If clear_breaker() raises RenewalError("store") (non-FileNotFoundError OSError on unlink, or a path-validation failure), callers convert the fully successful renewal into automatic sign-in failed: store / an auth error, while the breaker file is left in place. The same pattern exists in store_authenticator/remove_authenticator (breaker cleared after a successful save/delete). Consider making the clear best-effort (contextlib.suppress(OSError)) or reporting success with a cleanup warning.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread lighthouse_cli/api.py
the meantime are returned instead of being overwritten.
"""
try:
with renewal_lock():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: First cookie access of any command can block on the cross-process sign-in lock, and lock contention is reported as session expiry

_load_migrating_legacy_cookies takes renewal_lock() (120 s deadline) and runs on the first .cookies access of any command - including non-recoverable POSTs, since this happens before can_recover matters. While another process renews, ordinary commands can block for up to 2 minutes and then get SessionExpiredError("Session expired (cookie upgrade busy)"): a lock-contention failure surfaced as session expiry, a new failure class for POST paths that previously never raised it. Consider a shorter deadline for the migration read and a non-"expired" error for the busy case.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread lighthouse_cli/auth.py
json_output,
)
try:
with renewal_lock():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: The global sign-in lock is held across a network probe

renewal_lock() now spans probe_cookies(cookies) - a whoami GET with a 30 s timeout and up to 4 retryable attempts - plus the credential save. A slow D2L endpoint can pin the lock for minutes, so every concurrent sign-in path (auth login, totp-setup/totp-remove, automatic renewal, legacy-cookie migration, import-session) waits out the 120 s deadline and fails "busy". The atomicity goal (no automatic sign-in publishing cookies in between) is valid, but consider probing within the shorter _SIGNED_OUT_CHECK_TIMEOUT budget, or releasing the lock before the probe and re-checking only if cookies.json changed.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread lighthouse_cli/session_renewal.py Outdated
raise exc.error from None
except Exception as exc: # every failure maps to a category
error = _as_renewal_error(exc)
_record_failure(store, state, error.category)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: _record_failure failure can mask the original failure category

If _write_state fails (read-only dir, full disk), _record_failure raises RenewalError("store"), which replaces the original error before raise error runs - a credentials_rejected failure would then be reported as store, sending callers down the wrong recovery path. Wrapping this call in contextlib.suppress(RenewalError) preserves the real category.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread lighthouse_cli/auth.py
turn echo off; that warning is raised as an error before anything is read.
"""
if not _is_interactive():
return sys.stdin.readline(_MAX_SECRET_LINE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: stdin read cap (4096) diverges from the secret validator's limit (2048)

totp.py rejects input over _MAX_INPUT_CHARS = 2048, but this non-interactive read truncates at 4096 and leaves the unread remainder in the pipe for the next reader. Matching 2048 (or draining stdin) would make the rejection deterministic and the fixed error immediate.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"valid": True,
"cookies": list(cookies.keys()),
"renewed": client.renewed_session,
"unattended_login": unattended_status(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Two unattended_status() snapshots can disagree

unattended is snapshotted at line 1041 (used for the no-cookies gate) and recomputed here and in _status_payload (line 1088). The breaker can flip between snapshots (e.g. during the whoami call), so the reported unattended_login may not match the state that gated the earlier branch. Threading a single snapshot through the payload would be consistent.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread README.md
```

`renewed` is `true` when this command signed in again. On failure the document
has `"valid": false`, an `error`, and, if automatic sign-in failed, its

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: category is documented as conditional but is always present

The implementation always includes category in failure documents, with null when no automatic sign-in was attempted (_status_payload; tests assert "category": None). A consumer written strictly from this wording could mishandle the always-present null. Suggested wording: "and a category (null when no automatic sign-in was attempted)".


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

)
except RenewalError as exc:
outcome = f"blocked:{exc.category}" if failure_category is None else f"error:{exc.category}"
except BaseException:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Bare except BaseException discards the exception, and outcome labels come from test intent

The worker labels its outcome from failure_category is None rather than the observed result, and the bare except drops the exception entirely - a regression that mislabels a gate refusal as a sign-in failure (or vice versa) would still report error:<category>. The parent assertions do cover the tested paths, so this is diagnosability rather than a false pass, but embedding the exception class name in the reported outcome would make failures trustworthy.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread lighthouse_cli/totp.py

def time_step(for_time: float, period: int = TOTP_PERIOD) -> int:
"""Return the RFC 6238 time-step counter for a Unix timestamp."""
return int(for_time // period)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Out-of-domain inputs surface raw stdlib exceptions

period=0 raises ZeroDivisionError here; NaN/inf timestamps raise raw ValueError/OverflowError downstream, and a counter at or above 2**64 raises struct.error. All current callers pass time.time() and the validated period (30), so this is unreachable in practice - noted because the module's stated contract is that failures carry fixed, non-echoing messages.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 27 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 24
Issue Details (click to expand)

WARNING

File Line Issue
lighthouse_cli/session_renewal.py 613 A failed post-success clear_breaker() (RenewalError("store") on unlink failure) converts a fully completed sign-in into a reported failure, in renew_session and in store_authenticator/remove_authenticator; with the in-flight block it also leaves the breaker blocked/unknown, refusing future automatic sign-ins until a manual login
lighthouse_cli/api.py 462 First cookie access of any command takes the 120 s cross-process renewal_lock() (legacy-cookie migration path); lock contention then surfaces as Session expired (cookie upgrade busy), a new failure class even for non-recoverable POST paths
lighthouse_cli/auth.py 685 renewal_lock() is held across the probe_cookies network probe (30 s timeout, up to 4 attempts), so one slow verification consumes the lock budget of every concurrent sign-in path

SUGGESTION

File Line Issue
tests/test_session_renewal.py 743 The no-fcntl import test omits quiz_preview_session/quiz_learner_session, which still hard-import fcntl; on non-POSIX those quiz subcommands remain the only CLI entry points failing with a raw ImportError traceback, and the test name overclaims whole-package coverage (NEW)
lighthouse_cli/session_renewal.py 585 The unsupported-platform RenewalUnavailableError reuses the "Run: lighthouse auth totp-setup" advice, which dead-ends where totp-setup refuses; unreachable via CLI today but misleading defense-in-depth for future callers (NEW)
lighthouse_cli/session_renewal.py 189 Without fcntl, the lock no-op also silently drops cross-process serialization for legacy-cookie migration and CDP cookie saves; two concurrent processes can interleave the migration read-reread-write (NEW)
lighthouse_cli/session_renewal.py 88 _UNREADABLE_AUTHENTICATOR message names only the fast-clock cause; reading the authenticator never consults the clock, so for a corrupt file or a foreign key the advice dead-ends
lighthouse_cli/session_renewal.py N/A _record_failure write failure raises store and masks the real failure category
lighthouse_cli/session_renewal.py 447 Path.exists() swallows OSError, so the pending_human_login gate can silently fail open
lighthouse_cli/session_renewal.py 479 A rejected step reservation maps to category store instead of clock_skew
lighthouse_cli/session_renewal.py 152 mkdir(mode=0o700) never tightens a pre-existing wide-permission config dir or lock file
lighthouse_cli/credential_store.py 486 reserve_totp_step's non-atomic read-modify-write relies on an undocumented "must hold renewal_lock" precondition
lighthouse_cli/credential_store.py 458 save_totp skips key-length validation that load_totp enforces, so out-of-band TotpParams can brick the stored authenticator
lighthouse_cli/credential_store.py 491 Condition order reads backwards and folds "not configured" into the misleading "data is invalid" message
lighthouse_cli/ms_auth.py 2113 Stored-mode empty-provider error keeps the human-flow recovery hint (auth verify) and no category
lighthouse_cli/ms_auth.py 468 Every Playwright TimeoutError is classified as "could not reach the page", though networkidle timeouts can mean a loaded-but-chatty page
lighthouse_cli/api.py 544 Missing from None; message also hides that a fresh sign-in just happened and failed validation
lighthouse_cli/api.py 589 renewal_failure never set when recovery falls through to the CDP path, so auth status under-reports the cause
lighthouse_cli/api.py 663 Any non-401 expired status is labeled "Forbidden"; parameterized set invites a misleading reason later
lighthouse_cli/api.py 542 403-to-whoami recovery is skipped when _skip_raise=True while 401 recovery still applies
lighthouse_cli/api.py 1111 check_auth() docstring omits its new state-changing unattended sign-in side effect
lighthouse_cli/auth.py N/A interrupt advice ("run: lighthouse auth login") loops back into the stored-authenticator path that just failed; the clock_skew advice documents the loop and the totp-remove escape
lighthouse_cli/auth.py 1169 stdin read cap 4096 diverges from the secret validator's 2048 limit; truncation leaves residue in the pipe
lighthouse_cli/commands.py 1062 unattended_status() snapshotted twice; breaker can flip between the gate and the reported payload
README.md 339 category documented as conditional but always present (null when no automatic sign-in was attempted)
tests/_relogin_mp_helper.py 102 Bare except BaseException discards the exception; worker outcome labels derive from test intent, not observation
lighthouse_cli/totp.py 136 Out-of-domain inputs (period=0, NaN/inf timestamps, huge counters) surface raw stdlib exceptions
Files Reviewed (16 files)
  • lighthouse_cli/session_renewal.py - 8 issues (incremental diff re-verified: the _HAS_FLOCK try/except import, the _process_lock thread-only fallback, unattended_supported() gates in unattended_configured/renew_session, and the unchanged renewal_lock semantics all check out against the new tests)
  • lighthouse_cli/api.py - 6 issues (targeted re-check at HEAD 4db986f, unchanged)
  • lighthouse_cli/auth.py - 3 issues (new _UNATTENDED_UNSUPPORTED refusal verified: it is allowlisted, fires before ensure_config_dir()/secret read, and _stored_authenticator_matches short-circuits so login keeps the person-driven flow)
  • lighthouse_cli/credential_store.py - 3 issues (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/ms_auth.py - 2 issues (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/commands.py - 1 issue (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/display.py - no issues (the rewritten _RENEWAL_FAILURE_RE matches both the raw (automatic sign-in failed: X) and the formatter's own automatic sign-in failed (X) shape; idempotency verified against the category allowlist and the new parametrized test)
  • lighthouse_cli/totp.py - 1 issue (targeted re-check at HEAD, unchanged)
  • README.md - 1 issue (targeted re-check at HEAD, unchanged)
  • docs/auth-unattended.md - no issues (the POSIX requirement and "totp-setup refuses elsewhere" wording match unattended_supported() and the auth.py gates)
  • tests/test_session_renewal.py - 1 issue (new no-fcntl import subprocess test, thread-only lock test, and stored-authenticator-off test all match the implementation)
  • tests/test_auth_login.py - no issues (the no-flock login test correctly asserts the person-driven flow via monkeypatched _HAS_FLOCK)
  • tests/test_auth_totp.py - no issues (the refusal-before-reading test matches the new gate and the JSON error document)
  • tests/test_cli_contracts.py - no issues (the format-twice idempotency test covers all RELOGIN_CATEGORIES)
  • tests/_relogin_mp_helper.py - 1 issue (targeted re-check at HEAD, unchanged)
  • .secrets.baseline - no issues (mechanical +7 line-number shifts tracking the new test_cli_contracts.py lines; internally consistent)

Incremental note: reviewed commits e097ba1 and 4db986f on top of the previously reviewed 6256fe0. Two earlier round-1 comments are fixed by these commits and dropped: the unconditional fcntl import breaking non-POSIX imports (api.py) is resolved by the try/except ImportError fallback, and the double-format category loss (display.py) is resolved by the idempotent regex plus its new test. All 24 previous findings were re-verified at HEAD and carried forward with updated line numbers. Three new suggestions were filed on the non-POSIX degradation change (test coverage gap, dead-end recovery advice, and the unadvertised loss of cross-process cookie-write serialization).

Fix these issues in Kilo Cloud

Previous Review Summaries (4 snapshots, latest commit 6256fe0)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 6256fe0)

Status: 24 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 21
Issue Details (click to expand)

WARNING

File Line Issue
lighthouse_cli/session_renewal.py 580 A failed post-success clear_breaker() (RenewalError("store") on unlink failure) converts a fully completed sign-in into a reported failure, in renew_session and in store_authenticator/remove_authenticator; with the in-flight block it also leaves the breaker blocked/unknown, refusing future automatic sign-ins until a manual login
lighthouse_cli/api.py 462 First cookie access of any command takes the 120 s cross-process renewal_lock() (legacy-cookie migration path); lock contention then surfaces as Session expired (cookie upgrade busy), a new failure class even for non-recoverable POST paths
lighthouse_cli/auth.py 682 renewal_lock() is held across the probe_cookies network probe (30 s timeout, up to 4 attempts), so one slow verification consumes the lock budget of every concurrent sign-in path

SUGGESTION

File Line Issue
lighthouse_cli/session_renewal.py 80 New _UNREADABLE_AUTHENTICATOR message names only the fast-clock cause; reading the authenticator never consults the clock, so for a corrupt file or a foreign key (changed passphrase/keyring) the advice dead-ends — consider adding "and the encryption key source" like the store docs row (NEW)
lighthouse_cli/session_renewal.py N/A _record_failure write failure raises store and masks the real failure category
lighthouse_cli/session_renewal.py 418 Path.exists() swallows OSError, so the pending_human_login gate can silently fail open
lighthouse_cli/session_renewal.py 450 A rejected step reservation maps to category store instead of clock_skew
lighthouse_cli/session_renewal.py 144 mkdir(mode=0o700) never tightens a pre-existing wide-permission config dir or lock file
lighthouse_cli/credential_store.py 486 reserve_totp_step's non-atomic read-modify-write relies on an undocumented "must hold renewal_lock" precondition
lighthouse_cli/credential_store.py 458 save_totp skips key-length validation that load_totp enforces, so out-of-band TotpParams can brick the stored authenticator
lighthouse_cli/credential_store.py 491 Condition order reads backwards and folds "not configured" into the misleading "data is invalid" message
lighthouse_cli/ms_auth.py 2113 Stored-mode empty-provider error keeps the human-flow recovery hint (auth verify) and no category
lighthouse_cli/ms_auth.py 468 Every Playwright TimeoutError is classified as "could not reach the page", though networkidle timeouts can mean a loaded-but-chatty page
lighthouse_cli/api.py 544 Missing from None; message also hides that a fresh sign-in just happened and failed validation
lighthouse_cli/api.py 589 renewal_failure never set when recovery falls through to the CDP path, so auth status under-reports the cause
lighthouse_cli/api.py 663 Any non-401 expired status is labeled "Forbidden"; parameterized set invites a misleading reason later
lighthouse_cli/api.py 542 403-to-whoami recovery is skipped when _skip_raise=True while 401 recovery still applies
lighthouse_cli/api.py 1111 check_auth() docstring omits its new state-changing unattended sign-in side effect
lighthouse_cli/auth.py N/A interrupt advice ("run: lighthouse auth login") loops back into the stored-authenticator path that just failed; the clock_skew advice documents the loop and the totp-remove escape
lighthouse_cli/auth.py 1164 stdin read cap 4096 diverges from the secret validator's 2048 limit; truncation leaves residue in the pipe
lighthouse_cli/commands.py 1062 unattended_status() snapshotted twice; breaker can flip between the gate and the reported payload
README.md 339 category documented as conditional but always present (null when no automatic sign-in was attempted)
tests/_relogin_mp_helper.py 102 Bare except BaseException discards the exception; worker outcome labels derive from test intent, not observation
lighthouse_cli/totp.py 136 Out-of-domain inputs (period=0, NaN/inf timestamps, huge counters) surface raw stdlib exceptions
Files Reviewed (12 files)
  • lighthouse_cli/session_renewal.py - 6 issues (incremental diff re-verified: the new _UNREADABLE_AUTHENTICATOR refusal, the username-agnostic same-key step keep, and the CredentialStoreError → _clean_auth_command → safe-message flow all check out against the new tests; earlier findings at 144/418/450/580 re-verified at HEAD)
  • lighthouse_cli/api.py - 6 issues (targeted re-check at HEAD 6256fe0, unchanged)
  • lighthouse_cli/auth.py - 3 issues (new unknown message verified: it names the --mfa-method choose escalation and the persisted block; the interrupt advice loop remains; renewal_lock across probe_cookies and the stdin cap remain)
  • lighthouse_cli/credential_store.py - 3 issues (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/ms_auth.py - 2 issues (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/commands.py - 1 issue (targeted re-check at HEAD, unchanged)
  • README.md - 1 issue (targeted re-check at HEAD, unchanged)
  • tests/_relogin_mp_helper.py - 1 issue (targeted re-check at HEAD, unchanged)
  • lighthouse_cli/totp.py - 1 issue (targeted re-check at HEAD, unchanged)
  • docs/auth-unattended.md - no issues (new totp-setup refusal text and the store row's "can leave it blocked as unknown" both match store_authenticator and the restore-on-unrecorded-failure logic)
  • tests/test_auth_login.py - no issues (new unknown message assertions match the updated text)
  • tests/test_auth_totp.py - no issues (new parametrized username-agnostic step-keep cases and the never-replace-unreadable test match the implementation)

Incremental note: reviewed commits ba2abbd and 6256fe0 on top of the previously reviewed 56673b2. Two previous items are dropped after HEAD verification: the corrupt-state non-string-category WARNING is not reproducible (the type(category) is not str guard short-circuits before the membership test), and the network cooldown docs suggestion is resolved by the updated category-table row. The new unreadable-authenticator refusal is mutually consistent with the docs, the safe-message allowlist, and the recovery flow; one new wording suggestion was filed on it.

Fix these issues in Kilo Cloud

Previous review (commit 56673b2)

Status: 23 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 20
Issue Details (click to expand)

WARNING

File Line Issue
lighthouse_cli/session_renewal.py 575 A failed post-success clear_breaker() (RenewalError("store") on unlink failure) converts a fully completed sign-in into a reported failure, in renew_session and in store_authenticator/remove_authenticator; with the new in-flight block it also leaves the breaker blocked/unknown, refusing future automatic sign-ins until a manual login
lighthouse_cli/api.py 462 First cookie access of any command takes the 120 s cross-process renewal_lock(); lock contention then surfaces as Session expired (cookie upgrade busy), a new failure class even for non-recoverable POST paths
lighthouse_cli/auth.py 678 renewal_lock() is held across the probe_cookies network probe (30 s timeout, up to 4 attempts), so one slow verification consumes the lock budget of every concurrent sign-in path

SUGGESTION

File Line Issue
lighthouse_cli/session_renewal.py 571 _record_failure write failure raises store and masks the real failure category
lighthouse_cli/session_renewal.py 417 Path.exists() swallows OSError, so the pending_human_login gate can silently fail open
lighthouse_cli/session_renewal.py 449 A rejected step reservation maps to category store instead of clock_skew
lighthouse_cli/session_renewal.py 142 mkdir(mode=0o700) never tightens a pre-existing wide-permission config dir or lock file
lighthouse_cli/credential_store.py 486 reserve_totp_step's non-atomic read-modify-write relies on an undocumented "must hold renewal_lock" precondition
lighthouse_cli/credential_store.py 458 save_totp skips key-length validation that load_totp enforces, so out-of-band TotpParams can brick the stored authenticator
lighthouse_cli/credential_store.py 491 Condition order reads backwards and folds "not configured" into the misleading "data is invalid" message
lighthouse_cli/ms_auth.py 2113 Stored-mode empty-provider error keeps the human-flow recovery hint (auth verify) and no category
lighthouse_cli/ms_auth.py 468 Every Playwright TimeoutError is classified as "could not reach the page", though networkidle timeouts can mean a loaded-but-chatty page
lighthouse_cli/api.py 545 Missing from None; message also hides that a fresh sign-in just happened and failed validation
lighthouse_cli/api.py 589 renewal_failure never set when recovery falls through to the CDP path, so auth status under-reports the cause
lighthouse_cli/api.py 663 Any non-401 expired status is labeled "Forbidden"; parameterized set invites a misleading reason later
lighthouse_cli/api.py 542 403-to-whoami recovery is skipped when _skip_raise=True while 401 recovery still applies
lighthouse_cli/api.py 1111 check_auth() docstring omits its new state-changing unattended sign-in side effect
lighthouse_cli/auth.py 152 interrupt advice ("run: lighthouse auth login") loops back into the stored-authenticator path that just failed; the clock_skew advice updated in this PR now documents the loop and the totp-remove escape
lighthouse_cli/auth.py 1160 stdin read cap 4096 diverges from the secret validator's 2048 limit; truncation leaves residue in the pipe
lighthouse_cli/commands.py 1062 unattended_status() snapshotted twice; breaker can flip between the gate and the reported payload
README.md 339 category documented as conditional but always present (null when no automatic sign-in was attempted)
tests/_relogin_mp_helper.py 102 Bare except BaseException discards the exception; worker outcome labels derive from test intent, not observation
lighthouse_cli/totp.py 136 Out-of-domain inputs (period=0, NaN/inf timestamps, huge counters) surface raw stdlib exceptions
Files Reviewed (14 files)
  • lighthouse_cli/session_renewal.py - 5 issues (incremental diff re-verified: in-flight blocked/unknown pre-write, restore-on-unrecorded-failure, and unconditional keep-newer-step in store_authenticator all check out against the new tests)
  • lighthouse_cli/api.py - 6 issues (targeted re-check, unchanged)
  • lighthouse_cli/auth.py - 3 issues (targeted re-check; new clock_skew message verified, interrupt advice loop remains)
  • lighthouse_cli/credential_store.py - 3 issues (targeted re-check, unchanged)
  • lighthouse_cli/ms_auth.py - 2 issues (targeted re-check, unchanged)
  • lighthouse_cli/commands.py - 1 issue (targeted re-check, unchanged)
  • README.md - 1 issue (targeted re-check, unchanged)
  • tests/_relogin_mp_helper.py - 1 issue (targeted re-check, unchanged)
  • lighthouse_cli/totp.py - 1 issue (targeted re-check, unchanged)
  • docs/auth-unattended.md - no issues (new write-semantics, category-table and Clock recovery text verified against _attempt, _code_provider, _check_gates and the _MAX_SECRET_LINE/validator limits; the earlier network rollback-advice gap is fixed by the new table text)
  • lighthouse_cli/cli.py - no issues (new "for that username" help text matches _usable_pair username matching)
  • tests/test_auth_totp.py - no issues (parametrized keep-newer-step and updated help assertions match the new logic)
  • tests/test_session_renewal.py - no issues (new restore, stay-blocked, in-flight-block and interrupted-sign-in tests match the implementation)
  • .gitleaksignore - no issues (unchanged since last verified)

Incremental note: reviewed commits 34bf5ca and 56673b2 on top of the previously reviewed 783702e. The previous docs SUGGESTION about the network cooldown rollback hold is resolved by the updated category table and is dropped. No new issues were found in the changed lines: the in-flight breaker block, its restore on unrecorded failures, the never-reuse-a-step setup change, and the totp-remove recovery docs/messages are mutually consistent and covered by tests. All other previous findings were re-verified against HEAD 56673b2 and remain listed with active inline comments.

Fix these issues in Kilo Cloud

Previous review (commit 783702e)

Status: 24 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 21
Issue Details (click to expand)

WARNING

File Line Issue
lighthouse_cli/session_renewal.py 574 A failed post-success clear_breaker() (RenewalError("store") on unlink failure) converts a fully completed sign-in into a reported failure, in renew_session and in store_authenticator/remove_authenticator
lighthouse_cli/api.py 462 First cookie access of any command takes the 120 s cross-process renewal_lock(); lock contention then surfaces as Session expired (cookie upgrade busy), a new failure class even for non-recoverable POST paths
lighthouse_cli/auth.py 676 renewal_lock() is held across the probe_cookies network probe (30 s timeout, up to 4 attempts), so one slow verification consumes the lock budget of every concurrent sign-in path

SUGGESTION

File Line Issue
lighthouse_cli/session_renewal.py 413 New: the network cooldown now also holds under a backwards clock, but the docs table still advises only "Retry later" for network and the clock paragraph covers only code_rejected/clock_skew, leaving rolled-back-clock users in a network loop with no working advice
lighthouse_cli/session_renewal.py 571 _record_failure write failure raises store and masks the real failure category
lighthouse_cli/session_renewal.py 417 Path.exists() swallows OSError, so the pending_human_login gate can silently fail open
lighthouse_cli/session_renewal.py 449 A rejected step reservation maps to category store instead of clock_skew
lighthouse_cli/session_renewal.py 142 mkdir(mode=0o700) never tightens a pre-existing wide-permission config dir or lock file
lighthouse_cli/credential_store.py 486 reserve_totp_step's non-atomic read-modify-write relies on an undocumented "must hold renewal_lock" precondition
lighthouse_cli/credential_store.py 458 save_totp skips key-length validation that load_totp enforces, so out-of-band TotpParams can brick the stored authenticator
lighthouse_cli/credential_store.py 491 Condition order reads backwards and folds "not configured" into the misleading "data is invalid" message
lighthouse_cli/ms_auth.py 2113 Stored-mode empty-provider error keeps the human-flow recovery hint (auth verify) and no category
lighthouse_cli/ms_auth.py 468 Every Playwright TimeoutError is classified as "could not reach the page", though networkidle timeouts can mean a loaded-but-chatty page
lighthouse_cli/api.py 545 Missing from None; message also hides that a fresh sign-in just happened and failed validation
lighthouse_cli/api.py 589 renewal_failure never set when recovery falls through to the CDP path, so auth status under-reports the cause
lighthouse_cli/api.py 663 Any non-401 expired status is labeled "Forbidden"; parameterized set invites a misleading reason later
lighthouse_cli/api.py 542 403-to-whoami recovery is skipped when _skip_raise=True while 401 recovery still applies
lighthouse_cli/api.py 1111 check_auth() docstring omits its new state-changing unattended sign-in side effect
lighthouse_cli/auth.py 148 clock_skew/interrupt advice ("run: lighthouse auth login") loops back into the stored-authenticator path that just failed
lighthouse_cli/auth.py 1158 stdin read cap 4096 diverges from the secret validator's 2048 limit; truncation leaves residue in the pipe
lighthouse_cli/commands.py 1062 unattended_status() snapshotted twice; breaker can flip between the gate and the reported payload
README.md 339 category documented as conditional but always present (null when no automatic sign-in was attempted)
tests/_relogin_mp_helper.py 102 Bare except BaseException discards the exception; worker outcome labels derive from test intent, not observation
lighthouse_cli/totp.py 136 Out-of-domain inputs (period=0, NaN/inf timestamps, huge counters) surface raw stdlib exceptions
Files Reviewed (14 files)
  • lighthouse_cli/session_renewal.py - 6 issues (incremental diff: fail-closed state validation, fast-clock setup recovery, writable-breaker pre-write, cooldown rollback hold — all verified)
  • lighthouse_cli/api.py - 6 issues (targeted re-check, unchanged)
  • lighthouse_cli/auth.py - 3 issues (targeted re-check, unchanged)
  • lighthouse_cli/credential_store.py - 3 issues (targeted re-check, unchanged)
  • lighthouse_cli/ms_auth.py - 2 issues (targeted re-check, unchanged)
  • lighthouse_cli/commands.py - 1 issue (targeted re-check, unchanged)
  • README.md - 1 issue (targeted re-check, unchanged)
  • tests/_relogin_mp_helper.py - 1 issue (targeted re-check, unchanged)
  • lighthouse_cli/totp.py - 1 issue (targeted re-check, unchanged)
  • .gitleaksignore - no issues (commit-pinned entry plus inline gitleaks:allow both cover the fixture key)
  • docs/auth-unattended.md - no issues (90-second and "more than a minute" claims match _MAX_FUTURE_STEPS = 2)
  • lighthouse_cli/cli.py - no issues (new help text verified against use_stored_authenticator)
  • tests/test_auth_totp.py - no issues (updated/new setup-step tests match the new keep/drop logic)
  • tests/test_session_renewal.py - no issues (new breaker-writability, cooldown-rollback, corrupt-state and fast-clock-recovery tests verified)

Incremental note: the previous corrupt-state TypeError WARNING is fixed by the new _state_from_document validation (non-string categories and timestamp-pairing mismatches now fail closed) and is dropped from this report. All other previous findings were re-verified against HEAD 783702e and remain listed; they already carry active inline comments. One new SUGGESTION (docs gap for the network cooldown rollback hold) was posted inline.

Fix these issues in Kilo Cloud

Previous review (commit 55e806b)

Status: 24 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 20
Issue Details (click to expand)

WARNING

File Line Issue
lighthouse_cli/session_renewal.py 232 Corrupt state with a non-string category (e.g. []/{}) raises TypeError in the membership test instead of failing closed; it escapes _load_state's and renew_session's try blocks, and unattended_status() catches only RenewalError, so auth status dies with a raw traceback
lighthouse_cli/session_renewal.py 560 A failed post-success clear_breaker() (RenewalError("store") on unlink failure) converts a fully completed sign-in into a reported failure, in renew_session and in store_authenticator/remove_authenticator
lighthouse_cli/api.py 462 First cookie access of any command takes the 120 s cross-process renewal_lock(); lock contention then surfaces as Session expired (cookie upgrade busy), a new failure class even for non-recoverable POST paths
lighthouse_cli/auth.py 676 renewal_lock() is held across the probe_cookies network probe (30 s timeout, up to 4 attempts), so one slow verification consumes the lock budget of every concurrent sign-in path

SUGGESTION

File Line Issue
lighthouse_cli/session_renewal.py 558 _record_failure write failure raises store and masks the real failure category
lighthouse_cli/session_renewal.py 408 Path.exists() swallows OSError, so the pending_human_login gate can silently fail open
lighthouse_cli/session_renewal.py 440 A rejected step reservation maps to category store instead of clock_skew
lighthouse_cli/session_renewal.py 139 mkdir(mode=0o700) never tightens a pre-existing wide-permission config dir or lock file
lighthouse_cli/credential_store.py 486 reserve_totp_step's non-atomic read-modify-write relies on an undocumented "must hold renewal_lock" precondition
lighthouse_cli/credential_store.py 458 save_totp skips key-length validation that load_totp enforces, so out-of-band TotpParams can brick the stored authenticator
lighthouse_cli/credential_store.py 491 Condition order reads backwards and folds "not configured" into the misleading "data is invalid" message
lighthouse_cli/ms_auth.py 2113 Stored-mode empty-provider error keeps the human-flow recovery hint (auth verify) and no category
lighthouse_cli/ms_auth.py 468 Every Playwright TimeoutError is classified as "could not reach the page", though networkidle timeouts can mean a loaded-but-chatty page
lighthouse_cli/api.py 544 Missing from None; message also hides that a fresh sign-in just happened and failed validation
lighthouse_cli/api.py 589 renewal_failure never set when recovery falls through to the CDP path, so auth status under-reports the cause
lighthouse_cli/api.py 663 Any non-401 expired status is labeled "Forbidden"; parameterized set invites a misleading reason later
lighthouse_cli/api.py 542 403-to-whoami recovery is skipped when _skip_raise=True while 401 recovery still applies
lighthouse_cli/api.py 1111 check_auth() docstring omits its new state-changing unattended sign-in side effect

[Snapshot truncated.]

Additional previous summary content was truncated to keep this comment within platform limits.


Reviewed by glm-5.3-flash · Input: 89.4K · Output: 28K · Cached: 1.2M

Review guidance: REVIEW.md from base branch main

gitleaks scans every commit in the PR range, and 55e806b holds the
OTHER_SECRET test fixture before 253a88e marked it `gitleaks:allow`.
The key is a made-up base32 string for tests, so its fingerprint is
ignored rather than rewriting the commit reviewers already saw.
@factory-droid

factory-droid Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


The renewal gate protects individual write requests, but write commands can still sign in unattended during prerequisite reads. Carry the write-command boundary through those reads so an expired session cannot trigger automatic sign-in before an upload or assessment write.

1 inline comment posted

Comment thread lighthouse_cli/api.py
# when its response reports an expired session. Never refresh and
# send that body again. The read-only auth mode also forbids
# sign-in, browser refresh and cookie persistence for GET/HEAD.
can_recover = retryable and not self._read_only_auth

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Disable unattended sign-in during write-command preflight reads

The recovery gate checks only this individual request, so a write command can still sign in automatically on its prerequisite GET and then perform the write. For example, AssessmentAPI.write() fetches /d2l/home via get_csrf_token() before its POST, and cmd_submit() uses a normal client to resolve its course and folder before uploading. With expired or missing cookies, those GETs renew the session and the subsequent write proceeds, contrary to the documented rule that uploads and other writes never sign in automatically. Carry the no-renewal policy through the entire write workflow, not just its POST.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — re-review of the four commits pushed since the initial review (253a88e, b61af23, 0962d8b, 783702e), which land the round-1 bot feedback: fail-closed breaker handling, fast-clock recovery, help text, and the gitleaks fixture markers.

  • Hardened the breaker parser (_state_from_document): non-string categories are now rejected by a type guard that runs before the category not in allowed membership test (so an unhashable []/{} in a hand-corrupted file returns _CORRUPT_STATE instead of raising TypeError), and since/last_attempt must pair with category — a recorded failure has both timestamps, a clear state neither. That invariant matches every writer: _record_failure always writes both, and the new pre-write rewrites a validated state as-is.
  • Made automatic sign-in prove the breaker writable before the password is sent: _attempt now calls _write_state(store, state) after _usable_pair and before _sign_in, so an unwritable config dir fails fast with unrecorded store and zero Microsoft logins — pinned by test_automatic_sign_in_needs_a_writable_breaker_first asserting sso.logins == []. The rewrite preserves cooldown timestamps, the success path still clears, and a pre-write failure stays unrecorded (store ∈ _UNRECORDED_CATEGORIES), so there is no nested-write failure path.
  • Added fast-clock recovery to auth totp-setup: store_authenticator keeps a previously recorded step only when it is at most _MAX_FUTURE_STEPS (2) ahead of the clock; anything further came from a clock running fast and is dropped, so re-running setup with the same secret recovers from clock_skew. Both sides of the boundary are tested, and the doc's "up to 90 seconds more" matches the provider's wait <= period + 1 sleep-window arithmetic.
  • Held the network cooldown across a backwards clock: the 0 <= lower bound is gone, so a clock that jumps back keeps the 300 s cooldown instead of re-arming failed attempts on every read; the gate error remains unrecorded, so it cannot extend itself. Covered by test_network_cooldown_holds_when_the_clock_goes_back.
  • Documented the stored authenticator in auth login --help and the command docstring, matching the use_stored_authenticator semantics exactly (no --totp and method unset or app, username match); pinned by a help-text test.
  • Silenced the gitleaks fixture findings two ways: the inline gitleaks:allow on OTHER_SECRET covers findings anchored to future commits, and the commit-pinned .gitleaksignore entry covers the finding anchored to the already-pushed first commit (an inline marker added later cannot retroactively cover it). This repo squash-merges, so post-merge the pinned commit never lands on main and the squash commit re-adds the line with the inline marker — the scan stays green either way, as the green lint / types / architecture / deps job on this head confirms.

Verification: the three touched suites pass locally (108 tests: test_session_renewal.py, test_auth_totp.py, test_secret_absence.py, including the seven new corrupt-state matrix cases and the manual-login-clears-corrupt-state path), ruff check . and strict mypy are clean, .secrets.baseline is line-number moves plus a generated_at refresh only, and CI — including the full-history gitleaks scan — is green on 783702e.

Pullfrog | View workflow run | Using glm-5.3 | 𝕏

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

if state.category == "network" and state.last_attempt:
# A clock that went back keeps the cooldown until it catches up.
elapsed = (_utc_now() - _parse_utc(state.last_attempt)).total_seconds()
if elapsed < NETWORK_COOLDOWN_SECONDS:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The new clock-rollback hold on the network cooldown is not reflected in the user-facing docs

Dropping the 0 <= bound makes a clock set backwards keep the network cooldown until the clock catches up — potentially days (intentional and covered by test_network_cooldown_holds_when_the_clock_goes_back). But the network row of the troubleshooting table in docs/auth-unattended.md still advises only "Retry later", which never succeeds after a large rollback, and the updated clock paragraph there covers only code_rejected and clock_skew. A user following the docs stays stuck in a network loop with no working advice; the docs should mention the rollback hold and that auth totp-setup/auth totp-remove (or a manual auth login) clears it.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

GPT-6.1 Sol found two ways the round-1 fixes let a sign-in repeat:

- `totp-setup` with the same key dropped a recorded step more than two
  steps ahead of the clock. After a successful sign-in at step S and a
  clock that went back three steps, setup saved S-3, and once the clock
  was corrected the next login sent the code for S again. Grok 4.7,
  GLM 5.3 and DeepSeek V4.1 Flash found the same replay. Setup with the
  same key again keeps the newer step. The recovery from a fast clock
  is now `totp-remove`, then `totp-setup`, once the clock is right; the
  docs and the `clock_skew` login message (Grok 4.7) say so.
- The breaker write before contacting Microsoft only proved the file was
  writable. A process killed while Microsoft had the password left the
  state clear, so the next read sent it again. Automatic sign-in now
  records itself as blocked (`unknown`) first, and the outcome replaces
  that: success clears it, a failure records its category, and a `store`
  failure restores the previous state. DeepSeek V4.1 Flash found the
  related case where recording the failure itself fails; the in-flight
  block now stays.

CodeRabbit found that the docs' fix for `unknown`
(`auth login --mfa-method choose`) never clears the block; it is now
`auth login`. DeepSeek V4.1 Flash found that a fast clock can stretch
the `network` cooldown; the docs now name `auth login` for that.
…n help

Factory Droid found that the docs listed `submit` among the things that
never sign in automatically, but the reads a write command makes first
(course and folder lookup, the CSRF fetch) are ordinary reads and can.
That is the reviewed design: the rule is per request, and a write is
never sent twice. The docs now say so.

CodeRabbit found that `auth login --help` left out that the stored
authenticator is used only for the username it is bound to.
@factory-droid

factory-droid Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


No new high-confidence actionable issues found.

0 inline comments posted

GPT-6.1 Sol found that `totp-setup` treated an authenticator file it
could not read as absent. After a sign-in at step S and a clock that
went back three steps, a corrupted or foreign-key `authenticator.json`
was replaced with S-3, and once the clock was corrected the next login
sent the code for S again. Setup now refuses to replace a file it
cannot read and names `totp-remove`, then `totp-setup`.

DeepSeek V4.1 Flash found that setup kept a newer step only when the
saved username was unchanged. Re-storing the same key under an alias
dropped it the same way. The step now follows the key, whatever the
username.
…lock

GLM 5.3, Grok 4.7, Muse Spark 1.3 and DeepSeek V4.1 Flash found that an
`unknown` stored-login failure still said to run
`auth login --mfa-method choose`. That login gives a session but leaves
automatic sign-in blocked; round 2 fixed the same advice in the docs
only. The message now says to run `auth login` again, and that the
chooser leaves automatic sign-in blocked.

GLM 5.3 found that the docs called `store` failures unrecorded, but one
during an automatic sign-in can leave the in-flight `unknown` block in
place. The docs now say so.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — re-review of the two commits pushed since the last review (34bf5ca, 56673b2), which close out the round-2 bot feedback: the used-step replay window and the breaker's in-flight visibility, plus the matching docs/help.

  • Blocked the breaker while an automatic sign-in runs: _attempt now persists blocked/unknown before the password is sent, instead of rewriting the prior state. A process killed mid-sign-in (KeyboardInterrupt bypasses the failure handler by design) leaves the block behind; a recorded failure overwrites it with its real category; an unrecorded store failure restores the previous state and stays blocked if that restore fails; success clears it. Every one of those paths is pinned by a new test, including the interrupted-then-refused sequence (sso.logins == 1) and the failure-that-cannot-be-recorded case.
  • Removed the fast-clock step drop from totp-setup: re-storing the same key now keeps the newer recorded step unconditionally. Round 2's _MAX_FUTURE_STEPS window could forget a step whose code Microsoft had already accepted and resend it — a genuine replay vector — so recovery from clock_skew is now totp-remove + re-setup. The clock-rollback replay case is pinned by test_setup_after_a_clock_rollback_never_replays_an_accepted_step, and the same-key retention is pinned at both the old boundary (+2) and an extreme (+100).
  • Aligned docs and user messages with the new semantics: the category table now counts an interrupted automatic sign-in as unknown, the clock section describes the never-resend rule and the remove+setup recovery, the fixed clock_skew message names that recovery, and the network row notes a fast clock stretches the cooldown. The write-request scope ("a write is never sent twice; a write command's earlier reads are ordinary reads") now matches can_recover's per-request predicate exactly.
  • Qualified the stored-authenticator help: auth login --help and the docstring now say the stored authenticator applies "for that username", matching use_stored_authenticator's username-match gate; pinned by the help-text test.

Verification: 277 tests green across the touched suites (test_session_renewal, test_auth_totp, test_cli_contracts, test_secret_absence, test_session_renewal_multiprocess), ruff check . clean, and strict mypy clean with the pinned toolchain — the one complaint from an unpinned mypy/types-requests run landed on pre-existing ms_auth.py code outside this delta and vanishes under requirements-dev.txt versions. The breaker state machine (reuse → gates → load → in-flight block → sign-in → record/restore/clear) was traced end-to-end against the code on disk.

Pullfrog | View workflow run | Using glm-5.3 | 𝕏

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@factory-droid

factory-droid Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


Two actionable issues remain: the new unconditional fcntl dependency prevents ordinary commands from loading on Windows, and repeated error formatting hides the unattended sign-in failure category in assignment downloads.

2 inline comments posted

}
# Setup cannot keep the used steps of an authenticator it cannot read.
_UNREADABLE_AUTHENTICATOR = (
"The stored authenticator could not be read. Check the system clock, "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: "Check the system clock" names only one of the ways an authenticator becomes unreadable

Nothing in the read path consults the clock (the failure comes from the file content, the envelope, or the key source), so for the common causes - a corrupt/truncated file or a foreign key after a changed passphrase/keyring - this first step is a dead end. The clock matters only when the user is re-storing after a fast-clock clock_skew (setup would then reserve an ahead-of-clock step again). Consider naming the other cause too, e.g. "Check the system clock and the encryption key source, then run: ...", matching the store row's "check the key source" advice in docs/auth-unattended.md.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — re-review of the two commits pushed since the last review (ba2abbd, 6256fe0), which close out the round-3 feedback: the unreadable-authenticator replay window, cross-username step retention, and the unknown recovery message.

  • Made totp-setup refuse to replace an authenticator it cannot read: store_authenticator now raises CredentialStoreError(_UNREADABLE_AUTHENTICATOR) from None when load_totp() fails, instead of treating the previous binding as absent — an unreadable file's used steps are unknown, and overwriting it could resend a code Microsoft already accepted. The raise surfaces through _clean_auth_command's CredentialStoreError handler into the exact-match allowlist, so the fixed message renders verbatim with exit 1 and the underlying error is discarded. Pinned by test_setup_never_replaces_an_authenticator_it_cannot_read, which also checks the file is left untouched and that totp-remove → setup recovers.
  • Extended same-key step retention across usernames: the previous.username == username condition is gone, so a step recorded under any username is kept when the same key is re-stored. Correct — a TOTP code depends only on the key and the time step, so a step burned under another username must never be resent either. Both the old boundary (+2) and an extreme (+100) are now pinned per bound_to username.
  • Pointed the unknown failure at the login that clears the block: the message now leads with plain auth login (never breaker-gated, uses the stored path), offers --mfa-method choose as the fallback, and states that automatic sign-in stays blocked until a stored login succeeds — which matches the breaker semantics exactly (chooser success signs in but does not clear the block). The store docs row now notes an automatic-sign-in store failure can leave the block as unknown, and the totp-setup docs row names the refuse-then-totp-remove behavior.

Verification: 432 tests green across the touched suites (test_auth_totp, test_auth_login, test_session_renewal, test_cli_contracts, test_secret_absence), ruff check . clean, and mypy shows only the known environmental playwright.sync_api import-not-found on this runner. The raise path, the retention predicate, and both new user-facing messages were traced end-to-end against the code on disk.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

Comment thread lighthouse_cli/api.py
missing_cookie_names,
save_cookies,
)
from .session_renewal import (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve ordinary API access on non-POSIX platforms

This unconditional import loads session_renewal.py, which imports the Unix-only fcntl module. On Windows, previously independent commands such as lighthouse courses --json, grades, downloads and authentication now fail during import with ModuleNotFoundError, even when unattended sign-in is not configured; the JSON command also produces no JSON output. Make the locking dependency platform-aware and keep unsupported renewal functionality from preventing ordinary cookie-authenticated access.

Comment thread lighthouse_cli/display.py Outdated
"credentials_rejected", "code_rejected", "method_unavailable", "clock_skew",
"interrupt", "network", "store", "busy", "pending_human_login", "unknown",
})
_RENEWAL_FAILURE_RE = re.compile(r"\(automatic sign-in failed: ([a-z_]{1,32})\)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve renewal categories when formatting an error twice

This regex recognizes the original exception text but not the formatter's own output, so formatting a sanitized renewal error again removes its category. For example, download 123 --assignment 7 --json sanitizes a failed folder fetch in _assignment_selector_snapshot, then sanitizes it again through _safe_error_entries; a code_rejected failure consequently becomes only Session expired. Run: lighthouse auth login in both diagnostics and JSON. Recognize the emitted category format too so existing rendering paths retain the failure reason.

Factory Droid found that `api.py` now imports `session_renewal`, which
imported `fcntl` at module level. On a system without it, such as
Windows, ordinary commands like `courses --json` died with a raw
`ModuleNotFoundError`; on main they ran. The login cookie save,
`import-session`, the CDP refresh save and the legacy cookie migration
also take the sign-in lock, whose lock file needs POSIX-only open flags.

`fcntl` is now optional. Without it the lock covers only threads, and
stored-authenticator sign-in is off: `totp-setup` refuses before reading
the secret, `auth login` keeps the person-driven flow, automatic sign-in
is never configured, and `renew_session` refuses before doing anything.
The docs' setup section says so.
Factory Droid found that `format_user_error` applied to its own output
dropped the automatic sign-in category, because it matched only the raw
"(automatic sign-in failed: X)" form. `download --assignment --json`
formats twice, so a `code_rejected` failure became a plain "Session
expired". The pattern now also matches the formatter's own form; the
category is still checked against the known set.

The secrets baseline refresh only moves line numbers in
tests/test_cli_contracts.py.
@factory-droid

factory-droid Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


No candidate comments were submitted for validation, so there are no new inline findings. Live Microsoft and D2L validation remains pending.

0 inline comments posted

_WITHOUT_FCNTL = """
import sys
sys.modules["fcntl"] = None # importing it fails, as on Windows
from lighthouse_cli import api, auth, cli, commands, session_import, session_renewal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The import list omits the two modules that still hard-import fcntl

quiz_preview_session.py and quiz_learner_session.py still import fcntl unconditionally (loaded lazily via assessment_commands for the instructor/student quiz subcommands) and are excluded from this snippet. On a non-POSIX system those subcommands remain the only CLI entry points that die with a raw ImportError traceback, while this PR establishes clean degradation (_UNATTENDED_UNSUPPORTED, the unattended_supported() gates) everywhere else, and docs/auth-unattended.md now says "the other commands work as before". The test name test_modules_import_without_fcntl also overclaims: a reader can assume whole-package coverage. Consider documenting the exclusion in the snippet (or naming the test after the listed modules), and/or giving the lazy quiz imports the same clean non-POSIX error treatment.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

RenewalError: Fixed-text failure with a ``category``.
"""
if not unattended_supported():
raise RenewalUnavailableError()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The unsupported-platform refusal reuses advice that dead-ends on that platform

RenewalUnavailableError's fixed message is "No stored authenticator matches the saved credentials. Run: lighthouse auth totp-setup" - but on the non-POSIX system that triggers this branch, auth totp-setup refuses with "Unattended sign-in needs a POSIX system..." (_UNATTENDED_UNSUPPORTED), so the recovery hint points at a command that cannot succeed there. Unreachable through the CLI today (both call sites gate on unattended_configured() / _stored_authenticator_matches first), but as defense-in-depth for future direct callers consider a distinct message naming the platform restriction.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@contextmanager
def _process_lock(deadline: float) -> Iterator[None]:
if not _HAS_FLOCK:
# Stored-authenticator sign-in is off here, so only this process's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The no-flock degradation also drops cross-process serialization for cookie migration and CDP saves

Besides stored-authenticator sign-in, renewal_lock() also serializes the legacy-cookie migration (api._load_migrating_legacy_cookies) and the CDP cookie write (api._refresh_from_browser). On a non-POSIX system this branch reduces those to thread-only locking, so two concurrent CLI processes can interleave the migration read-reread-write (last writer wins on cookies.json) - a state impossible before this change because the module could not import there at all. The comment here and docs/auth-unattended.md mention only sign-in serialization; worth noting the cookie-write scope in one of them.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — re-review of the two commits pushed since the last review (e097ba1, 4db986f), which close out the round-4 Factory Droid findings: fcntl no longer breaks ordinary commands on non-POSIX systems, and a twice-formatted error keeps its sign-in category.

  • Made fcntl optional — the import is now try/except ImportError behind _HAS_FLOCK, and a new unattended_supported() predicate gates every stored-authenticator entry point: totp-setup refuses up front with a fixed POSIX message (allowlisted in _SAFE_FIXED_AUTH_MESSAGES), auth login keeps the person-driven flow, renew_session raises RenewalUnavailableError before touching the lock, and unattended_configured() folds in the platform check so auth status and api.py's recovery path report and use it consistently — an unsupported platform falls through to the CDP browser refresh, exactly as on main. renewal_lock degrades to thread-only serialization via the new _process_lock no-op without creating relogin.lock, so the human login tail, import-session, and the CDP save still share the lock safely.
  • Kept the sign-in category through a second format — _RENEWAL_FAILURE_RE now matches both the raw "(automatic sign-in failed: X)" shape and the formatter's own "automatic sign-in failed (X)" output, so format_user_error is idempotent; pinned per category across all ten RELOGIN_CATEGORIES, with the unknown-category and missing-paren cases still ignored.
  • Pinned the platform behavior with tests — a subprocess test injects a missing fcntl (sys.modules["fcntl"] = None) and asserts api/auth/cli/commands/session_import/session_renewal still import and unattended_supported() is False; no-flock tests cover the person-driven login, setup's refuse-before-reading, thread-only lock serialization (no relogin.lock created), and stored sign-in staying off. AGENTS.md and docs/auth-unattended.md state the POSIX requirement.

Verification: 436 tests green across the four touched suites (test_session_renewal, test_cli_contracts, test_auth_login, test_auth_totp) in a clean venv install — the single failure seen under this runner's user-site pip install -e is a python -I path artifact that a proper install, as CI uses, does not hit — plus ruff check and strict mypy clean. All prior pullfrog reviews had zero open threads, so nothing was carried forward.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

Live test L4 on MAHE: of two concurrent reads with an expired session,
the one that waited on the sign-in lock and reused the other's cookies
also printed "Signing in again with the stored authenticator...".

renew_session takes an on_sign_in callback and calls it once, right
before it contacts Microsoft: after the cookie reuse check, the breaker
and cooldown gates, the username match and the in-flight breaker write.
api.py prints the notice from that callback, so a process that reuses
cookies, is refused by a gate or has no usable authenticator stays
quiet. A failing notice (closed stderr) is ignored instead of being
recorded as a failed sign-in.

Plan review (gpt-6.1-sol): added the breaker-write and interrupted-
notice boundary tests and a real blocking category for the api test.
@factory-droid

factory-droid Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


LGTM. No candidate comments were submitted for validation.

0 inline comments posted

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — re-review of the single commit pushed since the last review (0ee2e16), which closes the L4 live-test note: of two concurrent reads with an expired session, only the process that actually signs in now says so.

  • Moved the sign-in notice behind an on_sign_in callback — _recover_session no longer prints "Signing in again with the stored authenticator..." unconditionally; it passes _announce_sign_in, and renew_session invokes the callback at exactly one point: after the cookie-reuse check, the breaker/cooldown/pending gates, the username match, and the in-flight breaker write, and immediately before Microsoft is contacted. A waiter that reuses another process's cookies, a gate refusal, and an unmatched authenticator all stay quiet now.
  • Kept the notice non-fatal in both directions — a notice that fails (closed stderr) is suppressed so it can never count as a failed sign-in, while a KeyboardInterrupt from the notice still propagates and leaves the breaker blocked as unknown with zero Microsoft logins, matching the established interrupt semantics.
  • Preserved the manual path's single banner — auth login's stored-authenticator route does not pass the callback; it keeps its own "Using the stored authenticator for verification." message, so there is no double notice.
  • Pinned the contract with tests — seven new unit tests (notice-before-login ordering, silence on reuse / each gate / no-usable-pair, failing notice, interrupted notice), the api-transport fixture now emulates the callback, the 403-failure test uses a real blocking category, and the real-spawn multiprocess counters assert ["notice", "login"] — the reusing or blocked worker contributes nothing.

Verification: 407 tests green in a clean venv across test_api_transport, test_session_renewal, test_session_renewal_multiprocess, test_secret_absence, and test_auth_login (573 passed under the ambient install; the one failure is the documented python -I user-site runner artifact, not PR code), ruff check . clean, and strict mypy clean across all 39 source files. One bookkeeping note: the PR description's accepted-trade-offs list still says the notice "is printed before the breaker gates run" — stale as of this commit, worth striking when the L4 "fix in progress" note is updated.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@rabesss
rabesss merged commit 8d08533 into main Oct 15, 2026
9 checks passed
rabesss added a commit that referenced this pull request Oct 15, 2026
Conflicts: tests/test_auth_login.py imports (keep both sides) and
.secrets.baseline (main's, rescanned; only line numbers moved).
@rabesss
rabesss deleted the feat/unattended-relogin branch October 15, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant