fix(login): capture the full claude auth URL on a wide tmux window

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
This commit is contained in:
Claude
2026-07-13 19:19:19 +00:00
parent 32327a18c4
commit 882a071521
4 changed files with 55 additions and 7 deletions
+14 -3
View File
@@ -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+2500U+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")
+17 -2
View File
@@ -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)
+3 -2
View File
@@ -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)
+21
View File
@@ -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", ""]