mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
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:
+15
-1
@@ -74,7 +74,13 @@ def auth(env):
|
||||
@pytest.fixture
|
||||
def fake_tmux(monkeypatch):
|
||||
"""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()
|
||||
|
||||
from handler.control import tmux
|
||||
@@ -96,6 +102,12 @@ def fake_tmux(monkeypatch):
|
||||
def send_keys(name, 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():
|
||||
return list(live)
|
||||
|
||||
@@ -103,6 +115,8 @@ def fake_tmux(monkeypatch):
|
||||
monkeypatch.setattr(tmux, "has_session", has_session)
|
||||
monkeypatch.setattr(tmux, "kill_session", kill_session)
|
||||
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)
|
||||
|
||||
return {"calls": calls, "live": live}
|
||||
|
||||
+72
-53
@@ -1,9 +1,10 @@
|
||||
"""The claude web-login seam: driving ``claude /login`` through tmux, scraping the URL,
|
||||
and confirming the login.
|
||||
"""The claude web-login seam: navigating ``claude`` onboarding to the login URL, then
|
||||
pasting the code and confirming the login.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -24,7 +25,7 @@ def stable_creds(monkeypatch):
|
||||
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."""
|
||||
seq = list(frames)
|
||||
|
||||
@@ -37,10 +38,16 @@ def _pane(monkeypatch, *frames):
|
||||
# A complete Claude OAuth URL (scheme + client_id + redirect_uri + state) — extraction
|
||||
# deliberately rejects anything less, so the fixtures must use the real shape.
|
||||
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"
|
||||
"&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():
|
||||
@@ -57,81 +64,94 @@ def test_extract_url_none_when_no_link():
|
||||
|
||||
|
||||
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("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():
|
||||
# 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
|
||||
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
def test_start_launches_claude_selects_subscription_and_returns_url(
|
||||
env, fake_tmux, no_sleep, monkeypatch
|
||||
):
|
||||
_pane(monkeypatch, "booting…", f"Open this URL to log in:\n{AUTH_URL}")
|
||||
# ---- start: onboarding navigation ----
|
||||
|
||||
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}
|
||||
# A fresh claude session was launched…
|
||||
launched = fake_tmux["calls"]["new_session"]
|
||||
assert len(launched) == 1
|
||||
assert launched[0]["name"] == login.LOGIN_SESSION
|
||||
assert launched[0]["command"] == "claude"
|
||||
# A wide window so the long authorization URL isn't clipped at 80 columns.
|
||||
assert launched[0]["width"] == login.LOGIN_COLS
|
||||
assert launched[0]["height"] == login.LOGIN_ROWS
|
||||
# …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", ""]
|
||||
# The session is left alive for submit_code.
|
||||
assert login.LOGIN_SESSION in fake_tmux["live"]
|
||||
launched = fake_tmux["calls"]["new_session"][0]
|
||||
assert launched["command"] == "claude"
|
||||
assert launched["width"] == login.LOGIN_COLS # wide window, unclipped URL
|
||||
# Two Enters: accept the theme, then pick subscription. No blind "/login" typed into a
|
||||
# menu (that path is only for an already-onboarded REPL).
|
||||
assert len(fake_tmux["calls"]["send_enter"]) == 2
|
||||
assert fake_tmux["calls"]["send_keys"] == []
|
||||
assert login.LOGIN_SESSION in fake_tmux["live"] # left alive for submit_code
|
||||
|
||||
|
||||
def test_start_sends_login_when_already_onboarded_at_repl(env, fake_tmux, no_sleep, monkeypatch):
|
||||
# Already onboarded: no theme/method screen at first — a REPL. We send /login once,
|
||||
# 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):
|
||||
fake_tmux["live"].add(login.LOGIN_SESSION) # a leftover from an abandoned attempt
|
||||
_pane(monkeypatch, f"{AUTH_URL}")
|
||||
fake_tmux["live"].add(login.LOGIN_SESSION)
|
||||
_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"]
|
||||
|
||||
|
||||
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"):
|
||||
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"]
|
||||
|
||||
|
||||
def test_submit_code_confirmed_by_success_text(env, fake_tmux, no_sleep, stable_creds, monkeypatch):
|
||||
fake_tmux["live"].add(login.LOGIN_SESSION)
|
||||
_pane(monkeypatch, "Login successful. Welcome back!")
|
||||
# ---- submit: paste + separate Enter, then confirm ----
|
||||
|
||||
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 "Login successful" in result["output"]
|
||||
assert {"name": login.LOGIN_SESSION, "keys": "my-auth-code"} in fake_tmux["calls"]["send_keys"]
|
||||
# A confirmed login tears the session down.
|
||||
assert login.LOGIN_SESSION not in fake_tmux["live"]
|
||||
# The code goes in as a *paste* (send_text), and Enter is a *separate* keystroke — the
|
||||
# fix for the long-code/Enter race that left the code unsubmitted.
|
||||
assert fake_tmux["calls"]["send_text"] == [
|
||||
{"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)
|
||||
# The pane never prints a success string, but claude writes its credentials — the
|
||||
# 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():
|
||||
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)
|
||||
_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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
def test_submit_code_reports_failure_without_killing_session(
|
||||
env, fake_tmux, no_sleep, stable_creds, monkeypatch
|
||||
):
|
||||
def test_submit_fails_fast_on_oauth_error(env, fake_tmux, no_sleep, stable_creds, monkeypatch):
|
||||
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 "OAuth error" in result["output"]
|
||||
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"):
|
||||
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"):
|
||||
login.submit_code(" ")
|
||||
|
||||
Reference in New Issue
Block a user