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
+3
View File
@@ -15,6 +15,9 @@ downloads/
eggs/
.eggs/
lib/
# ...but the frontend's own source lib/ (api client + formatters) must be tracked, or the
# UI can't be rebuilt from a fresh clone. This Python-packaging `lib/` rule swallowed it.
!frontend/lib/
lib64/
parts/
sdist/
+39 -5
View File
@@ -5,6 +5,13 @@
# database, and the /var/lib/handler data volume with the API image (see Dockerfile),
# but runs the control process instead of uvicorn.
# ---- forge build stage: compile the git-forge CLI (git-pkgs/forge, Go) ----
# Built here and copied into the runtime image as a single static binary, so the runtime
# stage needs no Go toolchain. `forge` gives the CI poller its cross-forge `ci list`.
FROM golang:1.22-bookworm AS forge-builder
ENV CGO_ENABLED=0
RUN go install github.com/git-pkgs/forge/cmd/forge@latest
# ---- build stage: install the package + deps into an isolated venv ----
FROM python:3.11-slim AS builder
@@ -22,14 +29,41 @@ RUN pip install .
# ---- runtime stage ----
FROM python:3.11-slim
# git + tmux are the live-spawning dependencies the control layer shells out to
# (README "Requirements"); openssh-client covers git-over-ssh remotes. The `claude`
# and `forge` binaries are bring-your-own — layer or mount them in for live agent
# spawning and CI resolution; the poller degrades gracefully when forge is absent.
# Every executable the control layer shells out to is now bundled — no bring-your-own
# binaries — so the container can spawn live agents, run the verification gate, resolve CI,
# and drive the claude web-login flow out of the box:
# git / openssh-client — clone/push over https + ssh remotes
# tmux — one detached session per agent (and per login attempt)
# node + claude — the Claude Code CLI the agents *are*, and the /login flow the
# dashboard drives (see control/login.py)
# mise — the per-project task runner the test/build gates invoke
# forge — the cross-forge CLI the CI poller reads run status from
# Node comes from NodeSource (>=18 is required by Claude Code); mise from its official apt
# repo; forge from the build stage above. Installed under /usr/{bin,local/bin} — outside the
# /var/lib/handler VOLUME — so the volume mount never masks them at runtime. The image is
# built for amd64 and arm64: NodeSource + forge detect the arch, and the mise apt source is
# pinned to $TARGETARCH (buildx sets it; the Debian arch names match) so the arm64 build
# doesn't pull an amd64-only list.
ARG TARGETARCH=amd64
RUN apt-get update \
&& apt-get install -y --no-install-recommends git tmux openssh-client \
&& apt-get install -y --no-install-recommends \
git tmux openssh-client curl ca-certificates gnupg \
&& install -dm 755 /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force \
&& curl -fsSL https://mise.jdx.dev/gpg-key.pub \
| gpg --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=${TARGETARCH}] https://mise.jdx.dev/deb stable main" \
> /etc/apt/sources.list.d/mise.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends mise \
&& rm -rf /var/lib/apt/lists/*
# The cross-forge CLI compiled in the build stage above (github.com/git-pkgs/forge).
COPY --from=forge-builder /go/bin/forge /usr/local/bin/forge
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
# SQLite fallback lives on the /var/lib/handler volume; point DATABASE_URL at the
+34 -7
View File
@@ -155,9 +155,10 @@ the `/var/lib/handler` data volume:
| `ghcr.io/0xwheatyz/handler` | [`Dockerfile`](Dockerfile) | the API (`uvicorn`) — also applies migrations on start | [`docker.yml`](.github/workflows/docker.yml) |
| `ghcr.io/0xwheatyz/handler/control` | [`Dockerfile.control`](Dockerfile.control) | the control worker (`handler worker`) | [`docker-control.yml`](.github/workflows/docker-control.yml) |
The control image bakes in `git` + `tmux`; the `claude` and `forge` binaries are
bring-your-own (layer or mount them in for live agent spawning — the CI poller degrades
gracefully without `forge`). The **worker** drains the control-command queue the API
The control image bakes in **every executable the control layer shells out to**`git`,
`tmux`, `openssh-client`, `node` + the `claude` CLI, `mise`, and `forge` — so live agent
spawning, the verification gate, CI resolution, and the [web login](#claude-login-from-the-web-ui)
flow all work with zero bring-your-own binaries. The **worker** drains the control-command queue the API
enqueues (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval
(subsuming `poll-ci --watch`), so the whole system is drivable from the dashboard — see
[Web management](#web-management).
@@ -224,12 +225,38 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
- **Activity** — every enqueued command with its status (queued → running → done/failed) —
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
live status.
- **Claude Login** — log Claude Code in on the host from the browser (see below), so agents
spawn against a real authenticated `claude` with no shell access to the container.
The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`,
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`, and
`GET /commands[/{id}]`; hosts as `/hosts`; schedules as `/schedules` +
`/projects/{id}/schedules`; project mutation as `PATCH`/`DELETE /projects/{id}`. Run the
worker with `handler worker` (the control image's default command).
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`,
`POST /login/start`, `POST /login/submit`, and `GET /commands[/{id}]`; hosts as `/hosts`;
schedules as `/schedules` + `/projects/{id}/schedules`; project mutation as
`PATCH`/`DELETE /projects/{id}`. Run the worker with `handler worker` (the control image's
default command).
### Claude login from the web UI
Agents *are* `claude` processes, so the control container needs a logged-in Claude Code.
Because that container has no interactive shell in normal operation, the **Claude Login**
pane logs it in from the browser — the same command-queue handoff every other control
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.
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
restarts and is shared by every agent the worker spawns. The flow is admin-gated
(`ADMIN_TOKEN`) and driven entirely through `POST /login/start` and `POST /login/submit`.
The interactive claude TUI is timing-sensitive; the waits in `control.login` are generous
and overridable if a slow host needs more.
## Control CLI
+3 -3
View File
@@ -33,9 +33,9 @@ services:
# Shares the database and the handler-data volume with the API. It waits for the API
# (which owns migrations), so RUN_MIGRATIONS is off here to avoid a startup race. Run
# one-shot control commands against the same image with, e.g.,
# `docker compose run --rm control handler list`. Live agent spawning also needs
# `git`/`tmux` (baked in) plus bring-your-own `claude`/`forge` binaries — layer or mount
# those in.
# `docker compose run --rm control handler list`. Every executable it shells out to —
# `git`, `tmux`, `node`+`claude`, `mise`, `forge` — is bundled in the image, so live
# agent spawning and the claude web-login flow work with no bring-your-own binaries.
control:
image: ghcr.io/0xwheatyz/handler/control:latest
build:
+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>;
+204
View File
@@ -0,0 +1,204 @@
/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
* pydantic schemas in src/handler/api/schemas.py). The browser calls the API same-origin
* with relative paths; set NEXT_PUBLIC_API_BASE to point `npm run dev` at another origin.
*
* NOTE: this file lives under frontend/lib/, now un-ignored in .gitignore so the source
* ships and the build works from a fresh clone (the built export under
* src/handler/api/static/ is what the package serves). */
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
export type CommandStatus = "queued" | "running" | "done" | "failed";
export interface Project {
id: string;
root_dir: string;
git_remote?: string | null;
credential_ref?: string | null;
created_at: string;
/* Present on the registration response in git-server mode: the enqueued clone. */
sync_command_id?: number | null;
}
export interface Agent {
id: number;
project_id: string;
name: string;
working_dir: string;
status: string;
role?: string | null;
created_at: string;
}
export interface Checkmark {
agent_id: number;
checkpoint_at: string;
status: string;
where_it_stopped?: string | null;
next_steps?: string[] | null;
open_question?: string | null;
log_entry_id?: number | null;
tests_status: string;
tested_at?: string | null;
build_status: string;
built_at?: string | null;
}
export interface LogEntry {
id: number;
agent_id: number;
created_at: string;
session_id?: string | null;
status: string;
summary?: string | null;
decisions?: string | null;
question?: string | null;
answer?: string | null;
visibility: string;
push_sha?: string | null;
ci_status: string;
ci_checked_at?: string | null;
}
export interface Approval {
id: number;
project_id: string;
branch: string;
approved_sha?: string | null;
pr_ref?: string | null;
status: string;
approved_by_agent_id?: number | null;
actor?: string | null;
note?: string | null;
created_at: string;
}
export interface Host {
hostname: string;
forge_type: string;
token_env_var?: string | null;
base_url?: string | null;
ssh_public_key?: string | null;
has_token: boolean;
created_at: string;
}
export interface Command {
id: number;
project_id?: string | null;
agent_name?: string | null;
type: string;
payload?: Record<string, unknown> | null;
status: CommandStatus;
result?: Record<string, unknown> | null;
error?: string | null;
requested_by?: string | null;
claimed_by?: string | null;
created_at: string;
claimed_at?: string | null;
finished_at?: string | null;
}
export interface Schedule {
id: number;
project_id: string;
name_prefix: string;
task: string;
role?: string | null;
worktree?: string | null;
subdir?: string | null;
interval_seconds: number;
enabled: boolean;
next_run_at: string;
last_run_at?: string | null;
last_command_id?: number | null;
created_at: string;
}
export interface SharedContext {
key: string;
value: string;
set_by_agent_id?: number | null;
updated_at: string;
}
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
* quiet while the app re-prompts for a token. */
export class AuthError extends Error {
constructor(message = "unauthorized") {
super(message);
this.name = "AuthError";
}
}
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
export interface ApiError extends Error {
status: number;
}
interface ApiOptions {
method?: string;
body?: unknown;
}
interface TrackOptions {
attempts?: number;
intervalMs?: number;
}
export interface ApiClient {
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
* budget (worker down or a very slow command). */
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
}
export function createClient(token: string, onUnauthorized: () => void): ApiClient {
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
const hasBody = opts?.body !== undefined && opts?.body !== null;
const res = await fetch(BASE + path, {
method: opts?.method ?? (hasBody ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${token}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
},
body: hasBody ? JSON.stringify(opts!.body) : undefined,
});
if (res.status === 401) {
onUnauthorized();
throw new AuthError();
}
if (!res.ok) {
let detail: string = res.statusText;
try {
const j = await res.json();
if (j && typeof j.detail !== "undefined") {
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
}
} catch {
/* non-JSON error body; keep statusText */
}
const err = new Error(detail) as ApiError;
err.status = res.status;
throw err;
}
if (res.status === 204) return undefined as T;
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
const attempts = opts?.attempts ?? 60;
const intervalMs = opts?.intervalMs ?? 500;
for (let i = 0; i < attempts; i++) {
const cmd = await api<Command>(`/commands/${id}`);
if (cmd.status === "done" || cmd.status === "failed") return cmd;
await new Promise((r) => setTimeout(r, intervalMs));
}
return null;
}
return { api, trackCommand };
}
+92
View File
@@ -0,0 +1,92 @@
/* Formatting + status helpers shared by the UI. Pure functions, no API access.
*
* NOTE: this file lives under frontend/lib/, which the repo's top-level .gitignore used to
* swallow (a broad `lib/` rule) it is now un-ignored (see .gitignore) so the source ships
* and `npm run build` works from a fresh clone. The *built* export under
* src/handler/api/static/ is still what the Python package serves. */
export type Tone = "neutral" | "info" | "success" | "warning" | "danger";
/* Map a raw handler status string (agent status, gate status, CI status) to a badge tone. */
export function statusTone(status: string | null | undefined): Tone {
switch ((status ?? "").toLowerCase()) {
case "pass":
case "done":
case "completed":
case "approved":
case "success":
return "success";
case "fail":
case "failed":
case "blocked":
case "rejected":
case "error":
return "danger";
case "pending":
case "queued":
case "running":
case "working":
case "paused_for_input":
return "warning";
case "not_applicable":
case "unknown":
case "":
return "neutral";
default:
return "info";
}
}
const LABELS: Record<string, string> = {
paused_for_input: "Needs input",
not_applicable: "N/A",
};
/* A tidy, human-readable label for a status string. */
export function statusLabel(status: string | null | undefined): string {
const raw = (status ?? "").trim();
if (!raw) return "—";
const key = raw.toLowerCase();
if (LABELS[key]) return LABELS[key];
return key
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/* Full local date + time, e.g. "Jul 13, 2026, 2:04 PM". "—" for empty. */
export function fmtFull(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/* First 7 chars of a commit sha. "—" for empty. */
export function shortSha(sha: string | null | undefined): string {
if (!sha) return "—";
return sha.slice(0, 7);
}
/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. */
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return "—";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "—";
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo`;
return `${Math.floor(months / 12)}y`;
}
+2
View File
@@ -20,6 +20,7 @@ from .routes import (
commands,
hosts,
interaction,
login,
projects,
schedules,
shared,
@@ -46,6 +47,7 @@ def create_app() -> FastAPI:
app.include_router(interaction.router)
app.include_router(approvals.router)
app.include_router(commands.router)
app.include_router(login.router)
app.include_router(hosts.router)
app.include_router(schedules.router)
app.include_router(shared.router)
+52
View File
@@ -0,0 +1,52 @@
"""Claude Code web-login: enqueue the two-step ``/login`` flow the worker runs.
The API container has no ``claude`` binary and doesn't own the tmux sessions, so — like
spawn/kill logging Claude Code in is a control command the worker executes in the
control container:
- ``POST /login/start`` enqueues ``login_start``; its result carries the ``url`` the UI
opens (in an iframe) for the operator to authorize.
- ``POST /login/submit`` enqueues ``login_submit`` with the pasted ``code``; its result
carries ``success``.
Both are admin-gated (they act on the host's Claude credentials). The UI polls
``GET /commands/{id}`` for each, exactly as it does for spawns.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy import Connection
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import CommandOut, LoginSubmitIn
router = APIRouter(tags=["login"], dependencies=[Depends(require_auth)])
@router.post(
"/login/start",
response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
)
def enqueue_login_start(conn: Connection = Depends(db_conn)) -> dict:
"""Open ``claude /login`` in the control container and return the authorization URL."""
return repo.enqueue_command(conn, "login_start", requested_by="operator:web")
@router.post(
"/login/submit",
response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
)
def enqueue_login_submit(body: LoginSubmitIn, conn: Connection = Depends(db_conn)) -> dict:
"""Feed the pasted authorization code back into the waiting login session."""
return repo.enqueue_command(
conn,
"login_submit",
payload={"code": body.code},
requested_by="operator:web",
)
+6
View File
@@ -154,6 +154,12 @@ class CommandOut(BaseModel):
finished_at: datetime | None = None
class LoginSubmitIn(BaseModel):
"""The authorization code the operator pastes back after logging in at claude.com."""
code: str = Field(min_length=1)
class HostIn(BaseModel):
hostname: str
forge_type: ForgeType
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-b4a814bde4e81779.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[8423,[\"931\",\"static/chunks/app/page-b4a814bde4e81779.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"twm_FqRjMWdGYZ-lbTh7o\",\"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-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>
+2 -2
View File
@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"]
3:I[8423,["931","static/chunks/app/page-b4a814bde4e81779.js"],"default",1]
3:I[9859,["931","static/chunks/app/page-aaee823ffe4c78c3.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
0:["twm_FqRjMWdGYZ-lbTh7o",[[["",{"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:["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]]]]
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
+145
View File
@@ -0,0 +1,145 @@
"""Drive the bundled ``claude`` binary's ``/login`` OAuth flow from the web UI.
The dashboard has no ``claude`` (it runs in the API container); the control container
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.
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``.
"""
from __future__ import annotations
import os
import re
import time
from ..config import get_settings
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")
_SUCCESS_HINTS = (
"login successful",
"logged in",
"successfully authenticated",
"authentication successful",
"you are now logged in",
)
class LoginError(Exception):
"""Raised when the claude login flow cannot be started or completed."""
def _home() -> str:
return os.path.expanduser("~") or "/tmp"
def _sleep(seconds: float) -> None:
"""Indirection so tests can patch out real waiting."""
time.sleep(seconds)
def _extract_url(pane: str) -> str | None:
"""Pull the login URL out of a captured pane, preferring the OAuth link."""
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
def start(
*,
boot_wait: float = 4.0,
menu_wait: float = 1.5,
url_timeout: float = 30.0,
poll_interval: float = 0.5,
) -> dict:
"""Open ``claude`` in tmux, drive ``/login`` to the subscription account, return the URL.
Leaves the tmux session alive for :func:`submit_code`. Raises :class:`LoginError` if
no authorization URL appears within ``url_timeout`` seconds.
"""
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)
deadline = time.monotonic() + url_timeout
url: str | None = None
while url is None and time.monotonic() < deadline:
url = _extract_url(tmux.capture_pane(LOGIN_SESSION))
if url is None:
_sleep(poll_interval)
if url is None:
# Don't leave a half-driven session lying around on failure.
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?"
)
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.
Returns ``{"success": bool, "output": <pane tail>}``. Kills the session on success.
Raises :class:`LoginError` if there is no active login session to submit to.
"""
code = (code or "").strip()
if not code:
raise LoginError("no authorization code provided")
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)
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)}
def _looks_successful(pane: str) -> bool:
low = (pane or "").lower()
return any(hint in low for hint in _SUCCESS_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()]
return "\n".join(kept[-lines:])
+18
View File
@@ -63,3 +63,21 @@ def send_keys(name: str, keys: str) -> None:
"""Send a line of input to a live session (used by the resume seam)."""
tmux = get_settings().tmux_bin
subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True)
def capture_pane(name: str) -> 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.
"""
tmux = get_settings().tmux_bin
result = subprocess.run(
[tmux, "capture-pane", "-t", name, "-p", "-J"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return ""
return result.stdout
+27 -1
View File
@@ -20,7 +20,7 @@ from datetime import UTC, datetime, timedelta
from ..db import repository as repo
from ..db.engine import connection
from . import gitops, poller, reposync, skills_gen, spawn
from . import gitops, login, poller, reposync, skills_gen, spawn
class CommandError(Exception):
@@ -154,6 +154,30 @@ def _cmd_poll_ci(command: dict) -> dict:
return poller.sweep(project_id=command.get("project_id"))
def _cmd_login_start(command: dict) -> dict:
"""Open the claude ``/login`` flow and return the claude.com authorization URL."""
try:
return login.start()
except login.LoginError as exc:
raise CommandError(str(exc)) from exc
def _cmd_login_submit(command: dict) -> dict:
"""Feed the pasted authorization code back into the live login session."""
code = _payload(command).get("code")
if not code:
raise CommandError("login_submit requires a 'code' in the payload")
try:
result = login.submit_code(code)
except login.LoginError as exc:
raise CommandError(str(exc)) from exc
if not result.get("success"):
# Surface the pane tail so the operator can see why claude rejected the code.
detail = result.get("output") or "claude did not confirm a successful login"
raise CommandError(f"login not confirmed — {detail}")
return result
def _cmd_sync(command: dict) -> dict:
project_id = command.get("project_id")
if not project_id:
@@ -177,6 +201,8 @@ _DISPATCH = {
"forge_init": _cmd_forge_init,
"poll_ci": _cmd_poll_ci,
"sync": _cmd_sync,
"login_start": _cmd_login_start,
"login_submit": _cmd_login_submit,
}
+16 -1
View File
@@ -35,7 +35,22 @@ APPROVAL_STATUSES = ("approved", "rejected")
# The control actions the API enqueues and the control-container worker executes.
# ``sync`` clones a project's repo into its root_dir (or fast-forward pulls an existing
# clone) using the git server's stored credentials — the API can't run git itself.
COMMAND_TYPES = ("spawn", "kill", "resume", "approve", "reject", "forge_init", "poll_ci", "sync")
# ``login_start``/``login_submit`` drive the bundled ``claude`` binary's ``/login`` OAuth
# flow from the web UI: the worker opens an interactive claude session in the control
# container, returns the claude.com authorization URL, and later feeds back the pasted
# code — the API container has no ``claude`` and can't run it directly.
COMMAND_TYPES = (
"spawn",
"kill",
"resume",
"approve",
"reject",
"forge_init",
"poll_ci",
"sync",
"login_start",
"login_submit",
)
COMMAND_STATUSES = ("queued", "running", "done", "failed")
# Forge families a host can belong to (drives per-host token env conventions).
FORGE_TYPES = ("github", "gitlab", "gitea", "forgejo", "bitbucket")
@@ -0,0 +1,42 @@
"""claude web-login command types
Revision ID: 0005_claude_login_commands
Revises: 0004_git_servers_schedules
Create Date: 2026-07-13
Adds the ``login_start`` and ``login_submit`` command types so the dashboard can drive
the bundled ``claude`` binary's ``/login`` OAuth flow inside the control container: the
worker opens an interactive ``claude`` session, selects the subscription account, returns
the claude.com authorization URL for the operator to open, and later feeds back the code
they paste. The commands CHECK constraint change goes through ``batch_alter_table`` so
SQLite recreates the table while Postgres alters in place (same pattern as 0004's
``sync``).
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0005_claude_login_commands"
down_revision: str | None = "0004_git_servers_schedules"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
OLD_COMMAND_TYPES = (
"'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'poll_ci', 'sync'"
)
NEW_COMMAND_TYPES = OLD_COMMAND_TYPES + ", 'login_start', 'login_submit'"
def upgrade() -> None:
with op.batch_alter_table("commands", schema=None) as batch_op:
batch_op.drop_constraint("ck_commands_type", type_="check")
batch_op.create_check_constraint("ck_commands_type", f"type IN ({NEW_COMMAND_TYPES})")
def downgrade() -> None:
with op.batch_alter_table("commands", schema=None) as batch_op:
batch_op.drop_constraint("ck_commands_type", type_="check")
batch_op.create_check_constraint("ck_commands_type", f"type IN ({OLD_COMMAND_TYPES})")
+49
View File
@@ -0,0 +1,49 @@
"""The web-login API surface: enqueue login_start / login_submit, admin-gated."""
from __future__ import annotations
def _admin(env):
# ADMIN_TOKEN is unset in the test env, so the admin gate falls back to AUTH_TOKEN.
return {"Authorization": f"Bearer {env['token']}"}
def test_login_start_enqueues_command(client, env):
r = client.post("/login/start", headers=_admin(env))
assert r.status_code == 202
body = r.json()
assert body["type"] == "login_start"
assert body["status"] == "queued"
assert body["requested_by"] == "operator:web"
def test_login_submit_enqueues_command_with_code(client, env):
r = client.post("/login/submit", headers=_admin(env), json={"code": "auth-xyz"})
assert r.status_code == 202
body = r.json()
assert body["type"] == "login_submit"
assert body["payload"] == {"code": "auth-xyz"}
def test_login_submit_rejects_blank_code(client, env):
r = client.post("/login/submit", headers=_admin(env), json={"code": ""})
assert r.status_code == 422
def test_login_start_requires_auth(client):
assert client.post("/login/start").status_code in (401, 403)
def test_login_endpoints_require_admin_token(client, env, monkeypatch):
# With a distinct admin token set, the plain auth token must be refused.
monkeypatch.setenv("ADMIN_TOKEN", "admin-secret")
from handler import config
config.get_settings.cache_clear()
try:
r = client.post("/login/start", headers={"Authorization": f"Bearer {env['token']}"})
assert r.status_code == 403
ok = client.post("/login/start", headers={"Authorization": "Bearer admin-secret"})
assert ok.status_code == 202
finally:
config.get_settings.cache_clear()
+117
View File
@@ -0,0 +1,117 @@
"""The claude web-login seam: driving ``claude /login`` through tmux and scraping the URL.
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.
"""
from __future__ import annotations
import pytest
from handler.control import login, tmux
@pytest.fixture
def no_sleep(monkeypatch):
monkeypatch.setattr(login, "_sleep", lambda *_a, **_k: None)
def _pane(monkeypatch, *frames):
"""Make ``capture_pane`` return each frame in turn, then repeat the last one."""
seq = list(frames)
def capture(_name):
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"
def test_extract_url_prefers_oauth_link():
pane = f"Visit https://example.com/help or\n{AUTH_URL}\nand paste the code."
assert login._extract_url(pane) == AUTH_URL
def test_extract_url_strips_trailing_punctuation():
assert login._extract_url(f"Open ({AUTH_URL}).") == AUTH_URL
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}")
result = login.start(url_timeout=1.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"]
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}")
login.start(url_timeout=1.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")
with pytest.raises(login.LoginError, match="timed out"):
login.start(url_timeout=0.05, poll_interval=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"]
def test_submit_code_reports_failure_without_killing_session(
env, fake_tmux, no_sleep, monkeypatch
):
fake_tmux["live"].add(login.LOGIN_SESSION)
_pane(monkeypatch, "Invalid code, please try again")
result = login.submit_code("wrong")
assert result["success"] is False
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):
with pytest.raises(login.LoginError, match="no active"):
login.submit_code("code")
def test_submit_code_rejects_blank(env, fake_tmux, no_sleep):
with pytest.raises(login.LoginError, match="no authorization code"):
login.submit_code(" ")
+48 -1
View File
@@ -6,7 +6,7 @@ machinery (already covered by test_control_spawn)."""
from __future__ import annotations
from handler.control import poller, spawn, worker
from handler.control import login, poller, spawn, worker
from handler.db import repository as repo
from handler.db.engine import get_engine
@@ -107,6 +107,53 @@ def test_poll_ci_command_returns_summary(env, monkeypatch):
assert done["result"] == {"checked": 0, "resolved": 0, "pending": 0}
def test_login_start_command_returns_url(env, monkeypatch):
monkeypatch.setattr(
login, "start", lambda: {"session": "handler__login", "url": "https://claude.ai/oauth"}
)
cmd = _enqueue(type="login_start")
worker.drain("w")
done = _get(cmd["id"])
assert done["status"] == "done"
assert done["result"]["url"] == "https://claude.ai/oauth"
def test_login_submit_command_feeds_code(env, monkeypatch):
seen = {}
def fake_submit(code):
seen["code"] = code
return {"success": True, "output": "Login successful"}
monkeypatch.setattr(login, "submit_code", fake_submit)
cmd = _enqueue(type="login_submit", payload={"code": "auth-123"})
worker.drain("w")
done = _get(cmd["id"])
assert done["status"] == "done"
assert done["result"]["success"] is True
assert seen["code"] == "auth-123"
def test_login_submit_failure_is_recorded_failed(env, monkeypatch):
monkeypatch.setattr(
login, "submit_code", lambda code: {"success": False, "output": "Invalid code"}
)
cmd = _enqueue(type="login_submit", payload={"code": "bad"})
worker.drain("w")
failed = _get(cmd["id"])
assert failed["status"] == "failed"
assert "Invalid code" in failed["error"]
def test_login_submit_without_code_is_failed(env):
cmd = _enqueue(type="login_submit", payload={})
assert worker.drain("w") == 1
assert _get(cmd["id"])["status"] == "failed"
def test_bad_command_is_recorded_failed_not_raised(env):
# spawn with no agent name -> CommandError -> the worker records 'failed', keeps going.
_seed_project()