feat: bundle agent executables + web-driven claude login

Two changes so an operator can stand up and authenticate Handler entirely
from the browser, with a self-contained control image.

Bundle executables in the control image (Dockerfile.control)
- Node.js (NodeSource) + the Claude Code CLI, mise (official apt repo), and
  forge (git-pkgs/forge, built in a Go stage) join the existing git/tmux/ssh.
  No more bring-your-own binaries: live agent spawning, the verification gate,
  CI resolution, and the login flow all work out of the box. Installed under
  /usr so the /var/lib/handler VOLUME never masks them; mise apt source pinned
  to $TARGETARCH for the multi-arch (amd64/arm64) build.

Claude login from the web UI
- New login_start / login_submit command types (migration 0005) drive the
  interactive `claude /login` through the same enqueue→worker handoff every
  other control action uses — the API container has no claude binary.
- control/login.py opens `claude` in a dedicated tmux session, sends /login,
  selects the subscription account, and scrapes the claude.com authorization
  URL (tmux.capture_pane, -pJ so a wrapped URL rejoins); a second command feeds
  back the pasted code. Fully mockable via the tmux seam.
- API: POST /login/start, POST /login/submit (admin-gated).
- Dashboard: a "Claude Login" pane — a button that starts the flow, embeds the
  URL in an iframe (with a new-tab fallback, since claude.com may refuse
  framing), and takes the code to finish.

Also un-ignores frontend/lib/ (a broad Python `lib/` rule was swallowing the
UI's own api client + formatters, breaking rebuilds from a fresh clone) and
reconstructs those two source files; rebuilt static export committed.

Tests: control/login unit tests (tmux faked), worker dispatch, and API route
tests. Full suite green (195 tests), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
This commit is contained in:
Claude
2026-07-13 17:45:52 +00:00
parent 5c68c8d47b
commit fa2e97130d
27 changed files with 1145 additions and 24 deletions
+9
View File
@@ -12,6 +12,7 @@ import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
import { GitServersSection } from "@/components/sections/GitServersSection";
import { ActivitySection } from "@/components/sections/ActivitySection";
import { SharedSection } from "@/components/sections/SharedSection";
import { LoginSection } from "@/components/sections/LoginSection";
interface NavDef {
key: Section;
@@ -34,6 +35,13 @@ const NAV: NavDef[] = [
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
{
key: "login",
label: "Claude Login",
count: () => 0,
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
];
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
@@ -97,6 +105,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
{s.section === "servers" && <GitServersSection />}
{s.section === "activity" && <ActivitySection />}
{s.section === "shared" && <SharedSection />}
{s.section === "login" && <LoginSection />}
</div>
)}
</main>
@@ -0,0 +1,118 @@
/* 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). */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import { Button, Callout, Input } from "@/components/ui";
export function LoginSection() {
const s = useDashboard();
const { status, url, message } = s.claudeLogin;
const [code, setCode] = useState("");
const busy = status === "starting" || status === "submitting";
const awaiting = status === "awaiting" || status === "submitting";
const submit = async () => {
const ok = await s.submitClaudeCode(code);
if (ok) setCode("");
};
return (
<>
<div className="section-head">
<div className="section-title">Claude Login</div>
<div className="section-desc">
Log Claude Code in on the host so agents can run. This drives{" "}
<span className="mono">claude /login</span> in the control container and picks the
Claude account with a subscription.
</div>
</div>
<div className="section-body" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{message && (
<Callout tone={status === "error" ? "danger" : status === "done" ? "success" : "info"}>
{message}
</Callout>
)}
{status === "done" ? (
<div>
<Button variant="secondary" onClick={s.resetClaudeLogin}>
Log in again
</Button>
</div>
) : !awaiting ? (
<div className="hstack" style={{ gap: 10 }}>
<Button variant="primary" disabled={busy} onClick={s.startClaudeLogin}>
{status === "starting" ? "Starting…" : "Log in to Claude"}
</Button>
{status === "error" && (
<Button variant="ghost" disabled={busy} onClick={s.startClaudeLogin}>
Retry
</Button>
)}
</div>
) : (
<>
<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}>
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
label="Authorization code"
value={code}
onChange={setCode}
placeholder="Paste the code from claude.com"
disabled={status === "submitting"}
/>
</div>
<Button
variant="primary"
disabled={status === "submitting" || !code.trim()}
onClick={submit}
>
{status === "submitting" ? "Submitting…" : "Finish login"}
</Button>
</div>
</>
)}
</div>
</>
);
}
+116 -1
View File
@@ -36,7 +36,24 @@ export type Section =
| "approvals"
| "servers"
| "activity"
| "shared";
| "shared"
| "login";
/* The claude web-login flow, driven through the login_start / login_submit commands.
* idle → starting → awaiting (have URL) → submitting → done | error */
export type ClaudeLoginStatus =
| "idle"
| "starting"
| "awaiting"
| "submitting"
| "done"
| "error";
export interface ClaudeLoginState {
status: ClaudeLoginStatus;
url: string;
message: string;
}
export interface RunAgent extends Agent {}
@@ -97,6 +114,12 @@ interface StoreValue {
deleteSchedule: (id: number) => Promise<void>;
pollCi: () => Promise<void>;
setSharedKey: (key: string, value: string) => Promise<boolean>;
// Claude web-login
claudeLogin: ClaudeLoginState;
startClaudeLogin: () => Promise<void>;
submitClaudeCode: (code: string) => Promise<boolean>;
resetClaudeLogin: () => void;
}
export interface SpawnBody {
@@ -189,6 +212,11 @@ export function DashboardProvider({
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
const [lastError, setLastError] = useState("");
const [loading, setLoading] = useState(true);
const [claudeLogin, setClaudeLogin] = useState<ClaudeLoginState>({
status: "idle",
url: "",
message: "",
});
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -747,6 +775,89 @@ export function DashboardProvider({
await loadCommands();
}, [enqueueAndTrack, loadCommands]);
// ---- claude web-login (login_start / login_submit through the command queue) ----
const startClaudeLogin = useCallback(async () => {
setClaudeLogin({
status: "starting",
url: "",
message: "Opening `claude /login` in the control container and selecting the subscription account…",
});
try {
const command = await clientRef.current.api<Command>("/login/start", { method: "POST" });
// login_start boots claude, drives the menu, and scrapes the URL — allow ~90s
// (worker claim latency + boot waits + URL timeout).
const final = await clientRef.current.trackCommand(command.id, { attempts: 180 });
if (!final) {
setClaudeLogin({
status: "error",
url: "",
message: "Still starting (see Activity). Is the control worker running?",
});
return;
}
if (final.status !== "done") {
setClaudeLogin({ status: "error", url: "", message: final.error || "Failed to start login." });
return;
}
const url = final.result && typeof final.result.url === "string" ? final.result.url : "";
if (!url) {
setClaudeLogin({ status: "error", url: "", message: "No login URL was returned by claude." });
return;
}
setClaudeLogin({
status: "awaiting",
url,
message: "Authorize in the window below (or open it in a new tab), then paste the code claude gives you.",
});
} catch (e) {
if (e instanceof AuthError) return;
setClaudeLogin({ status: "error", url: "", message: (e as Error).message });
}
}, []);
const submitClaudeCode = useCallback(async (code: string) => {
const trimmed = code.trim();
if (!trimmed) return false;
setClaudeLogin((c) => ({ ...c, status: "submitting", message: "Submitting the authorization code…" }));
try {
const command = await clientRef.current.api<Command>("/login/submit", {
method: "POST",
body: { code: trimmed },
});
const final = await clientRef.current.trackCommand(command.id, { attempts: 60 });
if (!final) {
setClaudeLogin((c) => ({
...c,
status: "awaiting",
message: "Submit still running (see Activity). Is the control worker running?",
}));
return false;
}
if (final.status === "done") {
setClaudeLogin({
status: "done",
url: "",
message: "Claude Code is now logged in on the host — new agents will use this account.",
});
return true;
}
setClaudeLogin((c) => ({
...c,
status: "awaiting",
message: final.error || "Login was not confirmed. Re-check the code, or restart the flow.",
}));
return false;
} catch (e) {
if (e instanceof AuthError) return false;
setClaudeLogin((c) => ({ ...c, status: "awaiting", message: (e as Error).message }));
return false;
}
}, []);
const resetClaudeLogin = useCallback(() => {
setClaudeLogin({ status: "idle", url: "", message: "" });
}, []);
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
@@ -806,6 +917,10 @@ export function DashboardProvider({
deleteSchedule,
pollCi,
setSharedKey,
claudeLogin,
startClaudeLogin,
submitClaudeCode,
resetClaudeLogin,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;