fix(login): submit via paste+separate Enter and navigate onboarding

Reproduced the failure against a real claude 2.1 in tmux. Two root causes,
both now fixed (the URL was never wrong — claude genuinely emits
`claude.com/cai/oauth/authorize`, so extraction was fine):

1. Submit race (the actual failure). `send_keys` sent the code and Enter
   together; for a long real code the Enter is processed before Ink commits the
   paste, so nothing submits — the session sits at "Paste code here > ****…",
   exactly what the activity log showed. Fix: deliver the code as a bracketed
   paste (tmux set-buffer/paste-buffer, new tmux.send_text), let it settle, then
   send Enter separately (tmux.send_enter). Verified end-to-end: the separate
   Enter submits and claude proceeds to the exchange.

2. Fragile onboarding. A fresh claude shows a theme picker, then the
   login-method menu, before any URL — the old blind /login+Enter+Enter only
   reached the menu by luck. Fix: start() now reads the pane each pass and reacts
   — accept theme/trust/continue prompts, pick the default subscription option on
   the login-method menu, and send /login once only when already onboarded at the
   REPL.

Also: confirm login by watching ~/.claude.json (where claude stores the account
on Linux) plus a success-text fallback, and fail fast on an "OAuth error /
Press Enter to retry" screen instead of waiting out the poll. Tests updated to
the real TUI screen text. Suite green (200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
This commit is contained in:
Claude
2026-07-15 15:34:43 +00:00
parent f51a49fb98
commit 1fe260ebe4
4 changed files with 211 additions and 102 deletions
+102 -47
View File
@@ -4,21 +4,23 @@ The dashboard has no ``claude`` (it runs in the API container); the control cont
does. So logging Claude Code in is a two-step control command, mirroring the answer/resume does. So logging Claude Code in is a two-step control command, mirroring the answer/resume
handoff: handoff:
1. ``login_start`` opens an interactive ``claude`` session in a dedicated tmux window, 1. ``login_start`` opens an interactive ``claude`` session in a dedicated tmux window and
sends ``/login``, selects the **Claude account with subscription** option, and scrapes navigates to the **Claude account with subscription** login, driving whatever screens a
the pane for the ``claude.com`` / ``claude.ai`` authorization URL. The URL is returned fresh claude shows first (theme picker, folder-trust, the login-method menu) until the
to the UI (which opens it in an iframe) and the tmux session is *left alive*. ``claude.com`` authorization URL appears. That URL is returned to the UI (which opens it
2. ``login_submit`` sends the authorization code the operator pastes back into that same in an iframe) and the tmux session is *left alive*.
still-alive session, then confirms the login by watching for claude to write its 2. ``login_submit`` pastes the authorization code the operator copies back, then presses
credentials file (with a success-text fallback). Enter *separately* (a long code plus an immediate Enter races Ink and never submits),
and confirms the login by watching for claude to write its credentials.
Everything shells out through the :mod:`~handler.control.tmux` seam, so the whole flow is Everything shells out through the :mod:`~handler.control.tmux` seam, so the whole flow is
unit-testable with a fake tmux and never needs a real ``claude`` binary — the same pattern unit-testable with a fake tmux and never needs a real ``claude`` binary — the same pattern
the spawn/resume tests use. the spawn/resume tests use.
The interactive claude TUI is inherently timing-sensitive; the waits below are generous The interactive claude TUI is timing-sensitive; the waits below are generous and
and overridable so an operator can tune them for a slow host. If claude's first run shows overridable. The navigation is screen-driven (it reads the pane and reacts) rather than a
onboarding (theme/trust prompts) before the ``/login`` menu, bump ``boot_wait``. fixed key sequence, so it survives a fresh-onboarding claude *and* an already-logged-out
one sitting at the REPL.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,7 +42,7 @@ LOGIN_SESSION = "handler__login"
LOGIN_COLS = 500 LOGIN_COLS = 500
LOGIN_ROWS = 50 LOGIN_ROWS = 50
# Strip ANSI CSI + OSC escape sequences so success-text matching sees plain text. # Strip ANSI CSI + OSC escape sequences so screen-text matching sees plain text.
_ANSI_RE = re.compile( _ANSI_RE = re.compile(
r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI (colors, cursor moves) r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI (colors, cursor moves)
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC (…terminated by BEL or ST) r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC (…terminated by BEL or ST)
@@ -58,6 +60,15 @@ _SUCCESS_HINTS = (
"you are now logged in", "you are now logged in",
"welcome back", "welcome back",
) )
_FAILURE_HINTS = (
"oauth error",
"press enter to retry",
"invalid code",
"authentication failed",
"login failed",
"code is invalid",
"expired",
)
class LoginError(Exception): class LoginError(Exception):
@@ -80,9 +91,9 @@ def _strip_ansi(text: str) -> str:
def _is_complete_oauth_url(url: str) -> bool: def _is_complete_oauth_url(url: str) -> bool:
"""A *usable* Claude OAuth URL, not a partial/garbled capture. """A *usable* Claude OAuth URL, not a partial/garbled capture.
Requiring the scheme + the OAuth query markers rejects a mid-render capture like Requiring the scheme + the OAuth query markers rejects a mid-render capture (dropped
``ttps://claude.com/cai/oauth?…`` (dropped scheme chars) or a URL cut before its scheme chars, or a URL cut before its query string) — handing either to the iframe
query string — handing either to the iframe would send the operator to a broken page. would send the operator to a broken page.
""" """
low = url.lower() low = url.lower()
return ( return (
@@ -108,17 +119,17 @@ def _extract_url(pane: str) -> str | None:
def _credentials_fingerprint() -> tuple: def _credentials_fingerprint() -> tuple:
"""A fingerprint of claude's on-disk credentials, to detect a login writing them. """A fingerprint of claude's on-disk credentials, to detect a login writing them.
Claude Code stores its OAuth credentials under the user's home; the exact filename has Claude Code stores its OAuth account/token under the user's home — on Linux in
drifted across versions, so we watch every likely location and any ``*credential*`` ``~/.claude.json`` (and/or ``~/.claude/.credentials.json``); the exact filename has
file under ``~/.claude``. The fingerprint is ``(path, mtime, size)`` tuples — it drifted across versions, so we watch every likely location. The fingerprint is
changes when a login creates or rewrites the credentials, which is a far more reliable ``(path, mtime_ns, size)`` tuples — it changes when a login writes the credentials,
"did it work" signal than scraping the TUI for a success string. a far more reliable "did it work" signal than scraping the TUI for a success string.
""" """
home = _home() home = _home()
paths = { paths = {
os.path.join(home, ".claude.json"),
os.path.join(home, ".claude", ".credentials.json"), os.path.join(home, ".claude", ".credentials.json"),
os.path.join(home, ".claude", "credentials.json"), os.path.join(home, ".claude", "credentials.json"),
os.path.join(home, ".claude.json"),
os.path.join(home, ".config", "claude", "credentials.json"), os.path.join(home, ".config", "claude", "credentials.json"),
} }
paths.update(glob.glob(os.path.join(home, ".claude", "*credential*"))) paths.update(glob.glob(os.path.join(home, ".claude", "*credential*")))
@@ -132,17 +143,39 @@ def _credentials_fingerprint() -> tuple:
return tuple(fp) return tuple(fp)
# ---- screen recognizers (matched against the ANSI-stripped, lower-cased pane) ----
def _is_login_method_screen(text: str) -> bool:
return "select login method" in text or ("subscription" in text and "console account" in text)
def _is_theme_screen(text: str) -> bool:
return "text style" in text or "choose the text" in text
def _is_trust_screen(text: str) -> bool:
return "do you trust" in text or ("trust" in text and "files in this" in text)
def _is_continue_screen(text: str) -> bool:
return "press enter to continue" in text
def start( def start(
*, *,
boot_wait: float = 6.0, boot_wait: float = 6.0,
menu_wait: float = 2.0, url_timeout: float = 60.0,
url_timeout: float = 45.0, step_wait: float = 1.5,
poll_interval: float = 0.5, poll_interval: float = 1.0,
) -> dict: ) -> dict:
"""Open ``claude`` in tmux, drive ``/login`` to the subscription account, return the URL. """Open ``claude`` in tmux, navigate to the subscription login, return the URL.
Leaves the tmux session alive for :func:`submit_code`. Raises :class:`LoginError` if Reads the pane each pass and reacts — accepts the theme picker, a folder-trust prompt,
no complete authorization URL appears within ``url_timeout`` seconds. and any "press enter to continue"; selects the (default) subscription option on the
login-method menu; sends ``/login`` once if claude is already onboarded and sitting at
the REPL. Leaves the tmux session alive for :func:`submit_code`. Raises
:class:`LoginError` if no complete authorization URL appears within ``url_timeout``.
""" """
claude = get_settings().claude_bin claude = get_settings().claude_bin
# A stale session from a previous, abandoned attempt would swallow our keystrokes. # A stale session from a previous, abandoned attempt would swallow our keystrokes.
@@ -152,35 +185,42 @@ def start(
tmux.new_session( tmux.new_session(
LOGIN_SESSION, cwd=_home(), command=claude, env={}, width=LOGIN_COLS, height=LOGIN_ROWS LOGIN_SESSION, cwd=_home(), command=claude, env={}, width=LOGIN_COLS, height=LOGIN_ROWS
) )
_sleep(boot_wait) # let claude finish its splash/boot and reach a prompt _sleep(boot_wait) # let claude finish its splash/boot and reach the first screen
tmux.send_keys(LOGIN_SESSION, "/login")
_sleep(menu_wait)
# The login menu's first, default-highlighted option is the subscription account;
# a bare Enter selects it (send_keys always appends Enter).
tmux.send_keys(LOGIN_SESSION, "")
_sleep(menu_wait)
deadline = time.monotonic() + url_timeout deadline = time.monotonic() + url_timeout
tried_login = False
url: str | None = None url: str | None = None
last_pane = "" last_pane = ""
while url is None and time.monotonic() < deadline: while url is None and time.monotonic() < deadline:
# Capture with escapes so an OSC-8 hyperlink href is recoverable; require a # Capture with escapes so an OSC-8 hyperlink href is recoverable; require a
# *complete* URL so a still-rendering pane keeps us polling instead of returning # *complete* URL so a still-rendering pane keeps us polling for a clean one.
# a garbled fragment.
last_pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) last_pane = tmux.capture_pane(LOGIN_SESSION, escapes=True)
url = _extract_url(last_pane) url = _extract_url(last_pane)
if url is None: if url is not None:
break
text = _strip_ansi(last_pane).lower()
if _is_login_method_screen(text):
tmux.send_enter(LOGIN_SESSION) # subscription is the default (option 1)
elif _is_theme_screen(text) or _is_trust_screen(text) or _is_continue_screen(text):
tmux.send_enter(LOGIN_SESSION) # accept the default and move on
elif not tried_login:
# Already-onboarded claude sitting at the REPL (or a screen we don't recognize):
# ask for the login menu once, then let the recognizers above take over.
tmux.send_keys(LOGIN_SESSION, "/login")
tried_login = True
else:
_sleep(poll_interval) _sleep(poll_interval)
continue
_sleep(step_wait)
if url is None: if url is None:
# Don't leave a half-driven session lying around on failure. Surface what claude # Surface what claude actually rendered so a wrong/blocked state is diagnosable.
# actually rendered so a wrong menu/onboarding state is diagnosable, not opaque.
tail = _tail(_strip_ansi(last_pane)) tail = _tail(_strip_ansi(last_pane))
if tmux.has_session(LOGIN_SESSION): if tmux.has_session(LOGIN_SESSION):
tmux.kill_session(LOGIN_SESSION) tmux.kill_session(LOGIN_SESSION)
message = ( message = (
"timed out waiting for a complete claude login URL — is the 'claude' binary " "timed out waiting for a complete claude login URL — is the 'claude' binary "
"installed in the control container and does '/login' open the subscription flow?" "installed in the control container and does '/login' reach the subscription flow?"
) )
if tail: if tail:
message += f" Last screen:\n{tail}" message += f" Last screen:\n{tail}"
@@ -191,15 +231,19 @@ def start(
def submit_code( def submit_code(
code: str, code: str,
*, *,
settle_wait: float = 2.0,
poll_timeout: float = 40.0, poll_timeout: float = 40.0,
poll_interval: float = 1.0, poll_interval: float = 1.0,
) -> dict: ) -> dict:
"""Feed the pasted authorization ``code`` into the live login session and confirm. """Paste the authorization ``code`` into the live login session and confirm.
Confirms by polling (up to ``poll_timeout`` seconds) for any of: claude's credentials Delivers the code as a paste and presses Enter **separately** after ``settle_wait`` —
file changing on disk (the authoritative signal), a success line in the pane, or the a long code plus an immediate Enter is processed before the paste registers, so nothing
session exiting cleanly. Returns ``{"success": bool, "output": <pane tail>}`` and kills submits (the observed failure). Then polls (up to ``poll_timeout``) for success —
the session on success. Raises :class:`LoginError` if there is no session to submit to. claude's credentials file changing on disk (authoritative), a success line, or the
session exiting — and fails fast on an OAuth-error screen. Returns
``{"success": bool, "output": <pane tail>}`` and kills the session on success. Raises
:class:`LoginError` if there is no session to submit to.
""" """
code = (code or "").strip() code = (code or "").strip()
if not code: if not code:
@@ -208,7 +252,9 @@ def submit_code(
raise LoginError("no active claude login session — start the login flow again") raise LoginError("no active claude login session — start the login flow again")
baseline = _credentials_fingerprint() baseline = _credentials_fingerprint()
tmux.send_keys(LOGIN_SESSION, code) tmux.send_text(LOGIN_SESSION, code) # paste, no Enter
_sleep(settle_wait) # let Ink commit the paste before we submit it
tmux.send_enter(LOGIN_SESSION) # separate Enter — avoids the paste/Enter race
deadline = time.monotonic() + poll_timeout deadline = time.monotonic() + poll_timeout
success = False success = False
@@ -216,16 +262,20 @@ def submit_code(
while time.monotonic() < deadline: while time.monotonic() < deadline:
_sleep(poll_interval) _sleep(poll_interval)
pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) pane = tmux.capture_pane(LOGIN_SESSION, escapes=True)
stripped = _strip_ansi(pane)
if _credentials_fingerprint() != baseline: if _credentials_fingerprint() != baseline:
success = True success = True
break break
if _looks_successful(_strip_ansi(pane)): if _looks_successful(stripped):
success = True success = True
break break
if not tmux.has_session(LOGIN_SESSION): if not tmux.has_session(LOGIN_SESSION):
# claude exited on its own after a successful login. # claude exited on its own after a successful login.
success = True success = True
break break
if _looks_failed(stripped):
# claude rejected the code (expired/invalid); stop waiting and report it.
break
if success and tmux.has_session(LOGIN_SESSION): if success and tmux.has_session(LOGIN_SESSION):
tmux.kill_session(LOGIN_SESSION) tmux.kill_session(LOGIN_SESSION)
@@ -237,6 +287,11 @@ def _looks_successful(pane: str) -> bool:
return any(hint in low for hint in _SUCCESS_HINTS) return any(hint in low for hint in _SUCCESS_HINTS)
def _looks_failed(pane: str) -> bool:
low = (pane or "").lower()
return any(hint in low for hint in _FAILURE_HINTS)
def _tail(pane: str, lines: int = 12) -> str: def _tail(pane: str, lines: int = 12) -> str:
"""The last few non-blank pane lines, for surfacing success/failure in the UI.""" """The last few non-blank pane lines, for surfacing success/failure in the UI."""
kept = [ln for ln in (pane or "").splitlines() if ln.strip()] kept = [ln for ln in (pane or "").splitlines() if ln.strip()]
+22 -1
View File
@@ -75,11 +75,32 @@ def kill_session(name: str) -> None:
def send_keys(name: str, keys: str) -> None: def send_keys(name: str, keys: str) -> None:
"""Send a line of input to a live session (used by the resume seam).""" """Type ``keys`` into a session followed by Enter (used by resume + menu nav)."""
tmux = get_settings().tmux_bin tmux = get_settings().tmux_bin
subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True) subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True)
def send_text(name: str, text: str) -> None:
"""Deliver ``text`` to a session as a bracketed paste, with **no** trailing Enter.
Loads the text into a dedicated tmux buffer and pastes it, so arbitrary content is
delivered verbatim — characters ``send-keys`` would treat as key names are safe, and a
long string can't lose its submit to a race (the classic failure: a code plus an
immediate Enter, where the Enter is processed before the paste registers, so nothing is
submitted). Submit afterwards with :func:`send_enter`.
"""
tmux = get_settings().tmux_bin
buf = "handler-login"
subprocess.run([tmux, "set-buffer", "-b", buf, "--", text], check=True)
subprocess.run([tmux, "paste-buffer", "-b", buf, "-p", "-d", "-t", name], check=True)
def send_enter(name: str) -> None:
"""Send a bare Enter to a session (e.g. submit a previously pasted line / pick a menu)."""
tmux = get_settings().tmux_bin
subprocess.run([tmux, "send-keys", "-t", name, "Enter"], check=True)
def capture_pane(name: str, escapes: bool = False) -> str: def capture_pane(name: str, escapes: bool = False) -> str:
"""Return the visible text of a session's pane. """Return the visible text of a session's pane.
+15 -1
View File
@@ -74,7 +74,13 @@ def auth(env):
@pytest.fixture @pytest.fixture
def fake_tmux(monkeypatch): def fake_tmux(monkeypatch):
"""Record tmux calls instead of spawning; report sessions as live by default.""" """Record tmux calls instead of spawning; report sessions as live by default."""
calls: dict[str, list] = {"new_session": [], "kill_session": [], "send_keys": []} calls: dict[str, list] = {
"new_session": [],
"kill_session": [],
"send_keys": [],
"send_text": [],
"send_enter": [],
}
live: set[str] = set() live: set[str] = set()
from handler.control import tmux from handler.control import tmux
@@ -96,6 +102,12 @@ def fake_tmux(monkeypatch):
def send_keys(name, keys): def send_keys(name, keys):
calls["send_keys"].append({"name": name, "keys": keys}) calls["send_keys"].append({"name": name, "keys": keys})
def send_text(name, text):
calls["send_text"].append({"name": name, "text": text})
def send_enter(name):
calls["send_enter"].append({"name": name})
def list_sessions(): def list_sessions():
return list(live) return list(live)
@@ -103,6 +115,8 @@ def fake_tmux(monkeypatch):
monkeypatch.setattr(tmux, "has_session", has_session) monkeypatch.setattr(tmux, "has_session", has_session)
monkeypatch.setattr(tmux, "kill_session", kill_session) monkeypatch.setattr(tmux, "kill_session", kill_session)
monkeypatch.setattr(tmux, "send_keys", send_keys) monkeypatch.setattr(tmux, "send_keys", send_keys)
monkeypatch.setattr(tmux, "send_text", send_text)
monkeypatch.setattr(tmux, "send_enter", send_enter)
monkeypatch.setattr(tmux, "list_sessions", list_sessions) monkeypatch.setattr(tmux, "list_sessions", list_sessions)
return {"calls": calls, "live": live} return {"calls": calls, "live": live}
+72 -53
View File
@@ -1,9 +1,10 @@
"""The claude web-login seam: driving ``claude /login`` through tmux, scraping the URL, """The claude web-login seam: navigating ``claude`` onboarding to the login URL, then
and confirming the login. pasting the code and confirming the login.
Uses the shared ``fake_tmux`` fixture (extended here with a scripted ``capture_pane``) and Uses the shared ``fake_tmux`` fixture (extended here with a scripted ``capture_pane``) and
patches out the real sleeps + the on-disk credentials check, so no live claude/tmux/FS is patches out the real sleeps + the on-disk credentials check, so no live claude/tmux/FS is
touched — the same approach as the spawn tests. touched — the same approach as the spawn tests. Screen text mirrors the real claude 2.1
TUI captured during development.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,7 +25,7 @@ def stable_creds(monkeypatch):
monkeypatch.setattr(login, "_credentials_fingerprint", lambda: ()) monkeypatch.setattr(login, "_credentials_fingerprint", lambda: ())
def _pane(monkeypatch, *frames): def _panes(monkeypatch, *frames):
"""Make ``capture_pane`` return each frame in turn, then repeat the last one.""" """Make ``capture_pane`` return each frame in turn, then repeat the last one."""
seq = list(frames) seq = list(frames)
@@ -37,10 +38,16 @@ def _pane(monkeypatch, *frames):
# A complete Claude OAuth URL (scheme + client_id + redirect_uri + state) — extraction # A complete Claude OAuth URL (scheme + client_id + redirect_uri + state) — extraction
# deliberately rejects anything less, so the fixtures must use the real shape. # deliberately rejects anything less, so the fixtures must use the real shape.
AUTH_URL = ( AUTH_URL = (
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&response_type=code" "https://claude.com/cai/oauth/authorize?code=true&client_id=abc123&response_type=code"
"&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback" "&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback"
"&scope=user%3Aprofile&code_challenge=chal&code_challenge_method=S256&state=st42" "&scope=user%3Aprofile&code_challenge=chal&code_challenge_method=S256&state=st42"
) )
THEME_SCREEN = "Choose the text style that looks best with your terminal\n 1. Auto\n 2. Dark"
METHOD_SCREEN = "Select login method:\n 1. Claude account with subscription\n 2. Console account"
URL_SCREEN = f"Browser didn't open? Use the url below to sign in (c to copy)\n{AUTH_URL}"
# ---- URL extraction ----
def test_extract_url_prefers_complete_oauth_link(): def test_extract_url_prefers_complete_oauth_link():
@@ -57,81 +64,94 @@ def test_extract_url_none_when_no_link():
def test_extract_url_rejects_incomplete_url(): def test_extract_url_rejects_incomplete_url():
# A garbled/partial capture (dropped scheme char, or no query string) must be refused
# so the iframe never opens a broken page.
assert login._extract_url("ttps://claude.com/cai/oauth/authorize?client_id=x") is None assert login._extract_url("ttps://claude.com/cai/oauth/authorize?client_id=x") is None
assert login._extract_url("https://claude.ai/oauth/authorize") is None assert login._extract_url("https://claude.com/cai/oauth/authorize") is None
def test_extract_url_stops_at_box_border(): def test_extract_url_stops_at_box_border():
# claude may draw the URL inside a rounded box; a "│" flush against the link must not
# be captured as part of the URL.
assert login._extract_url(f"{AUTH_URL}") == AUTH_URL assert login._extract_url(f"{AUTH_URL}") == AUTH_URL
def test_extract_url_recovers_href_from_osc8_hyperlink(): def test_extract_url_recovers_href_from_osc8_hyperlink():
# claude renders the URL as an OSC-8 hyperlink: the visible text can be styled/garbled # claude renders the URL as an OSC-8 hyperlink: the visible text can be styled/garbled
# while the real href sits in the escape. Capturing with escapes lets us recover it. # while the real href sits in the escape. Capturing with escapes lets us recover it.
pane = f"\x1b]8;;{AUTH_URL}\x1b\\click here\x1b]8;;\x1b\\" pane = f"\x1b]8;id=1;{AUTH_URL}\x1b\\click here\x1b]8;;\x1b\\"
assert login._extract_url(pane) == AUTH_URL assert login._extract_url(pane) == AUTH_URL
def test_start_launches_claude_selects_subscription_and_returns_url( # ---- start: onboarding navigation ----
env, fake_tmux, no_sleep, monkeypatch
):
_pane(monkeypatch, "booting…", f"Open this URL to log in:\n{AUTH_URL}")
result = login.start(url_timeout=1.0)
def test_start_navigates_theme_then_method_to_the_url(env, fake_tmux, no_sleep, monkeypatch):
# Fresh claude: theme picker → login-method menu → URL. Each unrecognized-as-URL screen
# gets an Enter; the subscription option is the default so a bare Enter selects it.
_panes(monkeypatch, THEME_SCREEN, METHOD_SCREEN, URL_SCREEN)
result = login.start(url_timeout=5.0)
assert result == {"session": login.LOGIN_SESSION, "url": AUTH_URL} assert result == {"session": login.LOGIN_SESSION, "url": AUTH_URL}
# A fresh claude session was launched… launched = fake_tmux["calls"]["new_session"][0]
launched = fake_tmux["calls"]["new_session"] assert launched["command"] == "claude"
assert len(launched) == 1 assert launched["width"] == login.LOGIN_COLS # wide window, unclipped URL
assert launched[0]["name"] == login.LOGIN_SESSION # Two Enters: accept the theme, then pick subscription. No blind "/login" typed into a
assert launched[0]["command"] == "claude" # menu (that path is only for an already-onboarded REPL).
# A wide window so the long authorization URL isn't clipped at 80 columns. assert len(fake_tmux["calls"]["send_enter"]) == 2
assert launched[0]["width"] == login.LOGIN_COLS assert fake_tmux["calls"]["send_keys"] == []
assert launched[0]["height"] == login.LOGIN_ROWS assert login.LOGIN_SESSION in fake_tmux["live"] # left alive for submit_code
# …then /login was sent, followed by a bare Enter selecting the subscription option.
sent = [c["keys"] for c in fake_tmux["calls"]["send_keys"]]
assert sent[:2] == ["/login", ""] def test_start_sends_login_when_already_onboarded_at_repl(env, fake_tmux, no_sleep, monkeypatch):
# The session is left alive for submit_code. # Already onboarded: no theme/method screen at first — a REPL. We send /login once,
assert login.LOGIN_SESSION in fake_tmux["live"] # which brings up the method menu, then select subscription.
_panes(monkeypatch, "some repl prompt, ? for shortcuts", METHOD_SCREEN, URL_SCREEN)
result = login.start(url_timeout=5.0)
assert result["url"] == AUTH_URL
assert [c["keys"] for c in fake_tmux["calls"]["send_keys"]] == ["/login"]
assert len(fake_tmux["calls"]["send_enter"]) == 1 # subscription pick
def test_start_kills_a_stale_session_first(env, fake_tmux, no_sleep, monkeypatch): def test_start_kills_a_stale_session_first(env, fake_tmux, no_sleep, monkeypatch):
fake_tmux["live"].add(login.LOGIN_SESSION) # a leftover from an abandoned attempt fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, f"{AUTH_URL}") _panes(monkeypatch, URL_SCREEN)
login.start(url_timeout=1.0) login.start(url_timeout=5.0)
assert login.LOGIN_SESSION in fake_tmux["calls"]["kill_session"] assert login.LOGIN_SESSION in fake_tmux["calls"]["kill_session"]
def test_start_times_out_and_cleans_up_when_no_url(env, fake_tmux, no_sleep, monkeypatch): def test_start_times_out_and_cleans_up_when_no_url(env, fake_tmux, no_sleep, monkeypatch):
_pane(monkeypatch, "still thinking, no url yet") _panes(monkeypatch, "still thinking, no url yet")
with pytest.raises(login.LoginError, match="timed out"): with pytest.raises(login.LoginError, match="timed out"):
login.start(url_timeout=0.05, poll_interval=0.0) login.start(url_timeout=0.05, poll_interval=0.0, step_wait=0.0)
# It shouldn't leave a half-driven session lying around.
assert login.LOGIN_SESSION not in fake_tmux["live"] assert login.LOGIN_SESSION not in fake_tmux["live"]
def test_submit_code_confirmed_by_success_text(env, fake_tmux, no_sleep, stable_creds, monkeypatch): # ---- submit: paste + separate Enter, then confirm ----
fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, "Login successful. Welcome back!")
result = login.submit_code("my-auth-code", poll_timeout=1.0)
def test_submit_pastes_code_then_sends_separate_enter(
env, fake_tmux, no_sleep, stable_creds, monkeypatch
):
fake_tmux["live"].add(login.LOGIN_SESSION)
_panes(monkeypatch, "Login successful. Welcome back!")
result = login.submit_code("a-long-authorization-code#state", poll_timeout=1.0)
assert result["success"] is True assert result["success"] is True
assert "Login successful" in result["output"] # The code goes in as a *paste* (send_text), and Enter is a *separate* keystroke — the
assert {"name": login.LOGIN_SESSION, "keys": "my-auth-code"} in fake_tmux["calls"]["send_keys"] # fix for the long-code/Enter race that left the code unsubmitted.
# A confirmed login tears the session down. assert fake_tmux["calls"]["send_text"] == [
assert login.LOGIN_SESSION not in fake_tmux["live"] {"name": login.LOGIN_SESSION, "text": "a-long-authorization-code#state"}
]
assert fake_tmux["calls"]["send_enter"] == [{"name": login.LOGIN_SESSION}]
assert login.LOGIN_SESSION not in fake_tmux["live"] # torn down on success
def test_submit_code_confirmed_by_credentials_file(env, fake_tmux, no_sleep, monkeypatch): def test_submit_confirmed_by_credentials_file(env, fake_tmux, no_sleep, monkeypatch):
fake_tmux["live"].add(login.LOGIN_SESSION) fake_tmux["live"].add(login.LOGIN_SESSION)
# The pane never prints a success string, but claude writes its credentials — the # The pane never prints a success string, but claude writes its credentials — the
# authoritative signal. First call = baseline, later calls = changed. # authoritative signal. First call = baseline, later calls = changed.
@@ -139,10 +159,10 @@ def test_submit_code_confirmed_by_credentials_file(env, fake_tmux, no_sleep, mon
def fingerprint(): def fingerprint():
calls["n"] += 1 calls["n"] += 1
return () if calls["n"] == 1 else (("~/.claude/.credentials.json", 123, 45),) return () if calls["n"] == 1 else (("~/.claude.json", 123, 45),)
monkeypatch.setattr(login, "_credentials_fingerprint", fingerprint) monkeypatch.setattr(login, "_credentials_fingerprint", fingerprint)
_pane(monkeypatch, "still on the paste-code screen, no success text") _panes(monkeypatch, "still on the paste-code screen, no success text")
result = login.submit_code("code", poll_timeout=1.0) result = login.submit_code("code", poll_timeout=1.0)
@@ -150,23 +170,22 @@ def test_submit_code_confirmed_by_credentials_file(env, fake_tmux, no_sleep, mon
assert login.LOGIN_SESSION not in fake_tmux["live"] assert login.LOGIN_SESSION not in fake_tmux["live"]
def test_submit_code_reports_failure_without_killing_session( def test_submit_fails_fast_on_oauth_error(env, fake_tmux, no_sleep, stable_creds, monkeypatch):
env, fake_tmux, no_sleep, stable_creds, monkeypatch
):
fake_tmux["live"].add(login.LOGIN_SESSION) fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, "Invalid code, please try again") _panes(monkeypatch, "OAuth error: Request failed with status code 400\nPress Enter to retry.")
result = login.submit_code("wrong", poll_timeout=0.05) result = login.submit_code("wrong", poll_timeout=5.0)
assert result["success"] is False assert result["success"] is False
assert "OAuth error" in result["output"]
assert login.LOGIN_SESSION in fake_tmux["live"] # left up for a retry assert login.LOGIN_SESSION in fake_tmux["live"] # left up for a retry
def test_submit_code_without_session_raises(env, fake_tmux, no_sleep): def test_submit_without_session_raises(env, fake_tmux, no_sleep):
with pytest.raises(login.LoginError, match="no active"): with pytest.raises(login.LoginError, match="no active"):
login.submit_code("code") login.submit_code("code")
def test_submit_code_rejects_blank(env, fake_tmux, no_sleep): def test_submit_rejects_blank(env, fake_tmux, no_sleep):
with pytest.raises(login.LoginError, match="no authorization code"): with pytest.raises(login.LoginError, match="no authorization code"):
login.submit_code(" ") login.submit_code(" ")