mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 12:26:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(" ")
|
||||
|
||||
Reference in New Issue
Block a user