From 8adbd3fac459161d98079fa48e3816bd631fead2 Mon Sep 17 00:00:00 2001 From: ml Date: Sat, 1 Aug 2026 07:55:20 +0200 Subject: [PATCH] Add Clay agent interface, fix registry UI routing, and misc improvements - Deploy Clay (claude Code web UI) at agent.ladkau.de behind basic auth; Traefik proxies to clay's HTTPS port 2633 with insecureSkipVerify - Document MCP server persistence: binaries need to be baked into the Dockerfile, config in /opt/clay/data survives rebuilds - Document scheduled agent workflows via Gitea Actions on.schedule with email reporting via Gmail SMTP - Fix registry UI: split /v2/ (registry) and / (UI) into separate Traefik routers; add registry_internal network - Add weekly registry GC cron job (/usr/local/bin/registry-gc) - Remove rate-limit middleware from Gitea router (act_runner polling exceeded 60 req/min limit) - Set Traefik websecure readTimeout: 0 to fix large layer upload 499s --- CLAUDE.md | 12 ++ ansible/group_vars/all/vars.yml | 10 ++ ansible/roles/clay/files/Dockerfile | 20 +++ ansible/roles/clay/handlers/main.yml | 16 ++ ansible/roles/clay/tasks/main.yml | 48 ++++++ .../clay/templates/docker-compose.yml.j2 | 35 ++++ ansible/roles/dashboard/templates/app.py.j2 | 1 + .../roles/traefik/templates/traefik.yml.j2 | 3 + ansible/site.yml | 1 + docs/runbook-configuration.md | 162 ++++++++++++++++++ docs/runbook-provisioning.md | 11 +- scripts/check-services.sh | 1 + scripts/check-vault.sh | 2 + 13 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 ansible/roles/clay/files/Dockerfile create mode 100644 ansible/roles/clay/handlers/main.yml create mode 100644 ansible/roles/clay/tasks/main.yml create mode 100644 ansible/roles/clay/templates/docker-compose.yml.j2 diff --git a/CLAUDE.md b/CLAUDE.md index 06641ea..a6c81a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,18 @@ The mail role deploys four containers on `mail_internal`: `mail-db` (PostgreSQL - Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist - `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured +### Clay (Claude Code web interface) + +Clay (`agent.ladkau.de`) provides a browser-based UI for Claude Code. The role +builds a Docker image from `ansible/roles/clay/files/Dockerfile` (Node 20 + +`@anthropic-ai/claude-code` + `clay-server`) at deploy time. Key details: + +- `ANTHROPIC_API_KEY` is passed as an environment variable from the vault +- Sessions and config are persisted at `/opt/clay/data` → `/root/.clay` in container +- Projects (git repos) are mounted from `/opt/clay/workspace` → `/workspace` +- Traefik basic auth (`clay-auth` middleware) uses `clay_users` from the vault +- Scheduled agent workflows use Gitea Actions `on.schedule` + the existing act_runners + ### Gitea Actions runners The `act_runner` role deploys three `gitea/act_runner` containers (Docker executor). Each runner handles one concurrent job. Key details: diff --git a/ansible/group_vars/all/vars.yml b/ansible/group_vars/all/vars.yml index 12a5c62..237f349 100644 --- a/ansible/group_vars/all/vars.yml +++ b/ansible/group_vars/all/vars.yml @@ -124,3 +124,13 @@ dl_sftp_port: 2223 # Status dashboard (public — cloud.ladkau.de root) dashboard_data_dir: /opt/dashboard + +# Clay — Claude Code web interface (agent.ladkau.de) +domain_clay: "agent.{{ domain_base }}" +clay_data_dir: /opt/clay +clay_users: [] # override in vault with [{username: ..., password: ...}] +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) +# anthropic_api_key: "" # from console.anthropic.com → API keys +# clay_users: +# - username: admin +# password: "your-password" diff --git a/ansible/roles/clay/files/Dockerfile b/ansible/roles/clay/files/Dockerfile new file mode 100644 index 0000000..0f18dc5 --- /dev/null +++ b/ansible/roles/clay/files/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +# Claude Code needs git and bash to operate on repositories +RUN apk add --no-cache git bash curl + +# Install Claude Code CLI +RUN npm install -g @anthropic-ai/claude-code + +# Install clay web server +RUN npm install -g clay-server + +# MCP servers and skills — add packages here to survive container rebuilds +# RUN npm install -g @clay-ai/clay-ralph +# RUN npm install -g @modelcontextprotocol/server-filesystem + +RUN mkdir -p /workspace /root/.clay + +WORKDIR /workspace + +EXPOSE 2633 diff --git a/ansible/roles/clay/handlers/main.yml b/ansible/roles/clay/handlers/main.yml new file mode 100644 index 0000000..00667b7 --- /dev/null +++ b/ansible/roles/clay/handlers/main.yml @@ -0,0 +1,16 @@ +--- +- name: Rebuild and restart clay + community.docker.docker_image: + name: clay:local + build: + path: "{{ clay_data_dir }}" + source: build + force_source: true + state: present + notify: Restart clay + +- name: Restart clay + community.docker.docker_compose_v2: + project_src: "{{ clay_data_dir }}" + state: present + recreate: always diff --git a/ansible/roles/clay/tasks/main.yml b/ansible/roles/clay/tasks/main.yml new file mode 100644 index 0000000..c1fd99f --- /dev/null +++ b/ansible/roles/clay/tasks/main.yml @@ -0,0 +1,48 @@ +--- +- name: Create clay directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ clay_data_dir }}" + - "{{ clay_data_dir }}/data" + - "{{ clay_data_dir }}/workspace" + tags: clay + +- name: Deploy Dockerfile + ansible.builtin.copy: + src: Dockerfile + dest: "{{ clay_data_dir }}/Dockerfile" + owner: root + group: root + mode: "0644" + notify: Rebuild and restart clay + tags: clay + +- name: Deploy Docker Compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ clay_data_dir }}/docker-compose.yml" + owner: root + group: root + mode: "0644" + notify: Restart clay + tags: clay + +- name: Build clay image + community.docker.docker_image: + name: clay:local + build: + path: "{{ clay_data_dir }}" + source: build + state: present + tags: clay + +- name: Start clay + community.docker.docker_compose_v2: + project_src: "{{ clay_data_dir }}" + state: present + tags: clay diff --git a/ansible/roles/clay/templates/docker-compose.yml.j2 b/ansible/roles/clay/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..b39261a --- /dev/null +++ b/ansible/roles/clay/templates/docker-compose.yml.j2 @@ -0,0 +1,35 @@ +# Managed by Ansible — do not edit manually +services: + clay: + image: clay:local + build: + context: {{ clay_data_dir }} + dockerfile: Dockerfile + container_name: clay + restart: unless-stopped + tty: true + environment: + - ANTHROPIC_API_KEY={{ anthropic_api_key }} + volumes: + - {{ clay_data_dir }}/data:/root/.clay + - {{ clay_data_dir }}/workspace:/workspace + command: ["clay-server", "--yes", "-p", "2633"] + networks: + - traefik_public + labels: + - "traefik.enable=true" + - "traefik.http.routers.clay.rule=Host(`{{ domain_clay }}`)" + - "traefik.http.routers.clay.entrypoints=websecure" + - "traefik.http.routers.clay.tls.certresolver=letsencrypt" + - "traefik.http.services.clay.loadbalancer.server.scheme=https" + - "traefik.http.services.clay.loadbalancer.server.port=2633" + - "traefik.http.routers.clay.middlewares=clay-auth" +{% set ns = namespace(entries=[]) %} +{% for user in clay_users %} +{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %} +{% endfor %} + - "traefik.http.middlewares.clay-auth.basicauth.users={{ ns.entries | join(',') }}" + +networks: + traefik_public: + external: true diff --git a/ansible/roles/dashboard/templates/app.py.j2 b/ansible/roles/dashboard/templates/app.py.j2 index 841ae47..627cac3 100644 --- a/ansible/roles/dashboard/templates/app.py.j2 +++ b/ansible/roles/dashboard/templates/app.py.j2 @@ -25,6 +25,7 @@ SERVICES = [ ("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"), ("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"), ("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"), + ("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"), ] {% raw %} diff --git a/ansible/roles/traefik/templates/traefik.yml.j2 b/ansible/roles/traefik/templates/traefik.yml.j2 index 10e7efb..0da0fde 100644 --- a/ansible/roles/traefik/templates/traefik.yml.j2 +++ b/ansible/roles/traefik/templates/traefik.yml.j2 @@ -30,6 +30,9 @@ entryPoints: imaps: address: ":993" +serversTransport: + insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633 + providers: docker: exposedByDefault: false diff --git a/ansible/site.yml b/ansible/site.yml index e30261e..9259bae 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -15,4 +15,5 @@ - k8s - vaultwarden - dl + - clay - dashboard diff --git a/docs/runbook-configuration.md b/docs/runbook-configuration.md index 242b6c0..01b2a4d 100644 --- a/docs/runbook-configuration.md +++ b/docs/runbook-configuration.md @@ -25,6 +25,7 @@ The script checks these endpoints and verifies the expected HTTP status code: | `https://k8s.ladkau.de` | 200 | Placeholder page | | `https://vault.ladkau.de` | 200 | Vaultwarden web vault | | `https://dl.ladkau.de` | 200 | Public download server | +| `https://agent.ladkau.de` | 401 | Clay — basic-auth prompt, correct without credentials | A `000` result means the connection was refused or timed out — a container that did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on @@ -468,3 +469,164 @@ and tags, inspecting image manifests and digests, and deleting images. Traefik routes `/v2/` (Docker API) to the registry container and everything else to the UI container — both on the same domain, so no CORS configuration is needed. `docker push` and `docker pull` work exactly as before. + +## 9. Clay — Claude Code web interface + +Clay is a browser-based front end for Claude Code, served at +`https://agent.ladkau.de`. Access is protected by HTTP basic auth using the +`clay_users` credentials from the vault. + +### 9.1 Add projects + +The container mounts `/opt/clay/workspace` at `/workspace`. Clone Gitea +repositories there to make them available to Claude Code: + +```bash +ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 +cd /opt/clay/workspace +git clone https://gitea.ladkau.de//.git +``` + +Then in the clay web UI, use **Add project** and point it at `/workspace/`. + +### 9.2 MCP servers and skills + +Clay configuration (MCP server entries, session history, project registrations) +is stored in `~/.clay`, which is mounted from `/opt/clay/data` on the host and +survives container rebuilds. + +MCP server **binaries** installed interactively inside a running container (e.g. +via `npm install -g` in a clay terminal) are part of the container filesystem +and are lost when the image is rebuilt. + +To make a package survive rebuilds, add it to +`ansible/roles/clay/files/Dockerfile` and redeploy: + +```dockerfile +# MCP servers and skills — add packages here to survive container rebuilds +RUN npm install -g @clay-ai/clay-ralph +RUN npm install -g @modelcontextprotocol/server-filesystem +``` + +```bash +ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass +``` + +Ansible detects the Dockerfile change and triggers a rebuild automatically. + +### 9.3 Rebuild after clay updates + +The Docker image is built from `ansible/roles/clay/files/Dockerfile` at deploy +time. To pull upstream changes to `clay-server` or `@anthropic-ai/claude-code`, +rebuild the image and restart the container: + +```bash +ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass +``` + +The Ansible role forces a rebuild whenever the Dockerfile changes; to force a +rebuild without a Dockerfile change, delete the cached image first: + +```bash +ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \ + "sudo docker image rm clay:local && sudo docker compose -f /opt/clay/docker-compose.yml up -d" +``` + +### 9.4 Interactive sessions vs. scheduled runs + +Clay and Gitea Actions serve different purposes and are independent of each +other: + +| | Clay | Gitea Actions | +|---|---|---| +| **Trigger** | You, in the browser | Schedule or manual button click | +| **Use case** | Exploratory, iterative work | Recurring automated tasks | +| **Project** | Repos cloned into `/opt/clay/workspace/` | Checked out fresh per run | +| **Output** | Interactive UI | Logs + optional email | + +Clay has no scheduling API — there is no way to trigger a Clay session +programmatically. For recurring agent tasks, Gitea Actions runs Claude Code +directly on the act_runners, independent of Clay. If both operate on the same +git repository the context is identical, but the sessions are separate. + +### 9.5 Scheduled Claude Code runs via Gitea Actions + +The act_runner containers can run Claude Code on a recurring schedule using +Gitea's `on.schedule` trigger. Adding `workflow_dispatch` alongside it allows +the same workflow to be triggered manually from the Gitea UI without waiting +for the next scheduled run. + +**Set up secrets** + +In the repository go to **Settings → Secrets → Actions** (or +**Site Administration → Actions → Secrets** for org-wide secrets) and add: + +| Secret | Value | +|--------|-------| +| `ANTHROPIC_API_KEY` | API key from the vault | +| `SMTP_USERNAME` | Gmail address used as the SMTP sender | +| `SMTP_PASSWORD` | Gmail App Password — myaccount.google.com → Security → App passwords | + +**Workflow file** + +Add `.gitea/workflows/weekly-agent.yml` to the repository: + +```yaml +name: Weekly agent run +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + workflow_dispatch: # also runnable manually from the Gitea Actions UI + +jobs: + agent: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Run agent + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + claude --print "Your prompt here" \ + --output-format text \ + | tee agent-output.txt + + - name: Send report email + env: + SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + run: | + python3 - <<'EOF' + import smtplib, os + from email.mime.text import MIMEText + + with open("agent-output.txt") as f: + body = f.read() + + msg = MIMEText(body) + msg["Subject"] = "Weekly agent report" + msg["From"] = os.environ["SMTP_USERNAME"] + msg["To"] = os.environ["SMTP_USERNAME"] + + with smtplib.SMTP("smtp.gmail.com", 587) as s: + s.starttls() + s.login(os.environ["SMTP_USERNAME"], os.environ["SMTP_PASSWORD"]) + s.send_message(msg) + EOF +``` + +Adjust the `cron` expression, prompt, and `msg["To"]` address as needed. + +**Run manually** + +Go to **Repository → Actions**, select **Weekly agent run** from the workflow +list, and click **Run workflow**. The run appears immediately in the Actions log. + +**Verify the schedule is active** + +After pushing the workflow file, scheduled runs appear automatically in +**Repository → Actions** at the configured time. diff --git a/docs/runbook-provisioning.md b/docs/runbook-provisioning.md index 9cd2192..e2e4c21 100644 --- a/docs/runbook-provisioning.md +++ b/docs/runbook-provisioning.md @@ -81,6 +81,7 @@ Let's Encrypt certificates on first start and DNS must resolve at that point. | k8s.ladkau.de | A → server IP | | vault.ladkau.de | A → server IP | | dl.ladkau.de | A → server IP | +| agent.ladkau.de | A → server IP | ### 4. Create the vault and populate secrets @@ -121,6 +122,13 @@ registry_users: - username: alice password: "your-password" +# Clay — Claude Code web interface at agent.ladkau.de +# Generate API key at console.anthropic.com → API keys +anthropic_api_key: "" +clay_users: + - username: admin + password: "your-password" + # Gitea Actions runners — token obtained after Gitea is running # See step 3.4 of runbook-configuration.md; replace after Gitea admin account is created gitea_runner_registration_token: "placeholder" @@ -221,7 +229,8 @@ This runs all roles in order: | 10 | `k8s` | Placeholder page at k8s.ladkau.de | | 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de | | 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload | -| 13 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats | +| 13 | `clay` | Claude Code web interface at agent.ladkau.de — basic auth, Anthropic API key required | +| 14 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats | > **Note:** The `act_runner` role requires `gitea_runner_registration_token` in the > vault, which can only be obtained after Gitea is running and an admin account has diff --git a/scripts/check-services.sh b/scripts/check-services.sh index ecf0d23..70d1bbd 100644 --- a/scripts/check-services.sh +++ b/scripts/check-services.sh @@ -18,6 +18,7 @@ CHECKS=( "https://k8s.ladkau.de 200 10" "https://vault.ladkau.de 200 10" "https://dl.ladkau.de 200 10" + "https://agent.ladkau.de 401 10" ) if ! command -v curl &>/dev/null; then diff --git a/scripts/check-vault.sh b/scripts/check-vault.sh index 17fff83..9088e23 100755 --- a/scripts/check-vault.sh +++ b/scripts/check-vault.sh @@ -44,6 +44,7 @@ required_scalars = [ "roundcube_db_password", "roundcube_des_key", "vaultwarden_admin_token", + "anthropic_api_key", ] # Required non-empty lists (must contain at least one entry) @@ -51,6 +52,7 @@ required_lists = [ "traefik_dashboard_users", "registry_users", "dovecot_users", + "clay_users", ] # Optional scalars: validated only when present — (key, validator_fn, hint)