Skip to content

Epic: Agentic remote coding platform on brutus (Coder + Forgejo + per-agent podman workspaces) #85

Description

@jasonboukheir

Epic: Agentic remote coding platform on brutus (Coder + Forgejo + per-agent podman workspaces)

Build a fully self-hosted, phone-operable agentic coding loop on brutus:

  • File issues and review/merge PRs from my phone (public + mTLS).
  • Self-hosted Forgejo forge. Each AI agent gets its own scoped bot account that can push feature branches and open PRs but cannot push to protected branches — I review & merge as human-in-the-loop.
  • Per-task Coder workspaces (podman containers) running a CLI coding agent (pi), driven for now via Coder's built-in web terminal.
  • Agents reach the local vLLM OpenAI endpoint over HTTP (no GPU passthrough needed) and Forgejo over a scoped token, and nothing else (host egress allowlist).
  • A "Open in Coder" button in each Forgejo repo (à la GitHub Codespaces) that launches a workspace pre-pointed at that repo, gated on login.

Everything declarative per repo conventions (.claude/CLAUDE.md): prefer existing NixOS module options, expose tunables as options (not let), secrets via agenix (never in the store), and write NixOS VM tests for assumptions (no tests that merely assert a config value).


Decisions (locked)

Area Decision
Agent pi — a CLI coding agent baked into the workspace image; pluggable
Agent UI (v1) Coder built-in web terminal (run pi interactively in-browser); AgentAPI/opencode-web deferred
Isolation Coder + podman containers (podman already enabled on brutus), ephemeral per task
Forge Forgejo (services.forgejo), Postgres, behind nginx
Exposure Public internet + mTLS via existing external nginx (:8443) + client-cert setup
LLM backend Existing vLLM-XPU (:8000) / LiteLLM proxy (:3200) — agents are HTTP clients, no GPU in containers
Auth Human login via Pocket ID OIDC (already wired for Coder); agent uses local Forgejo bot account + scoped token

Current state (what already exists on brutus)

  • Coderservices.coder via modules/homelab/services/coder/, Pocket ID OIDC, podman socket, *.code.sunnycareboo.com wildcard, terranix templates staged to ~coder/templates/.
  • podman with dockerCompat, socket at /run/podman/podman.sock (hosts/brutus/virtualization.nix).
  • vLLM-XPU (Qwen3.6 35B) :8000 + LiteLLM :3200 (hosts/brutus/services/vllm-xpu.nix, modules/homelab/services/litellm.nix).
  • nginx reverse proxy w/ ACME (Cloudflare DNS-01), internal :443 + external :8443 mTLS, recommendedProxySettings (modules/homelab/services.nix).
  • Pocket ID OIDC, LLDAP, agenix, Tailscale/Headscale, homelab service registry (modules/homelab/registry.nix).
  • Forgejo — not present.
  • pi agent — not present anywhere in the repo (needs packaging/sourcing).

Tasks

Phase 1 — Forgejo forge (declarative)

  • 1.1 Add forge/git service to modules/homelab/registry.nix (external service → git.sunnycareboo.com, allocate HTTP port).
  • 1.2 New homelab module modules/homelab/services/forgejo/default.nix using services.forgejo:
    • database.type = "postgres" (peer/socket auth, createDatabase = true); lfs.enable = true.
    • settings.server: HTTP_ADDR=127.0.0.1, DOMAIN/ROOT_URL=https://git.sunnycareboo.com/, SSH_PORT/SSH_DOMAIN (reuse host OpenSSH, START_SSH_SERVER=false).
    • settings.service.DISABLE_REGISTRATION = true (accounts created declaratively).
    • Secrets via services.forgejo.secrets.* (file paths → systemd LoadCredential), wired to agenix in hosts/brutus/secrets/secrets.nix. Do not put secrets in settings.
  • 1.3 nginx vhost git.sunnycareboo.com via the existing homelab proxy helper (internal :443 + external :8443 mTLS, websockets on). Reuse recommendedProxySettings; no hand-written /etc.
  • 1.4 Enable git.enable = true in hosts/brutus/services/homelab.nix. First-boot admin = me (Pocket ID OIDC for human login; keep one local admin).
  • 1.5 Bind-mount Forgejo state onto a ZFS dataset (mirror immich/opencloud pattern in hosts/brutus/services/storage.nix) and add to restic backup set.
  • 1.6 VM test: Forgejo boots, serves over nginx, Postgres reachable, git push/clone over HTTPS works.

Phase 2 — Agent bot account, scoped token & branch protection (the human-in-the-loop guarantee)

  • 2.1 Idempotent oneshot systemd unit (after forgejo, User=forgejo) that runs forgejo admin user create --restricted --username pi-bot --must-change-password=false --access-token --access-token-name pi --access-token-scopes "write:repository,write:issue" — guarded so re-runs don't error.
  • 2.2 Capture the generated token into an agenix secret consumed by the workspace template / agent env (the --raw token via generate-access-token).
  • 2.3 Same unit drives the Forgejo API to set branch protection on default branches: POST /repos/{owner}/{repo}/branch_protections with enable_push=true, enable_push_whitelist=true, push_whitelist_usernames=["jasonboukheir"] (excludes pi-bot), required_approvals=1, enable_approvals_whitelist (me). Net effect: pi-bot can push feature/* and open PRs but cannot push to main and cannot self-merge.
  • 2.4 Topology: branch-and-PR, pi-bot is a Write collaborator (NOT admin, so it can't edit protection rules). Document fork-and-PR as the upgrade path for less-trusted agents.
  • 2.5 VM test: pi-bot token can create an issue + push a feature branch + open a PR, and is rejected pushing to main.

Phase 3 — pi agent packaging + workspace image

  • 3.1 Package/source pi (flake input or overlay). Pin it; expose its version/source as an option.
  • 3.2 Build the workspace image with dockerTools.buildLayeredImage: pi + git + coreutils + the Coder agent deps. Reference it from a Coder Docker/podman template (virtualisation.oci-containers not needed — Coder provisions it).
  • 3.3 Configure pi to use the local LLM: OPENAI_BASE_URL → LiteLLM :3200/v1 (preferred, model-routing) or vLLM :8000/v1; key from agenix/EMPTY. Confirm pi speaks OpenAI-compatible API + tool-calling against Qwen3.6.
  • 3.4 Inject the pi-bot Forgejo token + git identity into the workspace env so pi can git push / open PRs via API.

Phase 4 — Coder workspace template (podman, ephemeral) + web-terminal control

  • 4.1 terranix/HCL template in modules/homelab/services/coder/ using the kreuzwerker/docker provider against the podman socket (/run/podman/podman.sock), count = start_count for ephemerality, persistent home volume.
  • 4.2 coder_agent runs the image; web terminal is the primary control surface (run pi interactively in-browser). Verify phone usability of the Coder web terminal.
  • 4.3 Autostop/dormancy to auto-teardown idle workspaces; consider a prebuild warm pool later.
  • 4.4 (Deferred) Wrap pi with AgentAPI behind a coder_app (share=owner) for an opencode-like chat UI once the terminal flow is proven. Coder Tasks is deprecating (ESR Jun 2026, removed ~Sep 2026) — prefer coder_app or Coder Agents (Beta), not Tasks.

Phase 5 — Forgejo ↔ Coder "Open in Coder" button

  • 5.1 Register a Forgejo OAuth2 app; configure Coder external-auth TYPE=gitea, ID=forgejo, explicit AUTH_URL/TOKEN_URL=/login/oauth/access_token/VALIDATE_URL=/login/oauth/userinfo, REGEX=git\.sunnycareboo\.com, redirect https://code.sunnycareboo.com/external-auth/forgejo/callback. So workspaces clone private Forgejo repos with the logged-in user's creds.
  • 5.2 One shared template devcontainer-forgejo with a coder_parameter "git_repo" + coder_external_auth "forgejo"; build from the repo's devcontainer.json via Coder Dev Containers integration (or envbuilder). Repo "owns its workspace" via devcontainer.json.
  • 5.3 Inject the launch button in Forgejo, gated on {{if .IsSigned}}: prefer the native "Open with" applications admin entry (no restart) with OpenURLhttps://code.sunnycareboo.com/templates/devcontainer-forgejo/workspace?param.git_repo={{.CloneButtonOriginLink.HTTPS}}; fall back to overriding custom/templates/repo/clone_panel.tmpl. (No template helper to test file existence — optionally hide via client-side JS check of the contents API.)
  • 5.4 E2E test the private-repo clone path early (known envbuilder + gitea external-auth gotchas: coder#13367, coder#12291).

Phase 6 — Network egress isolation (allowlist Forgejo + vLLM only)

  • 6.1 Host nftables egress allowlist so agent containers can reach only Forgejo :3000 + vLLM/LiteLLM :8000/:3200. Note podman/netavark has no DOCKER-USER chain — supply host forward rules (networking.firewall.filterForward = true for default-deny; extraForwardRules to allow the two destinations).
  • 6.2 Confirm networking.nat alone is not default-deny; the drop policy comes from the nftables firewall forward chain.
  • 6.3 VM test proving default-deny + the two allowlisted destinations behave as intended (per repo conventions — test the assumption, not a config value).

Phase 7 — Phone access (public + mTLS)

  • 7.1 Expose git.sunnycareboo.com + code.sunnycareboo.com on the external :8443 mTLS listener; install a client cert on the phone.
  • 7.2 Verify on a phone: Coder dashboard + web terminal + Forgejo issue/PR review/merge all work behind mTLS. Confirm Coder wildcard access URL + TLS cert covers *.code.sunnycareboo.com for apps/subdomain.

Phase 8 — End-to-end agentic loop

  • 8.1 Demo: file a Forgejo issue from phone → launch a Coder workspace via "Open in Coder" → drive pi in the web terminal to implement it → pi pushes feature/* + opens a PR as pi-bot → I review & merge from phone. main stays protected throughout.
  • 8.2 (Future) Forgejo webhook on issues:opened (HMAC-SHA256 verified) → auto-provision a workspace/agent for the issue. Out of scope for v1.

Research references

Coder NixOS module nixos/modules/services/web-apps/coder.nix; coder_app/external-auth/Open-in-Coder deep links (param.<name>=), Dev Containers/envbuilder, git-clone module registry.coder.com/coder/git-clone/coder@2.0.1. Forgejo services.forgejo (secrets.*LoadCredential), admin CLI user/token creation, branch-protection API, OAuth2 provider, "Open with" apps. Isolation: podman + dockerTools, nftables filterForward/extraForwardRules. vLLM client needs no GPU. (Full source URLs gathered during research — available on request.)

Note

Filed agentically per request. pi is not yet in the repo — Phase 3.1 must source/package it before the loop is functional. The rest is buildable with stock, currently-documented Coder + Forgejo features.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions