From 882a0715211fe6f4c509263f4f6931c45a611319 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:19:19 +0000 Subject: [PATCH 1/4] fix(login): capture the full claude auth URL on a wide tmux window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing the web login surfaced a truncated authorization URL (…client_id=9d1c250a-e61b-44d9-88) and a "missing redirect_uri" error: the login session ran at the default 80 columns, so claude clipped the long URL and capture-pane read it back cut off. - Launch the login session with a very wide, tall window (500x50) via new optional width/height on tmux.new_session, so claude prints the URL on one unclipped line. - Harden URL extraction to stop at box-drawing glyphs (U+2500–U+257F) in case the TUI renders the link flush against a border. Tests: assert the wide window is requested, and that extraction keeps a full redirect_uri/PKCE URL intact and strips a trailing box border. Suite green (197). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2 --- src/handler/control/login.py | 17 ++++++++++++++--- src/handler/control/tmux.py | 19 +++++++++++++++++-- tests/conftest.py | 5 +++-- tests/test_control_login.py | 21 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/handler/control/login.py b/src/handler/control/login.py index 3b26b86..8e026c2 100644 --- a/src/handler/control/login.py +++ b/src/handler/control/login.py @@ -32,8 +32,17 @@ from . import tmux # One well-known session name: ``login_start`` (re)creates it, ``login_submit`` reuses it. LOGIN_SESSION = "handler__login" -# Any http(s) URL in the pane; we then prefer the OAuth/authorize link among them. -_URL_RE = re.compile(r"https?://[^\s\"'<>`|]+") +# A very wide, tall detached window so claude prints the (long) authorization URL on a +# single unclipped line — at the default 80 columns capture-pane reads it back truncated +# (missing redirect_uri/state), which is the whole point of failure otherwise. +LOGIN_COLS = 500 +LOGIN_ROWS = 50 + +# Any http(s) URL in the pane; we then prefer the OAuth/authorize link among them. The +# character class stops at whitespace, quotes, and — importantly — box-drawing glyphs +# (U+2500–U+257F) the TUI may render flush against the link, so a bordered URL isn't +# captured with a trailing "│". +_URL_RE = re.compile(r"https?://[^\s\"'<>`|─-╿]+") _OAUTH_HINTS = ("oauth", "authorize", "claude.ai", "claude.com", "console.anthropic") _SUCCESS_HINTS = ( "login successful", @@ -85,7 +94,9 @@ def start( if tmux.has_session(LOGIN_SESSION): tmux.kill_session(LOGIN_SESSION) - tmux.new_session(LOGIN_SESSION, cwd=_home(), command=claude, env={}) + tmux.new_session( + LOGIN_SESSION, cwd=_home(), command=claude, env={}, width=LOGIN_COLS, height=LOGIN_ROWS + ) _sleep(boot_wait) # let claude boot to its prompt tmux.send_keys(LOGIN_SESSION, "/login") diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py index 02ad735..5a5f1da 100644 --- a/src/handler/control/tmux.py +++ b/src/handler/control/tmux.py @@ -19,14 +19,29 @@ def session_name(project_id: str, agent_name: str) -> str: return safe -def new_session(name: str, cwd: str, command: str, env: dict[str, str]) -> None: +def new_session( + name: str, + cwd: str, + command: str, + env: dict[str, str], + *, + width: int | None = None, + height: int | None = None, +) -> None: """Launch a detached tmux session running ``command`` in ``cwd`` with ``env`` set. ``tmux -e`` sets session environment, so the ``claude`` process (and therefore its - hooks) inherit the agent identity + ``DATABASE_URL``. + hooks) inherit the agent identity + ``DATABASE_URL``. ``width``/``height`` size the + detached window (``-x``/``-y``); the login flow uses a very wide window so ``claude`` + prints the full authorization URL on one line instead of clipping it at the default + 80 columns (which capture-pane would then read back truncated). """ tmux = get_settings().tmux_bin argv = [tmux, "new-session", "-d", "-s", name, "-c", cwd] + if width is not None: + argv += ["-x", str(width)] + if height is not None: + argv += ["-y", str(height)] for key, value in env.items(): argv += ["-e", f"{key}={value}"] argv.append(command) diff --git a/tests/conftest.py b/tests/conftest.py index 1e76c39..46ec0c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,9 +79,10 @@ def fake_tmux(monkeypatch): from handler.control import tmux - def new_session(name, cwd, command, env): + def new_session(name, cwd, command, env, *, width=None, height=None): calls["new_session"].append( - {"name": name, "cwd": cwd, "command": command, "env": env} + {"name": name, "cwd": cwd, "command": command, "env": env, + "width": width, "height": height} ) live.add(name) diff --git a/tests/test_control_login.py b/tests/test_control_login.py index dc0ae8f..4fb2656 100644 --- a/tests/test_control_login.py +++ b/tests/test_control_login.py @@ -43,6 +43,24 @@ def test_extract_url_none_when_no_link(): assert login._extract_url("no link here") 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_captures_full_long_url_with_redirect_uri(): + # The real login URL carries redirect_uri + PKCE; on a wide pane it arrives intact and + # extraction must not clip it (the truncation-at-80-cols bug was in capture, not here). + long_url = ( + "https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ab-" + "0123456789ab&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com" + "%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile&" + "code_challenge=abcDEF123&code_challenge_method=S256&state=xyz789" + ) + assert login._extract_url(f"Use this URL to sign in:\n{long_url}") == long_url + + def test_start_launches_claude_selects_subscription_and_returns_url( env, fake_tmux, no_sleep, monkeypatch ): @@ -56,6 +74,9 @@ def test_start_launches_claude_selects_subscription_and_returns_url( 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", ""] From f51a49fb982e95d4608bccda717f4ccac30ba8c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:37:20 +0000 Subject: [PATCH 2/4] fix(login): confirm via credentials file + validate the OAuth URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing showed the wide-window fix captured the full URL, but login still failed at submit ("login not confirmed"): the old check snapshotted the pane once after 3s and only matched a few success strings, so an in-progress or differently-worded exchange read as failure. Two hardening changes: - login_submit now polls (up to 40s) and confirms by the authoritative signal — claude's credentials file changing on disk (any of the known locations / ~/.claude/*credential*) — with success-text and clean-exit as fallbacks. - login_start captures with escape sequences (-e) and accepts only a *complete* OAuth URL (https:// + client_id + redirect_uri + state). This recovers the real href when claude renders the link as an OSC-8 hyperlink (whose visible text can be garbled, e.g. the "ttps://claude.com/cai/..." seen in testing) and refuses partial/garbled captures. On timeout the error now includes the actual last screen so a wrong menu/onboarding state is diagnosable. tmux.capture_pane gains an `escapes` flag. Tests cover URL completeness, OSC-8 href recovery, and credentials-file confirmation. Suite green (199). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2 --- src/handler/control/login.py | 149 +++++++++++++++++++++++++++-------- src/handler/control/tmux.py | 16 ++-- tests/test_control_login.py | 74 ++++++++++++----- 3 files changed, 181 insertions(+), 58 deletions(-) diff --git a/src/handler/control/login.py b/src/handler/control/login.py index 8e026c2..008dde3 100644 --- a/src/handler/control/login.py +++ b/src/handler/control/login.py @@ -9,7 +9,8 @@ handoff: the pane for the ``claude.com`` / ``claude.ai`` authorization URL. The URL is returned to the UI (which opens it in an iframe) and the tmux session is *left alive*. 2. ``login_submit`` sends the authorization code the operator pastes back into that same - still-alive session, waits for claude to exchange it, and reports success. + still-alive session, then confirms the login by watching for claude to write its + credentials file (with a success-text fallback). 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 @@ -22,6 +23,7 @@ onboarding (theme/trust prompts) before the ``/login`` menu, bump ``boot_wait``. from __future__ import annotations +import glob import os import re import time @@ -38,18 +40,23 @@ LOGIN_SESSION = "handler__login" LOGIN_COLS = 500 LOGIN_ROWS = 50 -# Any http(s) URL in the pane; we then prefer the OAuth/authorize link among them. The -# character class stops at whitespace, quotes, and — importantly — box-drawing glyphs -# (U+2500–U+257F) the TUI may render flush against the link, so a bordered URL isn't -# captured with a trailing "│". -_URL_RE = re.compile(r"https?://[^\s\"'<>`|─-╿]+") -_OAUTH_HINTS = ("oauth", "authorize", "claude.ai", "claude.com", "console.anthropic") +# Strip ANSI CSI + OSC escape sequences so success-text matching sees plain text. +_ANSI_RE = re.compile( + r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI (colors, cursor moves) + r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC (…terminated by BEL or ST) + r"|\x1b[@-Z\\-_]" # two-char escapes +) +# An http(s) URL. The class excludes whitespace, quotes, box-drawing glyphs the TUI may +# render flush against the link, *and* control/escape bytes — so a URL sitting inside an +# OSC-8 hyperlink escape (``\x1b]8;;\x1b\\``) is recovered cleanly, cut at the ESC. +_URL_RE = re.compile(r"https?://[^\s\"'<>`|\x00-\x1f─-╿]+") _SUCCESS_HINTS = ( "login successful", "logged in", "successfully authenticated", "authentication successful", "you are now logged in", + "welcome back", ) @@ -66,28 +73,76 @@ def _sleep(seconds: float) -> None: time.sleep(seconds) +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text or "") + + +def _is_complete_oauth_url(url: str) -> bool: + """A *usable* Claude OAuth URL, not a partial/garbled capture. + + Requiring the scheme + the OAuth query markers rejects a mid-render capture like + ``ttps://claude.com/cai/oauth?…`` (dropped scheme chars) or a URL cut before its + query string — handing either to the iframe would send the operator to a broken page. + """ + low = url.lower() + return ( + low.startswith("https://") + and "oauth" in low + and "client_id=" in low + and "redirect_uri=" in low + and "state=" in low + ) + + def _extract_url(pane: str) -> str | None: - """Pull the login URL out of a captured pane, preferring the OAuth link.""" + """Return the first *complete* OAuth URL found in a captured pane, else ``None``.""" if not pane: return None - candidates = [c.rstrip(".,);]") for c in _URL_RE.findall(pane)] - for c in candidates: - if any(hint in c.lower() for hint in _OAUTH_HINTS): - return c - return candidates[0] if candidates else None + for raw in _URL_RE.findall(pane): + candidate = raw.rstrip(".,);]}>") + if _is_complete_oauth_url(candidate): + return candidate + return None + + +def _credentials_fingerprint() -> tuple: + """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 + drifted across versions, so we watch every likely location and any ``*credential*`` + file under ``~/.claude``. The fingerprint is ``(path, mtime, size)`` tuples — it + changes when a login creates or rewrites the credentials, which is a far more reliable + "did it work" signal than scraping the TUI for a success string. + """ + home = _home() + paths = { + 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"), + } + paths.update(glob.glob(os.path.join(home, ".claude", "*credential*"))) + fp = [] + for p in sorted(paths): + try: + st = os.stat(p) + fp.append((p, st.st_mtime_ns, st.st_size)) + except OSError: + continue + return tuple(fp) def start( *, - boot_wait: float = 4.0, - menu_wait: float = 1.5, - url_timeout: float = 30.0, + boot_wait: float = 6.0, + menu_wait: float = 2.0, + url_timeout: float = 45.0, poll_interval: float = 0.5, ) -> dict: """Open ``claude`` in tmux, drive ``/login`` to the subscription account, return the URL. Leaves the tmux session alive for :func:`submit_code`. Raises :class:`LoginError` if - no authorization URL appears within ``url_timeout`` seconds. + no complete authorization URL appears within ``url_timeout`` seconds. """ claude = get_settings().claude_bin # A stale session from a previous, abandoned attempt would swallow our keystrokes. @@ -97,7 +152,7 @@ def start( tmux.new_session( LOGIN_SESSION, cwd=_home(), command=claude, env={}, width=LOGIN_COLS, height=LOGIN_ROWS ) - _sleep(boot_wait) # let claude boot to its prompt + _sleep(boot_wait) # let claude finish its splash/boot and reach a prompt tmux.send_keys(LOGIN_SESSION, "/login") _sleep(menu_wait) @@ -108,26 +163,43 @@ def start( deadline = time.monotonic() + url_timeout url: str | None = None + last_pane = "" while url is None and time.monotonic() < deadline: - url = _extract_url(tmux.capture_pane(LOGIN_SESSION)) + # 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 + # a garbled fragment. + last_pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) + url = _extract_url(last_pane) if url is None: _sleep(poll_interval) if url is None: - # Don't leave a half-driven session lying around on failure. + # Don't leave a half-driven session lying around on failure. Surface what claude + # actually rendered so a wrong menu/onboarding state is diagnosable, not opaque. + tail = _tail(_strip_ansi(last_pane)) if tmux.has_session(LOGIN_SESSION): tmux.kill_session(LOGIN_SESSION) - raise LoginError( - "timed out waiting for the claude login URL — is the 'claude' binary installed " - "in the control container and does '/login' open the subscription flow?" + message = ( + "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?" ) + if tail: + message += f" Last screen:\n{tail}" + raise LoginError(message) return {"session": LOGIN_SESSION, "url": url} -def submit_code(code: str, *, settle_wait: float = 3.0) -> dict: - """Feed the pasted authorization ``code`` into the live login session. +def submit_code( + code: str, + *, + poll_timeout: float = 40.0, + poll_interval: float = 1.0, +) -> dict: + """Feed the pasted authorization ``code`` into the live login session and confirm. - Returns ``{"success": bool, "output": }``. Kills the session on success. - Raises :class:`LoginError` if there is no active login session to submit to. + Confirms by polling (up to ``poll_timeout`` seconds) for any of: claude's credentials + file changing on disk (the authoritative signal), a success line in the pane, or the + session exiting cleanly. Returns ``{"success": bool, "output": }`` and kills + the session on success. Raises :class:`LoginError` if there is no session to submit to. """ code = (code or "").strip() if not code: @@ -135,14 +207,29 @@ def submit_code(code: str, *, settle_wait: float = 3.0) -> dict: if not tmux.has_session(LOGIN_SESSION): raise LoginError("no active claude login session — start the login flow again") + baseline = _credentials_fingerprint() tmux.send_keys(LOGIN_SESSION, code) - _sleep(settle_wait) - pane = tmux.capture_pane(LOGIN_SESSION) - success = _looks_successful(pane) + deadline = time.monotonic() + poll_timeout + success = False + pane = "" + while time.monotonic() < deadline: + _sleep(poll_interval) + pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) + if _credentials_fingerprint() != baseline: + success = True + break + if _looks_successful(_strip_ansi(pane)): + success = True + break + if not tmux.has_session(LOGIN_SESSION): + # claude exited on its own after a successful login. + success = True + break + if success and tmux.has_session(LOGIN_SESSION): tmux.kill_session(LOGIN_SESSION) - return {"success": success, "output": _tail(pane)} + return {"success": success, "output": _tail(_strip_ansi(pane))} def _looks_successful(pane: str) -> bool: diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py index 5a5f1da..f308c11 100644 --- a/src/handler/control/tmux.py +++ b/src/handler/control/tmux.py @@ -80,19 +80,21 @@ def send_keys(name: str, keys: str) -> None: subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True) -def capture_pane(name: str) -> str: +def capture_pane(name: str, escapes: bool = False) -> str: """Return the visible text of a session's pane. ``-p`` prints to stdout, ``-J`` joins wrapped lines so a long URL split across the pane width comes back on one logical line (the login flow relies on this to recover - the claude.com authorization link). Returns an empty string if the session is gone. + the claude.com authorization link). ``escapes=True`` adds ``-e`` to keep ANSI/OSC + escape sequences — the login URL extractor uses this so it can also recover a URL that + the TUI renders as an OSC-8 hyperlink (where the visible text differs from the href). + Returns an empty string if the session is gone. """ tmux = get_settings().tmux_bin - result = subprocess.run( - [tmux, "capture-pane", "-t", name, "-p", "-J"], - capture_output=True, - text=True, - ) + argv = [tmux, "capture-pane", "-t", name, "-p", "-J"] + if escapes: + argv.append("-e") + result = subprocess.run(argv, capture_output=True, text=True) if result.returncode != 0: return "" return result.stdout diff --git a/tests/test_control_login.py b/tests/test_control_login.py index 4fb2656..dae0f38 100644 --- a/tests/test_control_login.py +++ b/tests/test_control_login.py @@ -1,8 +1,9 @@ -"""The claude web-login seam: driving ``claude /login`` through tmux and scraping the URL. +"""The claude web-login seam: driving ``claude /login`` through tmux, scraping the URL, +and confirming the login. Uses the shared ``fake_tmux`` fixture (extended here with a scripted ``capture_pane``) and -patches out the real sleeps, so no live claude/tmux is touched — the same approach as the -spawn tests. +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. """ from __future__ import annotations @@ -17,20 +18,32 @@ def no_sleep(monkeypatch): monkeypatch.setattr(login, "_sleep", lambda *_a, **_k: None) +@pytest.fixture +def stable_creds(monkeypatch): + """No credentials change on disk — success must come from the pane/session signals.""" + monkeypatch.setattr(login, "_credentials_fingerprint", lambda: ()) + + def _pane(monkeypatch, *frames): """Make ``capture_pane`` return each frame in turn, then repeat the last one.""" seq = list(frames) - def capture(_name): + def capture(_name, escapes=False): return seq[0] if len(seq) == 1 else seq.pop(0) monkeypatch.setattr(tmux, "capture_pane", capture) -AUTH_URL = "https://claude.ai/oauth/authorize?code=true&client_id=abc&state=xyz" +# 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" + "&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback" + "&scope=user%3Aprofile&code_challenge=chal&code_challenge_method=S256&state=st42" +) -def test_extract_url_prefers_oauth_link(): +def test_extract_url_prefers_complete_oauth_link(): pane = f"Visit https://example.com/help or\n{AUTH_URL}\nand paste the code." assert login._extract_url(pane) == AUTH_URL @@ -43,22 +56,24 @@ def test_extract_url_none_when_no_link(): assert login._extract_url("no link here") is None +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 + + 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_captures_full_long_url_with_redirect_uri(): - # The real login URL carries redirect_uri + PKCE; on a wide pane it arrives intact and - # extraction must not clip it (the truncation-at-80-cols bug was in capture, not here). - long_url = ( - "https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ab-" - "0123456789ab&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com" - "%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile&" - "code_challenge=abcDEF123&code_challenge_method=S256&state=xyz789" - ) - assert login._extract_url(f"Use this URL to sign in:\n{long_url}") == long_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\\" + assert login._extract_url(pane) == AUTH_URL def test_start_launches_claude_selects_subscription_and_returns_url( @@ -103,11 +118,11 @@ def test_start_times_out_and_cleans_up_when_no_url(env, fake_tmux, no_sleep, mon assert login.LOGIN_SESSION not in fake_tmux["live"] -def test_submit_code_sends_code_and_reports_success(env, fake_tmux, no_sleep, monkeypatch): +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!") - result = login.submit_code("my-auth-code") + result = login.submit_code("my-auth-code", poll_timeout=1.0) assert result["success"] is True assert "Login successful" in result["output"] @@ -116,13 +131,32 @@ def test_submit_code_sends_code_and_reports_success(env, fake_tmux, no_sleep, mo assert login.LOGIN_SESSION not in fake_tmux["live"] +def test_submit_code_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. + calls = {"n": 0} + + def fingerprint(): + calls["n"] += 1 + return () if calls["n"] == 1 else (("~/.claude/.credentials.json", 123, 45),) + + monkeypatch.setattr(login, "_credentials_fingerprint", fingerprint) + _pane(monkeypatch, "still on the paste-code screen, no success text") + + result = login.submit_code("code", poll_timeout=1.0) + + assert result["success"] is True + assert login.LOGIN_SESSION not in fake_tmux["live"] + + def test_submit_code_reports_failure_without_killing_session( - env, fake_tmux, no_sleep, monkeypatch + env, fake_tmux, no_sleep, stable_creds, monkeypatch ): fake_tmux["live"].add(login.LOGIN_SESSION) _pane(monkeypatch, "Invalid code, please try again") - result = login.submit_code("wrong") + result = login.submit_code("wrong", poll_timeout=0.05) assert result["success"] is False assert login.LOGIN_SESSION in fake_tmux["live"] # left up for a retry From 1fe260ebe4cb8940e78ab4119b5825a5f09691be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:34:43 +0000 Subject: [PATCH 3/4] fix(login): submit via paste+separate Enter and navigate onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2 --- src/handler/control/login.py | 149 ++++++++++++++++++++++++----------- src/handler/control/tmux.py | 23 +++++- tests/conftest.py | 16 +++- tests/test_control_login.py | 125 ++++++++++++++++------------- 4 files changed, 211 insertions(+), 102 deletions(-) diff --git a/src/handler/control/login.py b/src/handler/control/login.py index 008dde3..4592c40 100644 --- a/src/handler/control/login.py +++ b/src/handler/control/login.py @@ -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 handoff: -1. ``login_start`` opens an interactive ``claude`` session in a dedicated tmux window, - sends ``/login``, selects the **Claude account with subscription** option, and scrapes - the pane for the ``claude.com`` / ``claude.ai`` authorization URL. The URL is returned - to the UI (which opens it in an iframe) and the tmux session is *left alive*. -2. ``login_submit`` sends the authorization code the operator pastes back into that same - still-alive session, then confirms the login by watching for claude to write its - credentials file (with a success-text fallback). +1. ``login_start`` opens an interactive ``claude`` session in a dedicated tmux window and + navigates to the **Claude account with subscription** login, driving whatever screens a + fresh claude shows first (theme picker, folder-trust, the login-method menu) until the + ``claude.com`` authorization URL appears. That URL is returned to the UI (which opens it + in an iframe) and the tmux session is *left alive*. +2. ``login_submit`` pastes the authorization code the operator copies back, then presses + 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 unit-testable with a fake tmux and never needs a real ``claude`` binary — the same pattern the spawn/resume tests use. -The interactive claude TUI is inherently timing-sensitive; the waits below are generous -and overridable so an operator can tune them for a slow host. If claude's first run shows -onboarding (theme/trust prompts) before the ``/login`` menu, bump ``boot_wait``. +The interactive claude TUI is timing-sensitive; the waits below are generous and +overridable. The navigation is screen-driven (it reads the pane and reacts) rather than a +fixed key sequence, so it survives a fresh-onboarding claude *and* an already-logged-out +one sitting at the REPL. """ from __future__ import annotations @@ -40,7 +42,7 @@ LOGIN_SESSION = "handler__login" LOGIN_COLS = 500 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( r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI (colors, cursor moves) r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC (…terminated by BEL or ST) @@ -58,6 +60,15 @@ _SUCCESS_HINTS = ( "you are now logged in", "welcome back", ) +_FAILURE_HINTS = ( + "oauth error", + "press enter to retry", + "invalid code", + "authentication failed", + "login failed", + "code is invalid", + "expired", +) class LoginError(Exception): @@ -80,9 +91,9 @@ def _strip_ansi(text: str) -> str: def _is_complete_oauth_url(url: str) -> bool: """A *usable* Claude OAuth URL, not a partial/garbled capture. - Requiring the scheme + the OAuth query markers rejects a mid-render capture like - ``ttps://claude.com/cai/oauth?…`` (dropped scheme chars) or a URL cut before its - query string — handing either to the iframe would send the operator to a broken page. + Requiring the scheme + the OAuth query markers rejects a mid-render capture (dropped + scheme chars, or a URL cut before its query string) — handing either to the iframe + would send the operator to a broken page. """ low = url.lower() return ( @@ -108,17 +119,17 @@ def _extract_url(pane: str) -> str | None: def _credentials_fingerprint() -> tuple: """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 - drifted across versions, so we watch every likely location and any ``*credential*`` - file under ``~/.claude``. The fingerprint is ``(path, mtime, size)`` tuples — it - changes when a login creates or rewrites the credentials, which is a far more reliable - "did it work" signal than scraping the TUI for a success string. + Claude Code stores its OAuth account/token under the user's home — on Linux in + ``~/.claude.json`` (and/or ``~/.claude/.credentials.json``); the exact filename has + drifted across versions, so we watch every likely location. The fingerprint is + ``(path, mtime_ns, size)`` tuples — it changes when a login writes the credentials, + a far more reliable "did it work" signal than scraping the TUI for a success string. """ home = _home() 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.json"), os.path.join(home, ".config", "claude", "credentials.json"), } paths.update(glob.glob(os.path.join(home, ".claude", "*credential*"))) @@ -132,17 +143,39 @@ def _credentials_fingerprint() -> tuple: 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( *, boot_wait: float = 6.0, - menu_wait: float = 2.0, - url_timeout: float = 45.0, - poll_interval: float = 0.5, + url_timeout: float = 60.0, + step_wait: float = 1.5, + poll_interval: float = 1.0, ) -> 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 - no complete authorization URL appears within ``url_timeout`` seconds. + Reads the pane each pass and reacts — accepts the theme picker, a folder-trust prompt, + 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 # A stale session from a previous, abandoned attempt would swallow our keystrokes. @@ -152,35 +185,42 @@ def start( tmux.new_session( 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 - - 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) + _sleep(boot_wait) # let claude finish its splash/boot and reach the first screen deadline = time.monotonic() + url_timeout + tried_login = False url: str | None = None last_pane = "" while url is None and time.monotonic() < deadline: # 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 - # a garbled fragment. + # *complete* URL so a still-rendering pane keeps us polling for a clean one. last_pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) 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) + continue + _sleep(step_wait) + if url is None: - # Don't leave a half-driven session lying around on failure. Surface what claude - # actually rendered so a wrong menu/onboarding state is diagnosable, not opaque. + # Surface what claude actually rendered so a wrong/blocked state is diagnosable. tail = _tail(_strip_ansi(last_pane)) if tmux.has_session(LOGIN_SESSION): tmux.kill_session(LOGIN_SESSION) message = ( "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: message += f" Last screen:\n{tail}" @@ -191,15 +231,19 @@ def start( def submit_code( code: str, *, + settle_wait: float = 2.0, poll_timeout: float = 40.0, poll_interval: float = 1.0, ) -> 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 - file changing on disk (the authoritative signal), a success line in the pane, or the - session exiting cleanly. Returns ``{"success": bool, "output": }`` and kills - the session on success. Raises :class:`LoginError` if there is no session to submit to. + Delivers the code as a paste and presses Enter **separately** after ``settle_wait`` — + a long code plus an immediate Enter is processed before the paste registers, so nothing + submits (the observed failure). Then polls (up to ``poll_timeout``) for success — + 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": }`` and kills the session on success. Raises + :class:`LoginError` if there is no session to submit to. """ code = (code or "").strip() if not code: @@ -208,7 +252,9 @@ def submit_code( raise LoginError("no active claude login session — start the login flow again") 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 success = False @@ -216,16 +262,20 @@ def submit_code( while time.monotonic() < deadline: _sleep(poll_interval) pane = tmux.capture_pane(LOGIN_SESSION, escapes=True) + stripped = _strip_ansi(pane) if _credentials_fingerprint() != baseline: success = True break - if _looks_successful(_strip_ansi(pane)): + if _looks_successful(stripped): success = True break if not tmux.has_session(LOGIN_SESSION): # claude exited on its own after a successful login. success = True 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): 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) +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: """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()] diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py index f308c11..a29fb9d 100644 --- a/src/handler/control/tmux.py +++ b/src/handler/control/tmux.py @@ -75,11 +75,32 @@ def kill_session(name: 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 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: """Return the visible text of a session's pane. diff --git a/tests/conftest.py b/tests/conftest.py index 46ec0c1..bf3fdb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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} diff --git a/tests/test_control_login.py b/tests/test_control_login.py index dae0f38..7e732b5 100644 --- a/tests/test_control_login.py +++ b/tests/test_control_login.py @@ -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(" ") From cd49dab5a3a4cbea6c1f47cb6bdc87b75a0bd974 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:09:30 +0000 Subject: [PATCH 4/4] feat(login): open the login URL in an OAuth-style popup, not an iframe claude.com refuses to be embedded in an iframe (X-Frame-Options), so the inline frame just showed a blocked page. Replace it with a small popup window, like a "Sign in with Google" flow: the "Log in to Claude" click opens a blank popup (within the user gesture, so it isn't popup-blocked) and, once login_start returns the URL, the popup is navigated to it. Buttons to reopen the window or open the URL in a new tab remain as fallbacks, and the popup is closed on success/error. README updated to match. Rebuilt static export. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2 --- README.md | 18 ++-- frontend/components/sections/LoginSection.tsx | 102 ++++++++++++------ src/handler/api/static/404.html | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 ...3ffe4c78c3.js => page-5b19e394a5d5460f.js} | 2 +- src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 4 +- 8 files changed, 84 insertions(+), 46 deletions(-) rename src/handler/api/static/_next/static/{J55muUx2ya8M2SQERVlYt => UFaQ4h6X_9jNfr8PwK2IS}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{J55muUx2ya8M2SQERVlYt => UFaQ4h6X_9jNfr8PwK2IS}/_ssgManifest.js (100%) rename src/handler/api/static/_next/static/chunks/app/{page-aaee823ffe4c78c3.js => page-5b19e394a5d5460f.js} (77%) diff --git a/README.md b/README.md index 0fe4532..51d3321 100644 --- a/README.md +++ b/README.md @@ -243,13 +243,17 @@ pane logs it in from the browser — the same command-queue handoff every other action uses: 1. **Log in to Claude** enqueues a `login_start` command. The worker opens `claude` in a - dedicated tmux session in the control container, sends `/login`, selects the **Claude - account with subscription** option, and scrapes the pane for the `claude.com` - authorization URL — returned in the command result. -2. The UI opens that URL in an embedded frame (with a new-tab link as a fallback, since - claude.com may refuse to be framed). You authorize and Claude gives you a code. -3. **Finish login** enqueues a `login_submit` command carrying the code; the worker feeds - it into the still-open session, waits for claude to exchange it, and reports success. + dedicated (wide) tmux session in the control container, navigates whatever onboarding a + fresh `claude` shows (theme picker, folder-trust) to the **Claude account with + subscription** login, and scrapes the pane for the `claude.com` authorization URL — + returned in the command result. +2. The UI opens that URL in a small **OAuth-style popup window** (like "Sign in with …"; + claude.com refuses to be embedded in an iframe, so a popup is the right surface), with + a new-tab link as a fallback. You authorize there and Claude gives you a code. +3. **Finish login** enqueues a `login_submit` command carrying the code; the worker pastes + it into the still-open session and presses Enter separately (a long code plus an + immediate Enter races the TUI and never submits), then confirms by watching claude write + its credentials. The login session lives in the control container, and Claude's credentials land under the `handler` user's home on the `/var/lib/handler` volume — so the login **persists** across diff --git a/frontend/components/sections/LoginSection.tsx b/frontend/components/sections/LoginSection.tsx index 8dfc582..f6c1ae4 100644 --- a/frontend/components/sections/LoginSection.tsx +++ b/frontend/components/sections/LoginSection.tsx @@ -1,24 +1,64 @@ /* Claude Login — drive the bundled `claude /login` OAuth flow on the host from the web UI. * - * Click "Log in to Claude" → the worker opens `claude /login` in the control container, - * selects the subscription account, and returns the claude.com authorization URL. That URL - * is shown in an embedded frame (and as a new-tab link, since claude.com may refuse to be - * framed); after authorizing, paste the code back to finish. All state lives in the store's - * `claudeLogin` machine (login_start / login_submit commands). */ + * Click "Log in to Claude" → a small OAuth-style popup window opens (like "Sign in with + * Google") and the worker drives `claude /login` in the control container, selecting the + * subscription account and returning the claude.com authorization URL, which we point the + * popup at. (claude.com refuses to be embedded in an iframe, so a popup — not an inline + * frame — is the right surface.) You authorize there, copy the code, and paste it back to + * finish. All state lives in the store's `claudeLogin` machine (login_start / login_submit + * commands). */ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDashboard } from "@/components/store"; import { Button, Callout, Input } from "@/components/ui"; +function openLoginPopup(url: string): Window | null { + const w = 520; + const h = 760; + // Center over the current window; specifying a size makes browsers open a popup window + // (the "Sign in with …" surface) rather than a new tab. + const left = window.screenX + Math.max(0, (window.outerWidth - w) / 2); + const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2); + return window.open( + url, + "claude-login", + `popup=yes,width=${w},height=${h},left=${Math.round(left)},top=${Math.round(top)}`, + ); +} + export function LoginSection() { const s = useDashboard(); const { status, url, message } = s.claudeLogin; const [code, setCode] = useState(""); + const popupRef = useRef(null); const busy = status === "starting" || status === "submitting"; const awaiting = status === "awaiting" || status === "submitting"; + // Open a blank popup *within the click* (below) so browsers don't block it; once + // login_start returns the URL, navigate that same popup to it. + useEffect(() => { + if (status === "awaiting" && url && popupRef.current && !popupRef.current.closed) { + try { + popupRef.current.location.href = url; + } catch { + /* cross-origin after navigation — expected, ignore */ + } + } + if (status === "done" || status === "error") { + popupRef.current?.close(); + popupRef.current = null; + } + }, [status, url]); + + const start = () => { + // Open the popup now, on the user gesture, to a lightweight loading page; the effect + // above redirects it to the real URL when it arrives. + popupRef.current = openLoginPopup("about:blank"); + void s.startClaudeLogin(); + }; + const submit = async () => { const ok = await s.submitClaudeCode(code); if (ok) setCode(""); @@ -50,48 +90,42 @@ export function LoginSection() { ) : !awaiting ? (
- {status === "error" && ( - )}
) : ( <> + + A Claude sign-in window should have opened. Authorize there, copy the code + Claude shows you, and paste it below. If the window didn't open (popups + blocked), use the button. +
- - Open login page in a new tab ↗ - - + {url && ( + + Open in a new tab + + )} +
-
-