diff --git a/.env.example b/.env.example index 9555d02..8dcc234 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ AUTH_TOKEN=change-me-to-a-long-random-string # Fully bring-your-own; the Notification hook is a no-op when unset. # WEBHOOK_URL=https://ntfy.sh/my-topic +# Symmetric key for the encrypted secret store: git-server tokens and SSH private keys +# are Fernet-encrypted with it before they reach the database. Set the SAME value on the +# API (encrypts on write) and the control container (decrypts at clone/spawn). Generate: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Unset => storing tokens/SSH keys on git servers is refused with a clear error. +# HANDLER_SECRET_KEY= + # Base directory under which per-project roots and agent worktrees live (isolation). PROJECTS_ROOT=/var/lib/handler/projects diff --git a/README.md b/README.md index 6b313fd..9841d67 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example) | `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` | | `ADMIN_TOKEN` | Gates the web control surface (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` | | `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op | -| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees | `./projects` | +| `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled | +| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees / auto-clones | `./projects` | | `CLAUDE_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `mise` / `tmux` / `forge` / `git` | | `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check | | `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` | @@ -193,25 +194,42 @@ and the worker in the control container executes it and writes the result back: What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`): -- **Projects** — create / edit / delete (`root_dir`, `git_remote`, `credential_ref`). +- **Git servers** — one entry per forge host, and the server **owns its credentials**: + - a **forge token**, submitted once and stored **encrypted** (`HANDLER_SECRET_KEY`, + Fernet) — the API never returns it, only a `has_token` flag. Every project on that + server uses it automatically (for both `forge` and git-over-HTTPS), no per-repo setup. + - an **SSH deploy key** (ed25519), generated server-side; the **public key is shown in + the dashboard** to paste into GitHub/Gitea/… as a deploy or account key. The private + key is encrypted at rest and only ever materialized (0600) in the control container. +- **Projects** — add a repo by picking a **configured git server** and typing + **`owner/name`** — that's the whole form. Handler derives the remote (ssh when the + server has a deploy key, https via the stored token otherwise), computes `root_dir` + under `PROJECTS_ROOT` (stateless workflows don't care where the clone lives), and + enqueues a `sync` command so the worker clones it. Manual mode (existing `root_dir`) + still works; every project with a remote gets a **Pull now** button, and spawn always + pulls first. +- **Schedules** — recurring agent spawns: a name prefix, a prompt, and an interval. The + worker fires each due schedule as a normal queued `spawn` with a timestamped agent name + (`nightly-20260710-090000`), so runs are fresh, stateless agents and show up in + Activity. The canonical prompt keeps its state in the repo: *"Read @notes.md, continue + from there; before finishing, overwrite that file."* - **Agents** — spawn (name, role, worktree/subdir, task) and kill via the queue; delete the row; plus the existing checkmark / log / answer-resume views. - **Approvals** — record an operator verdict per branch (approve/reject); the deploy gate treats an operator verdict as a genuine second party (no self-approval). -- **Forge hosts** — a registry mapping a host to the token env var to inject at spawn, so - self-hosted forges work without a code change (the built-in host map is the fallback). -- **Credentials** — manage a project's `credential_ref` **pointer**. The DB still never - stores a raw token: web-settable schemes are `env:` / `file:` / `db:` (the `cmd:` scheme - is CLI-only, since it would run an arbitrary command in the control container). `db:` is - reserved for a future encrypted secret store. +- **Credentials** — a project's `credential_ref` **pointer** still overrides everything. + Web-settable schemes are `env:` / `file:` / `db:host:` (the `cmd:` scheme is + CLI-only, since it would run an arbitrary command in the control container). + `db:host:` reads the named git server's encrypted stored 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. The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`, -`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, and `GET /commands[/{id}]`; -hosts as `/hosts`; 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`, 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). ## Control CLI @@ -223,6 +241,7 @@ handler spawn --project leeworks-api --name junior --role junior --worktree fea handler list [--project leeworks-api] handler attach --project leeworks-api --name junior handler kill --project leeworks-api --name junior +handler sync --project leeworks-api # clone or fast-forward the repo now # Phase 2 — forge workflow handler forge-init --project leeworks-api # write + commit the role skills @@ -253,6 +272,10 @@ All routes require `Authorization: Bearer `. `GET /health` is unauth | `GET /projects/:p/agents/:name/log` | The agent's log history (paginated) | | `POST /projects/:p/agents/:name/answer` | Backfill the operator's answer to an open question | | `POST /projects/:p/agents/:name/resume` | Feed the answer back via `claude --resume` | +| `GET /hosts` · `POST /hosts` · `PATCH`/`DELETE /hosts/:h` | Git servers (token stored encrypted; `ssh_public_key` returned) | +| `GET /schedules` · `GET`/`POST /projects/:p/schedules` | Recurring agent spawns | +| `PATCH`/`DELETE /schedules/:id` | Edit / pause / remove a schedule | +| `POST /projects/:p/sync` | Clone-or-pull the project's repo (enqueued) | | `GET /shared/log` | Cross-project feed of entries explicitly marked `global` | | `GET /shared/context` · `GET /shared/context/:key` | Read shared key/value facts | | `PUT /shared/context/:key` | Write a shared fact — requires the shared-write token | @@ -304,22 +327,45 @@ project's `credential_ref` (and optionally a `FORGE_VERSION` pin). the runs tied to that commit and backfills the authoritative verdict — one interface, any forge, no inbound webhook. -### Credentials — resolution, not storage (README 3.7) +### Credentials — resolution over raw storage (README 3.7) -The database never stores a raw token. A project's `credential_ref` is a **pointer**: +The database never stores a *usable* secret. A project's `credential_ref` is a **pointer**: | Form | Meaning | |---|---| | `env:VAR_NAME` | read the value from an environment variable | | `file:/path` | read (and strip) the value from a file | -| `cmd:some command` | run the command; its stdout is the value | +| `cmd:some command` | run the command; its stdout is the value (CLI-only) | +| `db:host:` | decrypt the named git server's stored token (`HANDLER_SECRET_KEY`) | -At spawn the control layer resolves the pointer and injects the value into that one -agent's environment as `FORGE_TOKEN` (plus the host-specific `GITHUB_TOKEN` / -`GITEA_TOKEN` / … when the remote is recognized). A repo-local git credential helper is -installed that hands the same value back for HTTPS push/pull — so one secret services both -`forge` and `git`, and the raw token lives only in the process environment, never on disk -or in the database. +When a project has **no** `credential_ref`, the git server matching its remote supplies +the token automatically (its stored token, decrypted at spawn) — so projects added from a +configured server need zero per-repo credential setup. + +At spawn the control layer resolves the token and injects it into that one agent's +environment as `FORGE_TOKEN` (plus the host-specific `GITHUB_TOKEN` / `GITEA_TOKEN` / … +when the remote is recognized). A repo-local git credential helper is installed that +hands the same value back for HTTPS push/pull — so one secret services both `forge` and +`git`, and the raw token lives only in the process environment. For **SSH remotes** the +server's deploy key is materialized to a 0600 file in the control container and pinned +via `GIT_SSH_COMMAND` / repo-local `core.sshCommand`, so agents' pushes over ssh just +work. Tokens and private keys stored in the database are Fernet-encrypted with +`HANDLER_SECRET_KEY`; without the key a database dump holds only ciphertext. + +### Scheduled agents + +A **schedule** spawns a fresh agent every `interval_seconds`: pick a repository, a name +prefix, a role, and a standing prompt. On each firing the worker enqueues an ordinary +`spawn` command (visible in Activity) with a timestamped agent name, and the repo is +pulled before the run — every run starts stateless from the remote's latest state. +Continuity lives in the repo itself; the canonical prompt is: + +> Read @notes.md and continue from where it left off. Before finishing, overwrite +> @notes.md with the current state so the next run can pick up from there. + +Missed intervals (worker down) collapse into a single catch-up run. Manage schedules in +the dashboard's **Schedules** pane or via `GET/POST /projects/:p/schedules`, +`PATCH`/`DELETE /schedules/:id`. ## Development @@ -341,7 +387,7 @@ and `control.spawn.resume` — are the mock points that stand in for live The dashboard (`frontend/`) is a **Next.js** app (React + TypeScript) that builds to a **static export** — the `Claude Activity` Control Center: a left-nav hub over Runs, -Repositories, Agents, Approvals, Git Servers, Activity, and Shared. It is a pure client of +Repositories, Agents, Schedules, Approvals, Git Servers, Activity, and Shared. It is a pure client of the API (same contract as `curl`): the browser prompts for the token once, stores it in `localStorage`, and attaches it to every call. All API values render as React text (never `dangerouslySetInnerHTML`) so agent-authored strings can't inject markup. diff --git a/docker-compose.yml b/docker-compose.yml index 73df539..c1f6958 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: WEBHOOK_URL: ${WEBHOOK_URL:-} UI_ENABLED: ${UI_ENABLED:-true} CORS_ORIGINS: ${CORS_ORIGINS:-} + # The API computes new projects' root_dir under this path (shared volume with the + # control container, which does the actual cloning). + PROJECTS_ROOT: /var/lib/handler/projects + # Encrypts git-server tokens/SSH keys at rest; must match the control container. + HANDLER_SECRET_KEY: ${HANDLER_SECRET_KEY:-} volumes: - handler-data:/var/lib/handler depends_on: @@ -23,7 +28,8 @@ services: restart: unless-stopped # Control layer: the `handler` worker. Drains the control-command queue the API enqueues - # (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval. + # (spawn/kill/resume/approve/reject/forge-init/poll-ci/sync), fires due schedules, and + # sweeps CI on an interval. # 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., @@ -42,6 +48,8 @@ services: # Per-project forge credentials are resolved from credential_ref pointers at spawn; # export the referenced vars here when spawning agents from this container. FORGE_VERSION: ${FORGE_VERSION:-} + # Decrypts git-server tokens/SSH keys stored by the API; must match the API's key. + HANDLER_SECRET_KEY: ${HANDLER_SECRET_KEY:-} volumes: - handler-data:/var/lib/handler depends_on: diff --git a/frontend/components/Dashboard.tsx b/frontend/components/Dashboard.tsx index 2744d5e..5b0192a 100644 --- a/frontend/components/Dashboard.tsx +++ b/frontend/components/Dashboard.tsx @@ -7,6 +7,7 @@ import { useDashboard, type Section } from "@/components/store"; import { RunsSection } from "@/components/sections/RunsSection"; import { RepositoriesSection } from "@/components/sections/RepositoriesSection"; import { AgentsSection } from "@/components/sections/AgentsSection"; +import { SchedulesSection } from "@/components/sections/SchedulesSection"; import { ApprovalsSection } from "@/components/sections/ApprovalsSection"; import { GitServersSection } from "@/components/sections/GitServersSection"; import { ActivitySection } from "@/components/sections/ActivitySection"; @@ -28,6 +29,7 @@ const NAV: NavDef[] = [ }, { key: "repositories", label: "Repositories", count: (s) => s.projects.length }, { key: "agents", label: "Agents", count: (s) => s.agents.length }, + { key: "schedules", label: "Schedules", count: (s) => s.schedules.length }, { key: "approvals", label: "Approvals", count: (s) => s.approvals.length }, { key: "servers", label: "Git Servers", count: (s) => s.hosts.length }, { key: "activity", label: "Activity", count: (s) => s.commands.length }, @@ -90,6 +92,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
{s.section === "repositories" && } {s.section === "agents" && } + {s.section === "schedules" && } {s.section === "approvals" && } {s.section === "servers" && } {s.section === "activity" && } diff --git a/frontend/components/sections/GitServersSection.tsx b/frontend/components/sections/GitServersSection.tsx index 99e2129..f2d23a3 100644 --- a/frontend/components/sections/GitServersSection.tsx +++ b/frontend/components/sections/GitServersSection.tsx @@ -1,5 +1,7 @@ -/* Git Servers — the forge host registry. Each row maps a host to the token env var to - * inject at spawn (and the credential-helper scope). Holds no secrets, only the var name. */ +/* Git Servers — one entry per forge host, carrying the server's own credentials: + * a forge token (stored encrypted, write-only) and an SSH deploy key whose public half + * is shown here to paste into the forge. Projects on a configured server need no + * per-repo credentials. */ "use client"; import { useState } from "react"; @@ -15,7 +17,52 @@ const FORGE_OPTS = [ { value: "bitbucket", label: "bitbucket" }, ]; -const empty = { hostname: "", forge_type: "github", token_env_var: "", base_url: "" }; +const empty = { + hostname: "", + forge_type: "github", + token_env_var: "", + base_url: "", + token: "", + generate_ssh_key: true, +}; + +function PublicKey({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable (http) — the key is selectable below */ + } + }; + return ( +
+
+ SSH public key — add it to the forge (deploy key) + +
+
+        {value}
+      
+
+ ); +} export function GitServersSection() { const s = useDashboard(); @@ -38,6 +85,8 @@ export function GitServersSection() { forge_type: h.forge_type, token_env_var: h.token_env_var ?? "", base_url: h.base_url ?? "", + token: "", + generate_ssh_key: false, }); setEditing(true); }; @@ -47,8 +96,10 @@ export function GitServersSection() {
Git Servers
- Maps a git host to the token env var injected at spawn. The built-in host map is the - fallback when no row matches. + Each server carries its own credentials: a forge token (encrypted at rest, used by + agents' forge + git) and an SSH deploy key — paste + the public key into the forge. New repositories are added by picking a server and + typing owner/name.
@@ -63,7 +114,7 @@ export function GitServersSection() { label="Hostname" value={form.hostname} onChange={(v) => setForm({ ...form, hostname: v })} - placeholder="git.corp.internal" + placeholder="github.com" disabled={editing} /> setForm({ ...form, token: v })} + placeholder="stored encrypted; used by forge + git" + /> + setForm({ ...form, base_url: v })} + placeholder="https://git.corp.internal:8443" + /> + setForm({ ...form, token_env_var: v })} placeholder="GITEA_TOKEN" /> - setForm({ ...form, base_url: v })} - placeholder="https://git.corp.internal (optional)" - /> +
@@ -121,6 +198,7 @@ export function GitServersSection() { token env {h.token_env_var || "—"} {h.base_url ? ` · ${h.base_url}` : ""}
+ {h.ssh_public_key && } ))}
diff --git a/frontend/components/sections/RepositoriesSection.tsx b/frontend/components/sections/RepositoriesSection.tsx index 40f7170..8479e06 100644 --- a/frontend/components/sections/RepositoriesSection.tsx +++ b/frontend/components/sections/RepositoriesSection.tsx @@ -1,16 +1,28 @@ -/* Repositories — register / edit / remove the projects (repos) Handler manages. - * Maps the design's "Repositories" pane to Handler's project registry. */ +/* Repositories — the projects Handler manages. Adding one is now server-first: pick a + * configured git server, type owner/name, and Handler derives the remote, decides where + * the clone lives, and pulls it (stateless workflows don't care about disk paths). + * Manual mode (an existing checkout) remains for everything else. */ "use client"; import { useMemo, useState } from "react"; -import { useDashboard } from "@/components/store"; -import { Badge, Button, Card, Input } from "@/components/ui"; +import { useDashboard, type NewProjectBody } from "@/components/store"; +import { Badge, Button, Card, Input, Select, Tabs } from "@/components/ui"; import { fmtFull } from "@/lib/format"; import type { Project } from "@/lib/api"; -const CRED_HELP = "credential_ref is a pointer, never the token (env: / file: / db:). cmd: is CLI-only."; +const CRED_HELP = + "Optional override — projects on a configured git server use its stored token automatically. " + + "credential_ref is a pointer, never the token (env: / file: / db:host:)."; -const empty = { id: "", root_dir: "", git_remote: "", credential_ref: "" }; +const empty: NewProjectBody = { + mode: "server", + git_server: "", + repo: "", + id: "", + root_dir: "", + git_remote: "", + credential_ref: "", +}; export function RepositoriesSection() { const s = useDashboard(); @@ -23,20 +35,28 @@ export function RepositoriesSection() { return m; }, [s.agents]); + const serverOpts = useMemo( + () => [ + { value: "", label: s.hosts.length ? "Pick a git server…" : "No git servers configured" }, + ...s.hosts.map((h) => ({ value: h.hostname, label: `${h.hostname} (${h.forge_type})` })), + ], + [s.hosts], + ); + const reset = () => { setForm(empty); setEditing(false); }; const save = async () => { - const ok = editing - ? await s.updateProject(form.id, form) - : await s.createProject(form); + const ok = editing ? await s.updateProject(form.id, form) : await s.createProject(form); if (ok) reset(); }; const edit = (p: Project) => { setForm({ + ...empty, + mode: "manual", id: p.id, root_dir: p.root_dir, git_remote: p.git_remote ?? "", @@ -45,6 +65,12 @@ export function RepositoriesSection() { setEditing(true); }; + const canSave = editing + ? !!form.root_dir.trim() + : form.mode === "server" + ? !!form.git_server && /^[\w.-]+\/[\w.-]+$/.test(form.repo.trim()) + : !!form.id.trim() && !!form.root_dir.trim(); + return ( <>
@@ -57,46 +83,89 @@ export function RepositoriesSection() {
- {editing ? `Edit repository · ${form.id}` : "Register a repository"} + {editing ? `Edit repository · ${form.id}` : "Add a repository"}
-
- setForm({ ...form, id: v })} - placeholder="leeworks-api" - disabled={editing} - /> - setForm({ ...form, root_dir: v })} - placeholder="/var/lib/handler/projects/leeworks" - /> - setForm({ ...form, git_remote: v })} - placeholder="git@github.com:user/repo.git (optional)" - /> - setForm({ ...form, credential_ref: v })} - placeholder="env:VAR / file:/path / db:id" - /> -
-

- {CRED_HELP} -

+ + {!editing && ( +
+ setForm({ ...form, mode: v as "server" | "manual" })} + /> +
+ )} + + {!editing && form.mode === "server" ? ( + <> +
+ setForm({ ...form, repo: v })} + placeholder="me/coolproj" + /> + setForm({ ...form, id: v })} + placeholder="coolproj" + /> +
+

+ The repo is always pulled: Handler derives the remote from the server (ssh when it + has a deploy key, https via the stored token otherwise), clones it under + PROJECTS_ROOT, and keeps it fresh before every run. +

+ + ) : ( + <> +
+ setForm({ ...form, id: v })} + placeholder="leeworks-api" + disabled={editing} + /> + setForm({ ...form, root_dir: v })} + placeholder="/var/lib/handler/projects/leeworks" + /> + setForm({ ...form, git_remote: v })} + placeholder="git@github.com:user/repo.git (optional)" + /> + setForm({ ...form, credential_ref: v })} + placeholder="env:VAR / file:/path / db:host:github.com" + /> +
+

+ {CRED_HELP} +

+ + )} +
- {editing && (
- cred {p.credential_ref || "—"} · added {fmtFull(p.created_at)} + cred {p.credential_ref || "server default"} · added {fmtFull(p.created_at)}
+ {p.git_remote && ( + + )} diff --git a/frontend/components/sections/SchedulesSection.tsx b/frontend/components/sections/SchedulesSection.tsx new file mode 100644 index 0000000..18724ed --- /dev/null +++ b/frontend/components/sections/SchedulesSection.tsx @@ -0,0 +1,196 @@ +/* Schedules — recurring agent spawns. Every interval the worker starts a fresh, + * stateless agent named - with the stored prompt. The canonical + * pattern: keep state in a file in the repo and have the prompt continue from it. */ +"use client"; + +import { useMemo, useState } from "react"; +import { useDashboard } from "@/components/store"; +import { Badge, Button, Card, Input, Select, Textarea, Toggle } from "@/components/ui"; +import { fmtFull } from "@/lib/format"; + +const ROLE_OPTS = [ + { value: "", label: "Role — none" }, + { value: "junior", label: "junior" }, + { value: "senior", label: "senior" }, + { value: "deploy", label: "deploy" }, +]; + +const INTERVAL_OPTS = [ + { value: "900", label: "every 15 minutes" }, + { value: "1800", label: "every 30 minutes" }, + { value: "3600", label: "every hour" }, + { value: "21600", label: "every 6 hours" }, + { value: "86400", label: "every day" }, + { value: "604800", label: "every week" }, +]; + +const TASK_PLACEHOLDER = + "Read @notes.md and continue from where it left off. Before finishing, overwrite " + + "@notes.md with the current state so the next run can pick up from there."; + +function intervalLabel(seconds: number): string { + const opt = INTERVAL_OPTS.find((o) => Number(o.value) === seconds); + if (opt) return opt.label; + if (seconds % 3600 === 0) return `every ${seconds / 3600}h`; + if (seconds % 60 === 0) return `every ${seconds / 60}m`; + return `every ${seconds}s`; +} + +const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "" }; + +export function SchedulesSection() { + const s = useDashboard(); + const [form, setForm] = useState(emptyForm); + + const projectOpts = useMemo( + () => s.projects.map((p) => ({ value: p.id, label: p.id })), + [s.projects], + ); + + const create = async () => { + const ok = await s.createSchedule(s.selectedProjectId, { + name_prefix: form.name_prefix, + task: form.task, + interval_seconds: Number(form.interval), + role: form.role, + }); + if (ok) setForm(emptyForm); + }; + + return ( + <> +
+
Schedules
+
+ Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a + file the prompt reads and overwrites. +
+
+
+ {s.projects.length === 0 ? ( +
Register a repository first.
+ ) : ( + <> +
+
+ setForm({ ...form, name_prefix: v })} + placeholder="nightly" + /> + setForm({ ...form, role: v })} + options={ROLE_OPTS} + /> +
+
+