From fa2e97130d1ff719ad6f81af04a6d734fb901a2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 17:45:52 +0000 Subject: [PATCH] feat: bundle agent executables + web-driven claude login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2 --- .gitignore | 3 + Dockerfile.control | 44 +++- README.md | 41 +++- docker-compose.yml | 6 +- frontend/components/Dashboard.tsx | 9 + frontend/components/sections/LoginSection.tsx | 118 ++++++++++ frontend/components/store.tsx | 117 +++++++++- frontend/lib/api.ts | 204 ++++++++++++++++++ frontend/lib/format.ts | 92 ++++++++ src/handler/api/app.py | 2 + src/handler/api/routes/login.py | 52 +++++ src/handler/api/schemas.py | 6 + src/handler/api/static/404.html | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 .../chunks/app/page-aaee823ffe4c78c3.js | 1 + .../chunks/app/page-b4a814bde4e81779.js | 1 - src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 4 +- src/handler/control/login.py | 145 +++++++++++++ src/handler/control/tmux.py | 18 ++ src/handler/control/worker.py | 28 ++- src/handler/db/tables.py | 17 +- .../versions/0005_claude_login_commands.py | 42 ++++ tests/test_api_login.py | 49 +++++ tests/test_control_login.py | 117 ++++++++++ tests/test_worker.py | 49 ++++- 27 files changed, 1145 insertions(+), 24 deletions(-) create mode 100644 frontend/components/sections/LoginSection.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/format.ts create mode 100644 src/handler/api/routes/login.py rename src/handler/api/static/_next/static/{twm_FqRjMWdGYZ-lbTh7o => J55muUx2ya8M2SQERVlYt}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{twm_FqRjMWdGYZ-lbTh7o => J55muUx2ya8M2SQERVlYt}/_ssgManifest.js (100%) create mode 100644 src/handler/api/static/_next/static/chunks/app/page-aaee823ffe4c78c3.js delete mode 100644 src/handler/api/static/_next/static/chunks/app/page-b4a814bde4e81779.js create mode 100644 src/handler/control/login.py create mode 100644 src/handler/migrations/versions/0005_claude_login_commands.py create mode 100644 tests/test_api_login.py create mode 100644 tests/test_control_login.py diff --git a/.gitignore b/.gitignore index c4971fa..e42ddb6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile.control b/Dockerfile.control index 4077c92..c05620e 100644 --- a/Dockerfile.control +++ b/Dockerfile.control @@ -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 diff --git a/README.md b/README.md index 9841d67..0fe4532 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c1f6958..e4683f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/components/Dashboard.tsx b/frontend/components/Dashboard.tsx index 5b0192a..3868382 100644 --- a/frontend/components/Dashboard.tsx +++ b/frontend/components/Dashboard.tsx @@ -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" && } {s.section === "activity" && } {s.section === "shared" && } + {s.section === "login" && } )} diff --git a/frontend/components/sections/LoginSection.tsx b/frontend/components/sections/LoginSection.tsx new file mode 100644 index 0000000..8dfc582 --- /dev/null +++ b/frontend/components/sections/LoginSection.tsx @@ -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 ( + <> +
+
Claude Login
+
+ Log Claude Code in on the host so agents can run. This drives{" "} + claude /login in the control container and picks the + Claude account with a subscription. +
+
+ +
+ {message && ( + + {message} + + )} + + {status === "done" ? ( +
+ +
+ ) : !awaiting ? ( +
+ + {status === "error" && ( + + )} +
+ ) : ( + <> + + +
+