commit 357791d6e39705701df1d12d7b3a16547b5f3dac Author: William Turner Date: Sun Aug 23 10:25:57 2026 +0000 Scaffold Matrix + GitOps + Claude automation stack diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c31f2a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy to .env and fill in. Never commit the real .env. + +# --- domain / TLS --- +ACME_EMAIL=you@example.com +MATRIX_SERVER_NAME=matrix.apps.williamturner.eu +AGENT_HOSTNAME=agent.apps.williamturner.eu +# Set to true ONLY for the first-boot window while creating the bot account, +# then back to false (or unset) and redeploy. See README. +MATRIX_ALLOW_REGISTRATION=false + +# --- gitea --- +GITEA_URL=https://gitea.apps.williamturner.eu +GITEA_TOKEN= +GITEA_WEBHOOK_SECRET= +# Image the agent runs from — built and pushed by .gitea/workflows/build.yml +GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu//claude-agent:latest + +# --- anthropic --- +ANTHROPIC_API_KEY= + +# --- matrix bot --- +MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu +MATRIX_BOT_TOKEN= +MATRIX_CONTROL_ROOM_ID= + +# --- portainer (GitOps redeploy) --- +PORTAINER_STACK_WEBHOOK_URL= + +# --- gitea actions runner --- +ACT_RUNNER_REGISTRATION_TOKEN= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..97d174d --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,26 @@ +name: build-agent +on: + push: + branches: [main] + paths: + - "agent/**" + - ".gitea/workflows/build.yml" + +jobs: + build-and-push: + # If your act_runner advertises a different label than "docker" (check its + # config.yaml / GITEA_RUNNER_LABELS), update this to match. + runs-on: docker + steps: + - uses: actions/checkout@v4 + + - name: Log in to Gitea registry + run: | + echo "${{ secrets.GITEA_TOKEN }}" | docker login "${{ vars.GITEA_HOST }}" \ + -u "${{ gitea.actor }}" --password-stdin + + - name: Build and push claude-agent image + run: | + IMAGE="${{ vars.GITEA_HOST }}/${{ gitea.repository }}/claude-agent:latest" + docker build -t "$IMAGE" ./agent + docker push "$IMAGE" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..12eb1d3 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# gitops-automation + +Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See +`~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale. + +Three things this gives you: +- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored + review comment. +- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image + (Gitea Actions) and redeploys the stack (Portainer webhook). +- **Chat-driven coding agent**: `!claude owner/repo ` in the Matrix control + room clones the repo, runs Claude Code, and opens a PR with the result. + +Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge. + +## Prerequisites (one-time, on the VPS) + +```bash +sudo usermod -aG docker william # then start a new shell/session +docker network create web +``` + +## Bring-up order + +1. **DNS** — confirm these resolve to `217.160.66.143`: + `gitea.apps.williamturner.eu`, `portainer.apps.williamturner.eu`, + `matrix.apps.williamturner.eu`, `agent.apps.williamturner.eu`. + +2. **Front Gitea with Traefik**: edit `~/gitea/docker-compose.yml` — add it to the `web` + network and Traefik labels (mirror the `matrix-homeserver` block in this repo's + `docker-compose.yml`, using port `3000` as the service port and rule + `` Host(`gitea.apps.williamturner.eu`) ``). Update `~/gitea/data/gitea/conf/app.ini`: + `DOMAIN` and `ROOT_URL` → `https://gitea.apps.williamturner.eu/`. Recreate the container, + confirm the HTTPS URL works, *then* remove `3000:3000` from the port mapping. + +3. **Front Portainer with Traefik**: same pattern on `~/portainer-compose.yaml`, service + port `9443` (Portainer serves TLS itself on that port — either terminate TLS at Traefik + with `traefik.http.services.portainer.loadbalancer.server.scheme=https` and + `serversTransport` with insecure skip-verify, or simplest: also expose Portainer's plain + HTTP port internally and point Traefik at that instead). Confirm + `https://portainer.apps.williamturner.eu` works before removing `9443:9443`. + +4. **Create the `gitops-automation` repo in Gitea** (via `http://217.160.66.143:3000` if + step 2 isn't done yet, otherwise the HTTPS URL), push this directory to it. + +5. **Gitea API token**: user Settings → Applications → generate a token with repo + + webhook scopes. Put it in `.env` as `GITEA_TOKEN`. + +6. **Gitea Actions runner**: admin Settings → Actions → Runners → create registration + token → `.env` as `ACT_RUNNER_REGISTRATION_TOKEN`. Also set repo-level Actions + variables `GITEA_HOST` (e.g. `gitea.apps.williamturner.eu`) and secret `GITEA_TOKEN` + (Settings → Actions → Variables/Secrets on the repo) — the workflow in + `.gitea/workflows/build.yml` reads those. + +7. **First image build** (registry is empty until Actions runs once): + ```bash + cp .env.example .env # fill in values as you go + docker build -t "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" ./agent + docker login -u + docker push "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" + ``` + +8. **Bring up the stack**, ideally as a Portainer "Repository" stack pointed at this repo + (so it's also the GitOps redeploy target) — or directly: + ```bash + docker compose up -d + ``` + +9. **First boot: Matrix bot account** — with `MATRIX_ALLOW_REGISTRATION=true` in `.env`, + redeploy `matrix-homeserver`, then register the bot: + ```bash + curl -s https://matrix.apps.williamturner.eu/_matrix/client/v3/register \ + -H 'Content-Type: application/json' \ + -d '{"username":"claude-bot","password":"","auth":{"type":"m.login.dummy"}}' + ``` + This returns an `access_token` — put it in `.env` as `MATRIX_BOT_TOKEN`. Then set + `MATRIX_ALLOW_REGISTRATION=false` and redeploy `matrix-homeserver` again. + +10. **Control room**: from any Matrix client logged in as yourself on this homeserver, + create a room, invite `@claude-bot:matrix.apps.williamturner.eu`, copy the room ID + into `.env` as `MATRIX_CONTROL_ROOM_ID`. Redeploy `claude-agent`. + +11. **Portainer stack webhook**: in the stack's settings, enable the webhook, copy the URL + into `.env`/repo secrets as `PORTAINER_STACK_WEBHOOK_URL`. + +12. **Gitea webhooks** on each repo you want automation for: + - push → `main` → `PORTAINER_STACK_WEBHOOK_URL` (only needed on *this* repo, for + GitOps redeploy of the automation stack itself) + - pull request (opened, synchronized) → `https://agent.apps.williamturner.eu/webhooks/gitea`, + secret = `GITEA_WEBHOOK_SECRET`, on every repo you want auto-reviewed. + +13. **Firewall**: `sudo ufw allow 80/tcp 443/tcp`; once the HTTPS routes above are all + confirmed working, `sudo ufw delete allow 3000/tcp` and `sudo ufw delete allow 9443/tcp`. + +## Smoke test + +- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment. +- In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a + "working on it" reply, then a PR link. +- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy. + +## Notes + +- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git + checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`), + so even a misbehaving prompt can't push directly or touch `main`. +- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only. +- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check + `GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered. diff --git a/agent/.dockerignore b/agent/.dockerignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/agent/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..70e05a0 --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + openssh-client \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY src ./src + +RUN git config --global user.email "claude-agent@apps.williamturner.eu" \ + && git config --global user.name "claude-agent" \ + && git config --global --add safe.directory '*' + +ENV NODE_ENV=production +EXPOSE 3001 +CMD ["node", "src/server.js"] diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..e90b8fa --- /dev/null +++ b/agent/package.json @@ -0,0 +1,14 @@ +{ + "name": "claude-gitops-agent", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "express": "^4.19.2", + "matrix-bot-sdk": "^0.7.1" + } +} diff --git a/agent/src/gitea.js b/agent/src/gitea.js new file mode 100644 index 0000000..406e3ee --- /dev/null +++ b/agent/src/gitea.js @@ -0,0 +1,54 @@ +const GITEA_URL = process.env.GITEA_URL; +const GITEA_TOKEN = process.env.GITEA_TOKEN; + +function authHeaders() { + return { + Authorization: `token ${GITEA_TOKEN}`, + "Content-Type": "application/json", + }; +} + +async function assertOk(res, action) { + if (!res.ok) { + throw new Error(`${action} failed: ${res.status} ${await res.text()}`); + } +} + +export async function postPRComment(owner, repo, index, body) { + const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/issues/${index}/comments`; + const res = await fetch(url, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ body }), + }); + await assertOk(res, "post PR comment"); +} + +export async function createBranch(owner, repo, newBranch, oldBranch = "main") { + const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`; + const res = await fetch(url, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }), + }); + await assertOk(res, "create branch"); +} + +export async function createPullRequest(owner, repo, { head, base = "main", title, body }) { + const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`; + const res = await fetch(url, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ head, base, title, body }), + }); + await assertOk(res, "create PR"); + return res.json(); +} + +// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys. +export function authenticatedCloneUrl(cloneUrl) { + const u = new URL(cloneUrl); + u.username = "claude-agent"; + u.password = GITEA_TOKEN; + return u.toString(); +} diff --git a/agent/src/matrixBot.js b/agent/src/matrixBot.js new file mode 100644 index 0000000..c7d138c --- /dev/null +++ b/agent/src/matrixBot.js @@ -0,0 +1,55 @@ +import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk"; +import { runChatTask } from "./runner.js"; + +const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL; +const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN; +const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID; +const GITEA_URL = process.env.GITEA_URL; + +// "!claude owner/repo do the thing" — everything after the repo slug is the instruction. +function parseCommand(text) { + const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s); + if (!match) return null; + const [, repoFullName, instruction] = match; + return { repoFullName, instruction: instruction.trim() }; +} + +export async function startMatrixBot() { + if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) { + console.warn("Matrix env vars not set — skipping bot startup"); + return; + } + + const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json"); + const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage); + AutojoinRoomsMixin.setupOnClient(client); + + const selfUserId = await client.getUserId(); + + client.on("room.message", async (roomId, event) => { + // Invite-only control room enforces who can reach the bot at all; this just + // scopes command handling to that one room and ignores the bot's own messages. + if (roomId !== CONTROL_ROOM_ID) return; + if (event.sender === selfUserId) return; + const body = event.content?.body; + if (!body) return; + + const cmd = parseCommand(body); + if (!cmd) return; + + const [owner, repo] = cmd.repoFullName.split("/"); + await client.sendText(roomId, `Working on it: ${cmd.repoFullName} — ${cmd.instruction}`); + + try { + const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`; + const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction }); + await client.sendText(roomId, `Opened PR: ${pr.html_url}`); + } catch (err) { + console.error("chat task failed", err); + await client.sendText(roomId, `Failed: ${err.message}`); + } + }); + + await client.start(); + console.log("Matrix bot started, room:", CONTROL_ROOM_ID); +} diff --git a/agent/src/runner.js b/agent/src/runner.js new file mode 100644 index 0000000..12bc766 --- /dev/null +++ b/agent/src/runner.js @@ -0,0 +1,99 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js"; + +const execFileAsync = promisify(execFile); +const WORKSPACE_ROOT = "/workspace"; + +async function run(cmd, args, opts = {}) { + const { stdout } = await execFileAsync(cmd, args, { + maxBuffer: 1024 * 1024 * 32, + ...opts, + }); + return stdout; +} + +async function withWorkspace(fn) { + await mkdir(WORKSPACE_ROOT, { recursive: true }); + const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "job-")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// Runs Claude Code headless. Unattended containers have no TTY to answer permission +// prompts, so this trusts the sandboxing of the throwaway clone dir instead: +// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so +// Claude can never push/commit/checkout itself — this script owns those steps. +async function runClaude(cwd, prompt, { allowEdits }) { + const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"]; + if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit"); + + const args = [ + "-p", prompt, + "--output-format", "text", + "--permission-mode", "bypassPermissions", + "--disallowedTools", disallowed.join(","), + ]; + + return run("claude", args, { cwd }); +} + +export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, prBody }) { + return withWorkspace(async (dir) => { + const authedUrl = authenticatedCloneUrl(cloneUrl); + await run("git", ["clone", "--quiet", authedUrl, dir]); + await run("git", ["fetch", "--quiet", "origin", ref], { cwd: dir }); + await run("git", ["checkout", "--quiet", ref], { cwd: dir }); + + const prompt = [ + "You are reviewing a pull request. Diff HEAD against the base branch (origin/main)", + "and give a concise, specific code review: correctness bugs first, then", + "simplification/reuse/efficiency notes. Read-only — do not edit any files.", + `PR title: ${prTitle}`, + `PR description:\n${prBody}`, + ].join("\n"); + + return runClaude(dir, prompt, { allowEdits: false }); + }); +} + +export async function runChatTask({ owner, repo, cloneUrl, instruction }) { + return withWorkspace(async (dir) => { + const authedUrl = authenticatedCloneUrl(cloneUrl); + await run("git", ["clone", "--quiet", authedUrl, dir]); + + const branch = `claude/${crypto.randomBytes(4).toString("hex")}`; + await createBranch(owner, repo, branch); + await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir }); + await run("git", ["checkout", "--quiet", branch], { cwd: dir }); + + const prompt = [ + "Implement the change described below in this repository. Make the smallest", + "correct change that satisfies it. Do not run git commit, git push, or git", + "checkout yourself — just edit files; committing and pushing happens separately.", + `Instruction: ${instruction}`, + ].join("\n"); + + await runClaude(dir, prompt, { allowEdits: true }); + + await run("git", ["add", "-A"], { cwd: dir }); + const status = await run("git", ["status", "--porcelain"], { cwd: dir }); + if (!status.trim()) { + throw new Error("Claude made no changes for this instruction"); + } + await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir }); + await run("git", ["push", "--quiet", "origin", branch], { cwd: dir }); + + return createPullRequest(owner, repo, { + head: branch, + title: `claude: ${instruction}`.slice(0, 200), + body: `Requested via Matrix:\n\n> ${instruction}`, + }); + }); +} diff --git a/agent/src/server.js b/agent/src/server.js new file mode 100644 index 0000000..968bb2e --- /dev/null +++ b/agent/src/server.js @@ -0,0 +1,66 @@ +import express from "express"; +import crypto from "node:crypto"; +import { postPRComment } from "./gitea.js"; +import { reviewPullRequest } from "./runner.js"; +import { startMatrixBot } from "./matrixBot.js"; + +const app = express(); +app.use( + express.json({ + verify: (req, _res, buf) => { + req.rawBody = buf; + }, + }) +); + +const PORT = process.env.PORT || 3001; +const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET; + +function verifySignature(req) { + if (!WEBHOOK_SECRET) return false; + const sig = req.get("X-Gitea-Signature"); + if (!sig) return false; + const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex"); + const sigBuf = Buffer.from(sig, "hex"); + const expBuf = Buffer.from(expected, "hex"); + return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf); +} + +app.get("/healthz", (_req, res) => res.send("ok")); + +app.post("/webhooks/gitea", async (req, res) => { + if (!verifySignature(req)) { + return res.status(401).send("bad signature"); + } + // Ack immediately — Gitea has a short webhook timeout and the review itself takes a while. + res.status(202).send("accepted"); + + const event = req.get("X-Gitea-Event"); + const body = req.body; + + try { + if (event === "pull_request" && ["opened", "synchronize"].includes(body.action)) { + const { repository, pull_request: pr } = body; + const [owner, repo] = repository.full_name.split("/"); + const review = await reviewPullRequest({ + owner, + repo, + ref: pr.head.sha, + cloneUrl: repository.clone_url, + prTitle: pr.title, + prBody: pr.body || "", + }); + await postPRComment(owner, repo, pr.number, review); + } + } catch (err) { + console.error("webhook handling failed:", err); + } +}); + +app.listen(PORT, () => { + console.log(`claude-agent listening on :${PORT}`); +}); + +startMatrixBot().catch((err) => { + console.error("matrix bot failed to start:", err); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..099da01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,96 @@ +services: + traefik: + image: traefik:v3.1 + container_name: traefik + restart: unless-stopped + command: + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=web" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--log.level=INFO" + ports: + - "80:80" + - "443:443" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "traefik_letsencrypt:/letsencrypt" + networks: + - web + + # Routes for services that live in OTHER compose files (Gitea, Portainer) are added + # as labels on those containers directly, not here — see README "Fronting existing + # services" section. + + matrix-homeserver: + image: ghcr.io/continuwuity/continuwuity:latest + container_name: matrix-homeserver + restart: unless-stopped + environment: + CONTINUWUITY_SERVER_NAME: ${MATRIX_SERVER_NAME} + CONTINUWUITY_DATABASE_PATH: /var/lib/continuwuity + CONTINUWUITY_ADDRESS: 0.0.0.0 + CONTINUWUITY_PORT: 8008 + # Private control-room bot only — no federation, no open registration. + # Registration is flipped on temporarily, once, to create the bot account + # (see README "First boot: Matrix bot account"). + CONTINUWUITY_ALLOW_FEDERATION: "false" + CONTINUWUITY_ALLOW_REGISTRATION: ${MATRIX_ALLOW_REGISTRATION:-false} + volumes: + - matrix_data:/var/lib/continuwuity + networks: + - web + labels: + - "traefik.enable=true" + - "traefik.http.routers.matrix.rule=Host(`${MATRIX_SERVER_NAME}`)" + - "traefik.http.routers.matrix.entrypoints=websecure" + - "traefik.http.routers.matrix.tls.certresolver=letsencrypt" + - "traefik.http.services.matrix.loadbalancer.server.port=8008" + + claude-agent: + image: ${GITEA_REGISTRY_IMAGE} + container_name: claude-agent + restart: unless-stopped + env_file: .env + volumes: + - agent_workspace:/workspace + networks: + - web + labels: + - "traefik.enable=true" + - "traefik.http.routers.agent.rule=Host(`${AGENT_HOSTNAME}`)" + - "traefik.http.routers.agent.entrypoints=websecure" + - "traefik.http.routers.agent.tls.certresolver=letsencrypt" + - "traefik.http.services.agent.loadbalancer.server.port=3001" + + act_runner: + image: gitea/act_runner:latest + container_name: act_runner + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: ${GITEA_URL} + GITEA_RUNNER_REGISTRATION_TOKEN: ${ACT_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_NAME: gitops-vps-runner + GITEA_RUNNER_LABELS: docker:docker://node:22-bookworm + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - act_runner_data:/data + networks: + - web + +networks: + web: + external: true + +volumes: + traefik_letsencrypt: + matrix_data: + agent_workspace: + act_runner_data: