mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:36:24 +00:00
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:
@@ -32,8 +32,17 @@ from . import tmux
|
|||||||
# One well-known session name: ``login_start`` (re)creates it, ``login_submit`` reuses it.
|
# One well-known session name: ``login_start`` (re)creates it, ``login_submit`` reuses it.
|
||||||
LOGIN_SESSION = "handler__login"
|
LOGIN_SESSION = "handler__login"
|
||||||
|
|
||||||
# Any http(s) URL in the pane; we then prefer the OAuth/authorize link among them.
|
# A very wide, tall detached window so claude prints the (long) authorization URL on a
|
||||||
_URL_RE = re.compile(r"https?://[^\s\"'<>`|]+")
|
# 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")
|
_OAUTH_HINTS = ("oauth", "authorize", "claude.ai", "claude.com", "console.anthropic")
|
||||||
_SUCCESS_HINTS = (
|
_SUCCESS_HINTS = (
|
||||||
"login successful",
|
"login successful",
|
||||||
@@ -85,7 +94,9 @@ def start(
|
|||||||
if tmux.has_session(LOGIN_SESSION):
|
if tmux.has_session(LOGIN_SESSION):
|
||||||
tmux.kill_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
|
_sleep(boot_wait) # let claude boot to its prompt
|
||||||
|
|
||||||
tmux.send_keys(LOGIN_SESSION, "/login")
|
tmux.send_keys(LOGIN_SESSION, "/login")
|
||||||
|
|||||||
@@ -19,14 +19,29 @@ def session_name(project_id: str, agent_name: str) -> str:
|
|||||||
return safe
|
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.
|
"""Launch a detached tmux session running ``command`` in ``cwd`` with ``env`` set.
|
||||||
|
|
||||||
``tmux -e`` sets session environment, so the ``claude`` process (and therefore its
|
``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
|
tmux = get_settings().tmux_bin
|
||||||
argv = [tmux, "new-session", "-d", "-s", name, "-c", cwd]
|
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():
|
for key, value in env.items():
|
||||||
argv += ["-e", f"{key}={value}"]
|
argv += ["-e", f"{key}={value}"]
|
||||||
argv.append(command)
|
argv.append(command)
|
||||||
|
|||||||
+3
-2
@@ -79,9 +79,10 @@ def fake_tmux(monkeypatch):
|
|||||||
|
|
||||||
from handler.control import tmux
|
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(
|
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)
|
live.add(name)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ def test_extract_url_none_when_no_link():
|
|||||||
assert login._extract_url("no link here") is None
|
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(
|
def test_start_launches_claude_selects_subscription_and_returns_url(
|
||||||
env, fake_tmux, no_sleep, monkeypatch
|
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 len(launched) == 1
|
||||||
assert launched[0]["name"] == login.LOGIN_SESSION
|
assert launched[0]["name"] == login.LOGIN_SESSION
|
||||||
assert launched[0]["command"] == "claude"
|
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.
|
# …then /login was sent, followed by a bare Enter selecting the subscription option.
|
||||||
sent = [c["keys"] for c in fake_tmux["calls"]["send_keys"]]
|
sent = [c["keys"] for c in fake_tmux["calls"]["send_keys"]]
|
||||||
assert sent[:2] == ["/login", ""]
|
assert sent[:2] == ["/login", ""]
|
||||||
|
|||||||
Reference in New Issue
Block a user