mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-29 19:21:40 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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 @@
|
||||
<!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>
|
||||
@@ -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
|
||||
|
||||
@@ -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:])
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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})")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user