Merge pull request #10 from 0xWheatyz/claude/docker-executables-web-login-ybxp9p

fix(login): make the claude web-login flow actually work
This commit is contained in:
Wyatt
2026-07-15 14:01:38 -04:00
committed by GitHub
12 changed files with 471 additions and 153 deletions
+11 -7
View File
@@ -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
+68 -34
View File
@@ -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<Window | null>(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() {
</div>
) : !awaiting ? (
<div className="hstack" style={{ gap: 10 }}>
<Button variant="primary" disabled={busy} onClick={s.startClaudeLogin}>
<Button variant="primary" disabled={busy} onClick={start}>
{status === "starting" ? "Starting…" : "Log in to Claude"}
</Button>
{status === "error" && (
<Button variant="ghost" disabled={busy} onClick={s.startClaudeLogin}>
<Button variant="ghost" disabled={busy} onClick={start}>
Retry
</Button>
)}
</div>
) : (
<>
<Callout tone="info">
A Claude sign-in window should have opened. Authorize there, copy the code
Claude shows you, and paste it below. If the window didn&apos;t open (popups
blocked), use the button.
</Callout>
<div className="hstack" style={{ gap: 10, flexWrap: "wrap" }}>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Open login page in a new tab
</a>
<Button variant="ghost" disabled={busy} onClick={s.startClaudeLogin}>
<Button
variant="secondary"
disabled={!url}
onClick={() => {
if (url) popupRef.current = openLoginPopup(url);
}}
>
Open Claude sign-in window
</Button>
{url && (
<a className="btn btn-ghost" href={url} target="_blank" rel="noopener noreferrer">
Open in a new tab
</a>
)}
<Button variant="ghost" disabled={busy} onClick={start}>
Restart
</Button>
</div>
<div
style={{
border: "1px solid var(--border-default)",
borderRadius: 10,
overflow: "hidden",
height: 460,
background: "var(--surface-1, #111)",
}}
>
<iframe
title="Claude login"
src={url}
style={{ width: "100%", height: "100%", border: "none" }}
sandbox="allow-forms allow-scripts allow-same-origin allow-popups"
/>
</div>
<div className="faint" style={{ fontSize: "var(--text-xs)" }}>
If the frame stays blank, claude.com is refusing to be embedded use the
new-tab link above instead. The login session stays open until you submit the
code or restart.
</div>
<div className="hstack" style={{ gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
<div style={{ flex: "1 1 320px" }}>
<Input
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-aaee823ffe4c78c3.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-aaee823ffe4c78c3.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"J55muUx2ya8M2SQERVlYt\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/bbd6ee1e7265be74.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Handler · Claude Activity\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Monitor and manage Claude Code agents across projects.\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/icon.svg?bab509b45a421e43\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}]]\n3:null\n"])</script></body></html>
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-5b19e394a5d5460f.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-5b19e394a5d5460f.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"UFaQ4h6X_9jNfr8PwK2IS\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/bbd6ee1e7265be74.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Handler · Claude Activity\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Monitor and manage Claude Code agents across projects.\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/icon.svg?bab509b45a421e43\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}]]\n3:null\n"])</script></body></html>
+2 -2
View File
@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9859,["931","static/chunks/app/page-aaee823ffe4c78c3.js"],"default",1]
3:I[9859,["931","static/chunks/app/page-5b19e394a5d5460f.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
0:["J55muUx2ya8M2SQERVlYt",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
0:["UFaQ4h6X_9jNfr8PwK2IS",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
+202 -49
View File
@@ -4,24 +4,28 @@ 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, waits for claude to exchange it, and reports success.
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
import glob
import os
import re
import time
@@ -32,15 +36,38 @@ 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\"'<>`|]+")
_OAUTH_HINTS = ("oauth", "authorize", "claude.ai", "claude.com", "console.anthropic")
# 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
# 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)
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;;<URL>\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",
)
_FAILURE_HINTS = (
"oauth error",
"press enter to retry",
"invalid code",
"authentication failed",
"login failed",
"code is invalid",
"expired",
)
@@ -57,66 +84,166 @@ 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 (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 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, ".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)
# ---- 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 = 4.0,
menu_wait: float = 1.5,
url_timeout: float = 30.0,
poll_interval: float = 0.5,
boot_wait: float = 6.0,
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 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.
if tmux.has_session(LOGIN_SESSION):
tmux.kill_session(LOGIN_SESSION)
tmux.new_session(LOGIN_SESSION, cwd=_home(), command=claude, env={})
_sleep(boot_wait) # let claude boot to its 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)
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 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:
url = _extract_url(tmux.capture_pane(LOGIN_SESSION))
if url is None:
# Capture with escapes so an OSC-8 hyperlink href is recoverable; require a
# *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 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/blocked state is diagnosable.
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' reach 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,
*,
settle_wait: float = 2.0,
poll_timeout: float = 40.0,
poll_interval: float = 1.0,
) -> dict:
"""Paste the authorization ``code`` into the live login session and confirm.
Returns ``{"success": bool, "output": <pane tail>}``. Kills the session on success.
Raises :class:`LoginError` if there is no active login 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": <pane tail>}`` and kills the session on success. Raises
:class:`LoginError` if there is no session to submit to.
"""
code = (code or "").strip()
if not code:
@@ -124,14 +251,35 @@ 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")
tmux.send_keys(LOGIN_SESSION, code)
_sleep(settle_wait)
baseline = _credentials_fingerprint()
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
pane = ""
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(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
pane = tmux.capture_pane(LOGIN_SESSION)
success = _looks_successful(pane)
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:
@@ -139,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()]
+48 -10
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)
@@ -60,24 +75,47 @@ 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 capture_pane(name: str) -> str:
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.
``-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
+18 -3
View File
@@ -74,14 +74,21 @@ 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
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)
@@ -95,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)
@@ -102,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}
+119 -45
View File
@@ -1,8 +1,10 @@
"""The claude web-login seam: driving ``claude /login`` through tmux and scraping the URL.
"""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, 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. Screen text mirrors the real claude 2.1
TUI captured during development.
"""
from __future__ import annotations
@@ -17,20 +19,38 @@ def no_sleep(monkeypatch):
monkeypatch.setattr(login, "_sleep", lambda *_a, **_k: None)
def _pane(monkeypatch, *frames):
@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 _panes(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.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}"
def test_extract_url_prefers_oauth_link():
# ---- URL extraction ----
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,75 +63,129 @@ def test_extract_url_none_when_no_link():
assert login._extract_url("no link here") is None
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}")
def test_extract_url_rejects_incomplete_url():
assert login._extract_url("ttps://claude.com/cai/oauth/authorize?client_id=x") is None
assert login._extract_url("https://claude.com/cai/oauth/authorize") is None
result = login.start(url_timeout=1.0)
def test_extract_url_stops_at_box_border():
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;id=1;{AUTH_URL}\x1b\\click here\x1b]8;;\x1b\\"
assert login._extract_url(pane) == AUTH_URL
# ---- start: onboarding navigation ----
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"
# …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_sends_code_and_reports_success(env, fake_tmux, no_sleep, monkeypatch):
fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, "Login successful. Welcome back!")
result = login.submit_code("my-auth-code")
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"]
# ---- submit: paste + separate Enter, then confirm ----
def test_submit_code_reports_failure_without_killing_session(
env, fake_tmux, no_sleep, monkeypatch
def test_submit_pastes_code_then_sends_separate_enter(
env, fake_tmux, no_sleep, stable_creds, monkeypatch
):
fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, "Invalid code, please try again")
_panes(monkeypatch, "Login successful. Welcome back!")
result = login.submit_code("wrong")
result = login.submit_code("a-long-authorization-code#state", poll_timeout=1.0)
assert result["success"] is True
# 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_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.json", 123, 45),)
monkeypatch.setattr(login, "_credentials_fingerprint", fingerprint)
_panes(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_fails_fast_on_oauth_error(env, fake_tmux, no_sleep, stable_creds, monkeypatch):
fake_tmux["live"].add(login.LOGIN_SESSION)
_panes(monkeypatch, "OAuth error: Request failed with status code 400\nPress Enter to retry.")
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(" ")