Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Build context for container/Dockerfile (built from the repo root so the Node
# build stage can compile agent-server). Keep the context lean: the build stage
# runs `npm ci` itself, so host node_modules/dist must never be shipped (stale
# artifacts + huge context). Docs/git history are irrelevant to the image.
node_modules
dist
.gen
.git
.github
docs
test
*.log
.env
.env.local
.DS_Store
# container/ runtime artefacts that are NOT baked into the image (the host
# passes seccomp by absolute path at `docker run` time; scripts/md are docs).
container/*.md
container/run-outer.sh
container/smoke.sh
container/gen-seccomp.sh
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ WORKSPACE_DIR=/abs/path/to/workspace

# optional — bearer auth on /v1/*. Leave unset for loopback-only single-user dev.
# AGENT_SERVER_TOKEN=

# optional — containerised apps (Stage 1). Builder-agent assets live under builder-agent/.
# Seed each fresh project from a baked-in template (build caches skipped):
# APPX_TEMPLATE_DIR=./builder-agent/templates/vite-spa
# Container runtime the deploy-app skill + injected prompt reference (podman default;
# use docker for macOS Docker Desktop):
# APP_CONTAINER_RUNTIME=podman
# Wire the deploy-app skill into local dev runs (use an absolute path — it is passed
# through unresolved and the runtime cwd is the project dir):
# PI_SKILL_PATHS=/abs/path/to/agent-server/builder-agent/skills/deploy-app
50 changes: 50 additions & 0 deletions .github/workflows/container-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#
# Stage 2 infra smoke — the nested-rootless-podman chain, guarded independently
# of whichever VM is used for manual iteration.
#
# GitHub's ubuntu-latest runners are full VMs (not containers), so the file-cap
# newuidmap + native-overlay recipe works there exactly as on the Hetzner box.
# This builds the outer image, starts agent-server inside it, and runs the
# deploy-app skill's literal command sequence end-to-end (build the seeded Vite
# template under nested podman, run DEV + PROD, redeploy, survive a restart) —
# all WITHOUT an LLM. See scripts/container-smoke.sh and
# docs/plans/builder-containers-plan.md (Stage 2).
#
# Manual (workflow_dispatch) + nightly so it never blocks normal PRs (the build
# is heavy) but still catches infra drift on the proven recipe.
name: container-smoke

on:
workflow_dispatch:
schedule:
# 03:17 UTC nightly (off the top of the hour to dodge scheduler congestion).
- cron: "17 3 * * *"

# Don't pile up nightly/dispatch runs on the same ref.
concurrency:
group: container-smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
name: Nested rootless podman chain (no LLM)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

# Docker is preinstalled on ubuntu-latest runners; show what we're on.
- name: Environment
run: |
docker --version
uname -rm
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || true

- name: Run the Stage 2 container smoke
# No ANTHROPIC_API_KEY needed: the agent never runs an LLM here — the
# smoke executes the deploy skill's literal bash commands directly.
run: ./scripts/container-smoke.sh

- name: Outer container logs on failure
if: failure()
run: docker logs builder-outer || true
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,41 @@ All via env vars (see `.env.example`):
| `AGENT_SERVER_HOST` | no | `127.0.0.1` | Bind host. |
| `AGENT_SERVER_PORT` | no | `4001` | Bind port. |
| `AGENT_SERVER_TOKEN` | no | — | If set, `/v1/*` requires `Authorization: Bearer <token>`. |
| `APPX_TEMPLATE_DIR` | no | — | App template recursively copied into a project dir the first time it is created (build caches skipped). Absent ⇒ projects start empty. Must exist if set. |
| `APP_CONTAINER_RUNTIME` | no | `podman` | Container runtime the deploy-app skill + injected prompt reference. Use `docker` for macOS Docker Desktop in local dev. |

Auth is opt-in: loopback-only single-user dev can leave `AGENT_SERVER_TOKEN`
unset. Set it for shared hosts or any deployment where another local process
could reach the port.

### Containerised apps (Stage 1)

New projects can be seeded from a baked-in app template and deployed as DEV +
PROD containers. The builder-agent assets (the deploy skill + app template) live
under `builder-agent/`. For local dev:

```sh
WORKSPACE_DIR=/abs/path/to/workspace \
APPX_TEMPLATE_DIR="$PWD/builder-agent/templates/vite-spa" \
APP_CONTAINER_RUNTIME=docker \
PI_SKILL_PATHS="$PWD/builder-agent/skills/deploy-app" \
npm run dev
```

- `APPX_TEMPLATE_DIR` seeds the provisional Vite SPA template (a lean,
single-runtime-target Dockerfile served by nginx) into each fresh project.
- `PI_SKILL_PATHS` wires in the `deploy-app` skill so the builder agent knows
the build/run/redeploy/promote conventions. The outer container image bakes
both in at fixed paths (Stage 2).
- Ports + public URLs come from the control plane (appx) on project create and
are written to each project's `.pi/deployment.json`; the agent never invents a
port.

> The shell above exports the vars (so `$PWD` expands). When using a `.env`
> file, Node's `--env-file` does **not** expand `$PWD` — write real paths, and
> make `PI_SKILL_PATHS` **absolute** (it is passed through unresolved and the
> runtime cwd is the project dir, not the agent-server repo).

## Filesystem layout

Everything lives under `WORKSPACE_DIR`, so a single mounted volume makes projects
Expand Down
97 changes: 97 additions & 0 deletions builder-agent/skills/deploy-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
name: deploy-app
description: Build and run a project's app as DEV + PROD containers on the ports the control plane allocated. Use whenever the user wants to see, deploy, refine, or promote their app.
---

# deploy-app

Deploy this project as **two containers built from the same image** — a DEV
instance you iterate against and a PROD instance that stays stable until you
promote. The control plane (appx) owns the ports and public URLs; you never
choose a port. Read them from `.pi/deployment.json`.

The container runtime is `$APP_CONTAINER_RUNTIME` (e.g. `podman` in the builder
container, `docker` in local macOS dev). Use that variable in every command —
never hardcode `podman` or `docker`.

## The contract

- **dev = prod.** One Dockerfile, one build target, **no `--target`**. DEV and
PROD differ only by image tag, container name, and host port.
- **The app listens on a container port** (a template detail, e.g. `8080`) that
is **not** the reserved host port. Always map `-p <reservedHostPort>:<containerPort>`.
- **Never pass secrets into app containers.** Do not forward `ANTHROPIC_API_KEY`,
`OPENAI_API_KEY`, or any `*_API_KEY` into `run` with `-e`. The app does not
need LLM credentials.
- **Loopback only.** Do not publish on `0.0.0.0`; appx is the only edge. Do not
use `--network=host`.
- **Use fully-qualified image refs** in Dockerfiles (`docker.io/library/...`).

## 1. Read the deployment metadata

```bash
cat .pi/deployment.json
```

It looks like:

```json
{
"dev": { "port": 10006, "url": "https://eventx-dev.example.com" },
"prod": { "port": 10007, "url": "https://eventx.example.com" }
}
```

Use `dev.port`/`dev.url` for DEV and `prod.port`/`prod.url` for PROD. Find the
container port the app listens on in the project's Dockerfile (`EXPOSE` / the
server's bind port).

## 2. Deploy / redeploy DEV (the iterate loop)

Rebuild the image and replace the DEV container. This is idempotent — stop and
remove any existing instance first so containers never accumulate.

```bash
$APP_CONTAINER_RUNTIME build -t <project>-app:dev .
$APP_CONTAINER_RUNTIME rm -f <project>-app-dev 2>/dev/null || true
$APP_CONTAINER_RUNTIME run -d --name <project>-app-dev \
-p <devPort>:<containerPort> <project>-app:dev
```

Every refinement rebuilds **DEV only**; PROD's URL stays stable while the user
iterates.

## 3. Promote to PROD

When the user is happy with DEV, rebuild PROD from the current source so it
matches what they approved:

```bash
$APP_CONTAINER_RUNTIME build -t <project>-app:prod .
$APP_CONTAINER_RUNTIME rm -f <project>-app-prod 2>/dev/null || true
$APP_CONTAINER_RUNTIME run -d --name <project>-app-prod \
-p <prodPort>:<containerPort> <project>-app:prod
```

## 4. Health-check before declaring success

Do not tell the user the app is live until a request succeeds on the host port:

```bash
for i in $(seq 1 10); do
curl -fsS "127.0.0.1:<port>" >/dev/null && break
sleep 1
done
curl -fsS "127.0.0.1:<port>" >/dev/null && echo "up" || echo "FAILED"
```

Then report the relevant **public URL** (`dev.url` after a DEV deploy,
`prod.url` after a promote) — not the loopback address.

## Multi-container apps (db, cache, etc.)

If the app needs a database or other service, run them as sibling containers
named `<project>-db` etc. on a shared `<project>` network
(`$APP_CONTAINER_RUNTIME network create <project>`). **Only the app container
publishes the reserved host port(s);** inter-container traffic stays on the
network. Secrets for those services are app config, never LLM keys.
3 changes: 3 additions & 0 deletions builder-agent/templates/vite-spa/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.git
12 changes: 12 additions & 0 deletions builder-agent/templates/vite-spa/.pi/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# App builder

You are building a web app in this project. It starts as a minimal Vite
single-page app served in production by nginx.

- Edit `src/` and `index.html` to build what the user asks for.
- The `Dockerfile` builds one lean image; the deploy-app skill runs it as DEV
and PROD containers on the ports the control plane allocated.
- Use the **deploy-app skill** to build, run, redeploy (DEV), and promote (PROD).
Never invent ports — read them from `.pi/deployment.json`.
- Keep the production image lean and non-root; the app listens on container
port 8080.
25 changes: 25 additions & 0 deletions builder-agent/templates/vite-spa/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
#
# Lean multi-stage build with a SINGLE final runtime target (no --target):
# DEV and PROD are the same image, differing only by tag/port at run time.
# The `deps` layer is a cache anchor so warm rebuilds are sub-second.
# Final image is plain nginx serving the built static assets as a non-root
# user — no source, no node_modules, no build tooling shipped.

FROM docker.io/library/node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install

FROM deps AS build
COPY . .
RUN npm run build

# Final runtime image: nginx serving /usr/share/nginx/html on container port 8080.
FROM docker.io/library/nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Run unprivileged: the bundled nginx user owns only what it needs.
USER nginx
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
12 changes: 12 additions & 0 deletions builder-agent/templates/vite-spa/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>appx app</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
34 changes: 34 additions & 0 deletions builder-agent/templates/vite-spa/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Minimal nginx config for a non-root container listening on 8080.
# pid + temp paths live under /tmp so the `nginx` user can write them.
worker_processes auto;
pid /tmp/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;

sendfile on;
keepalive_timeout 65;

server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;

# SPA fallback: unknown routes serve index.html for client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
}
14 changes: 14 additions & 0 deletions builder-agent/templates/vite-spa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "appx-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.4.0"
}
}
7 changes: 7 additions & 0 deletions builder-agent/templates/vite-spa/src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const app = document.querySelector("#app");
app.innerHTML = `
<main style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem;">
<h1>Your app is running 🚀</h1>
<p>This is the starter template. Tell the builder agent what to build.</p>
</main>
`;
9 changes: 9 additions & 0 deletions builder-agent/templates/vite-spa/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";

// Bind to all interfaces so the dev server is reachable from outside the
// container if ever used; the production image is plain nginx and ignores this.
export default defineConfig({
server: {
host: "0.0.0.0",
},
});
Loading
Loading