From 882a0715211fe6f4c509263f4f6931c45a611319 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:19:19 +0000 Subject: [PATCH] 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", ""]