diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76c328c --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 17cf427..37123cd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/container-smoke.yml b/.github/workflows/container-smoke.yml new file mode 100644 index 0000000..4333ef9 --- /dev/null +++ b/.github/workflows/container-smoke.yml @@ -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 diff --git a/README.md b/README.md index b4c8513..ed090cc 100644 --- a/README.md +++ b/README.md @@ -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 `. | +| `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 diff --git a/builder-agent/skills/deploy-app/SKILL.md b/builder-agent/skills/deploy-app/SKILL.md new file mode 100644 index 0000000..b9b6ecb --- /dev/null +++ b/builder-agent/skills/deploy-app/SKILL.md @@ -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 :`. +- **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 -app:dev . +$APP_CONTAINER_RUNTIME rm -f -app-dev 2>/dev/null || true +$APP_CONTAINER_RUNTIME run -d --name -app-dev \ + -p : -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 -app:prod . +$APP_CONTAINER_RUNTIME rm -f -app-prod 2>/dev/null || true +$APP_CONTAINER_RUNTIME run -d --name -app-prod \ + -p : -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:" >/dev/null && break + sleep 1 +done +curl -fsS "127.0.0.1:" >/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 `-db` etc. on a shared `` network +(`$APP_CONTAINER_RUNTIME network create `). **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. diff --git a/builder-agent/templates/vite-spa/.dockerignore b/builder-agent/templates/vite-spa/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/builder-agent/templates/vite-spa/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/builder-agent/templates/vite-spa/.pi/AGENTS.md b/builder-agent/templates/vite-spa/.pi/AGENTS.md new file mode 100644 index 0000000..fe348d3 --- /dev/null +++ b/builder-agent/templates/vite-spa/.pi/AGENTS.md @@ -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. diff --git a/builder-agent/templates/vite-spa/Dockerfile b/builder-agent/templates/vite-spa/Dockerfile new file mode 100644 index 0000000..cb5bbb6 --- /dev/null +++ b/builder-agent/templates/vite-spa/Dockerfile @@ -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;"] diff --git a/builder-agent/templates/vite-spa/index.html b/builder-agent/templates/vite-spa/index.html new file mode 100644 index 0000000..bd41380 --- /dev/null +++ b/builder-agent/templates/vite-spa/index.html @@ -0,0 +1,12 @@ + + + + + + appx app + + +
+ + + diff --git a/builder-agent/templates/vite-spa/nginx.conf b/builder-agent/templates/vite-spa/nginx.conf new file mode 100644 index 0000000..ede4ec1 --- /dev/null +++ b/builder-agent/templates/vite-spa/nginx.conf @@ -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; + } + } +} diff --git a/builder-agent/templates/vite-spa/package.json b/builder-agent/templates/vite-spa/package.json new file mode 100644 index 0000000..c9d16c2 --- /dev/null +++ b/builder-agent/templates/vite-spa/package.json @@ -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" + } +} diff --git a/builder-agent/templates/vite-spa/src/main.js b/builder-agent/templates/vite-spa/src/main.js new file mode 100644 index 0000000..3a4ebd9 --- /dev/null +++ b/builder-agent/templates/vite-spa/src/main.js @@ -0,0 +1,7 @@ +const app = document.querySelector("#app"); +app.innerHTML = ` +
+

Your app is running 🚀

+

This is the starter template. Tell the builder agent what to build.

+
+`; diff --git a/builder-agent/templates/vite-spa/vite.config.js b/builder-agent/templates/vite-spa/vite.config.js new file mode 100644 index 0000000..1f0cae0 --- /dev/null +++ b/builder-agent/templates/vite-spa/vite.config.js @@ -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", + }, +}); diff --git a/container/Dockerfile b/container/Dockerfile index e93c772..8d044a3 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,12 +1,42 @@ # syntax=docker/dockerfile:1 -# Stage 0 spike draft — the OUTER "builder" container image. +# Stage 2 — the OUTER "builder" container image. # -# Validates: unprivileged docker container running rootless podman inside. -# agent-server is deliberately NOT installed here (Stage 2 adds it); this image -# exists purely to prove the nesting works on the target host. +# Promotes the Stage 0 spike image (which only proved nested rootless podman by +# keeping itself alive for `docker exec`) to actually RUN agent-server, with the +# deploy-app skill + app template baked in. # -# See docs/plans/stage0-spike-brief.md for the validation tasks and -# container/SPIKE-FINDINGS.md for the (to-be-recorded) results. +# Built from the REPO ROOT (not container/) so the Node build stage can compile +# agent-server. `run-outer.sh` invokes: docker build -f container/Dockerfile . +# +# HARD SECURITY INVARIANTS (transcribed verbatim from the deletion-tested Stage 0 +# recipe — see container/SPIKE-FINDINGS.md). Do NOT weaken any of these: +# - no --privileged, no --cap-add SYS_ADMIN, no /dev/fuse, no seccomp=unconfined +# - the outer process runs as uid 1000 (builder) +# - newuidmap/newgidmap ship with FILE CAPABILITIES (not setuid-root) +# - NEVER add --security-opt no-new-privileges (incompatible with the file-cap +# helpers; see the WARNING below) +# - native rootless overlay, cgroupfs, file events logger, subuid/subgid ranges +# See docs/plans/builder-containers-plan.md (Stage 2, D3/D5/D6). + +# ── Stage A: compile agent-server + prune to a production runtime ───────────── +# Official node:22 (current LTS, matches .github/workflows/contract.yml's +# node-version: 22 and the box's node). The build stage owns npm + the dev +# toolchain; only the pruned dist/ + production node_modules are copied forward, +# so none of that tooling reaches the final unprivileged image. +FROM node:22-bookworm-slim AS server-build +WORKDIR /build +# Install deps first (cache anchor) — only re-runs when the lockfile changes. +COPY package.json package-lock.json ./ +RUN npm ci +# Compile TypeScript → dist/ (the build script also copies the generated event +# schema into dist/contract/). +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build +# Prune to production dependencies in place; node_modules is now runtime-only. +RUN npm prune --omit=dev + +# ── Stage B: the rootless-podman outer image (Stage 0 recipe, UNCHANGED) ────── FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive @@ -20,6 +50,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl git iproute2 libcap2-bin \ && rm -rf /var/lib/apt/lists/* +# Node 22 runtime for agent-server in the final stage. Installed from NodeSource +# (the canonical way to get a specific Node LTS on Ubuntu, with correct apt +# dependency resolution) so it matches the build stage's major version. We keep +# the proven ubuntu:24.04 podman base rather than switching to a node:* base — +# the nesting recipe (file-cap helpers, native overlay) was validated on this +# base and must not change. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && node --version + # CRITICAL FIX (see SPIKE-FINDINGS.md "newuidmap"): Ubuntu ships newuidmap / # newgidmap as setuid-root. Inside an unprivileged docker container that makes # euid=0 when they run, which fails the kernel's uid_map ownership shortcut and @@ -69,9 +110,33 @@ RUN mkdir -p /home/builder/.config/containers \ RUN mkdir -p /workspace /home/builder/.local/share/containers \ && chown -R builder:builder /workspace /home/builder/.local -COPY entrypoint.sh /usr/local/bin/entrypoint.sh +# ── agent-server runtime + baked builder assets (Stage 2 additions) ─────────── +# Pruned runtime from the build stage (dist/ + production node_modules + +# package.json so Node resolves "type": "module" and node_modules). Root-owned +# is fine — the unprivileged builder process only READS these. +COPY --from=server-build /build/dist /opt/agent-server/dist +COPY --from=server-build /build/node_modules /opt/agent-server/node_modules +COPY --from=server-build /build/package.json /opt/agent-server/package.json + +# Bake the deploy skill + app template at fixed paths and point env at them +# (D4 / D5). builder reads these read-only. +COPY builder-agent/skills/deploy-app /opt/builder-agent/skills/deploy-app +COPY builder-agent/templates/vite-spa /opt/builder-agent/templates/vite-spa + +COPY container/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod 0755 /usr/local/bin/entrypoint.sh +# agent-server env contract (see src/config.ts). Secrets (ANTHROPIC_API_KEY, +# AGENT_SERVER_TOKEN) are NEVER baked — they arrive via `docker run -e`. +# AGENT_SERVER_HOST=0.0.0.0 the container boundary takes over loopback's +# role; the host PUBLISH stays loopback-only (run-outer.sh -p 127.0.0.1:...). +ENV WORKSPACE_DIR=/workspace \ + AGENT_SERVER_HOST=0.0.0.0 \ + AGENT_SERVER_PORT=4001 \ + APP_CONTAINER_RUNTIME=podman \ + APPX_TEMPLATE_DIR=/opt/builder-agent/templates/vite-spa \ + PI_SKILL_PATHS=/opt/builder-agent/skills/deploy-app + USER builder WORKDIR /workspace @@ -79,6 +144,7 @@ WORKDIR /workspace ENV XDG_RUNTIME_DIR=/tmp/runtime-builder ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -# Spike keeps the container alive for `docker exec` iteration; Stage 2 replaces -# this with the agent-server process. -CMD ["sleep", "infinity"] +# Stage 2: agent-server is the container's main process. The entrypoint warms +# up rootless podman then `exec`s this (so signals reach Node directly). Node +# resolves /opt/agent-server/node_modules by walking up from the script dir. +CMD ["node", "/opt/agent-server/dist/server.js"] diff --git a/container/SPIKE-FINDINGS.md b/container/SPIKE-FINDINGS.md index 146cf24..6f111f7 100644 --- a/container/SPIKE-FINDINGS.md +++ b/container/SPIKE-FINDINGS.md @@ -257,3 +257,90 @@ The Stage 2 image and appx's Stage 3 supervisor should transcribe this verbatim: - If appx ever wants podman-on-host, settle the rootful-podman DNS configuration (`aardvark-dns`/`netavark` or `--dns`) noted in the T2 sub-question. + +--- + +# Stage 2 Findings — agent-server packaged inside the outer container + +**Status:** COMPLETE — `./scripts/container-smoke.sh` exits 0 (31/31) and the +Stage 0 `./container/smoke.sh` still exits 0 (11/11), both on the same Ubuntu +26.04 / kernel 7.0 Hetzner VM as Stage 0. The security boundary is byte-for-byte +the proven Stage 0 set; Stage 2 added ONLY packaging (agent-server + assets) and +the two new publishes. `docker inspect` confirms `Privileged=false`, +`CapAdd=[]`, no `no-new-privileges`, no `/dev/fuse`. + +## What changed (packaging only — no flag/recipe change) + +- `container/Dockerfile` — added a **Node 22 build stage** (`node:22-bookworm-slim`, + `npm ci && npm run build && npm prune --omit=dev`) and copied the pruned runtime + (`dist/` + production `node_modules` + `package.json`) into the unchanged + ubuntu:24.04 rootless-podman stage. The file-cap `newuidmap`/`newgidmap` fix, + native-overlay `storage.conf`, `containers.conf`, subuid/subgid, `builder` user + and volume mountpoints are **verbatim from Stage 0**. +- The image is now built from the **repo root** (`docker build -f container/Dockerfile ..`) + so the build stage can see the agent-server source; a root `.dockerignore` + keeps the context lean (host `node_modules`/`dist`/`.git`/`docs` excluded). +- Baked assets at fixed paths: `/opt/builder-agent/skills/deploy-app` (→ `PI_SKILL_PATHS`) + and `/opt/builder-agent/templates/vite-spa` (→ `APPX_TEMPLATE_DIR`). +- `container/entrypoint.sh` — kept the stale-`XDG_RUNTIME_DIR` wipe + `podman info` + warmup **exactly**; the warmup still does not crash-loop the container on + failure (now so the API stays reachable, not for spike debugging). CMD is now + `node /opt/agent-server/dist/server.js`, `exec`'d as PID 1 for clean signals. +- `container/run-outer.sh` — added `-p 127.0.0.1:4001:4001` (API) and changed the + app publish to `-p 127.0.0.1:10000-10199:10000-10199` (200 ports = 100 projects + × DEV+PROD; matches appx `PublishedPortRangeEnd=10199`). Secrets passed by name + (`-e ANTHROPIC_API_KEY -e AGENT_SERVER_TOKEN`) — never baked. Security flags + untouched. + +## Decisions (industry-standard options + rationale) + +- **Node in the final stage: NodeSource apt repo (`setup_22.x`).** Canonical way + to get a specific Node LTS on Ubuntu with correct apt dependency resolution, + and it keeps the *proven* ubuntu:24.04 podman base (we did NOT switch to a + `node:*` base — the nesting recipe was validated on ubuntu:24.04 and must not + change). The build stage uses the official `node:22-bookworm-slim` for fast, + reproducible `npm ci`/`build`; only the pruned runtime crosses into the final + image (no npm/dev-toolchain shipped). Node major matches (22) across both + stages so any native addons stay ABI-compatible; bookworm→ubuntu24.04 is a + forward glibc step (2.36 → 2.39), which is compatible. +- **Build once, two instances (D6).** The smoke builds `

-app:dev` once and + `podman tag`s `:prod` from it, then runs both — the most faithful encoding of + "DEV and PROD are the same build." Redeploy rebuilds the `:dev` tag only, so + PROD stays the old image (verified by grepping each instance's hashed JS bundle). +- **Invoker runtime: rootful host docker** (the box's pre-installed Docker + 29.5.3, user in the `docker` group). Stage 0's T2 sub-question already settled + this: docker-outer is the complete, lowest-friction path (embedded DNS resolver, + no extra flags); rootless-podman-outer is a dead end (nested subuid exhaustion) + and rootful-podman-outer trades two security flags for a DNS-config burden. + No change for Stage 2. +- **Smoke determinism: drop the named volumes up front.** A box polluted by + earlier manual/spike runs leaves inner containers that collide on the app ports + (`podman start --all` → "address already in use"). The gate starts from clean + `builder-workspace` + `builder-podman-storage` volumes so it can't flap. (This + is exactly the contamination that surfaced during bring-up; it is a test-hygiene + issue, not a Stage 2 regression.) + +## Metrics (this VM: 4 vCPU / 7.6 GiB) + +- **Node version:** build stage `node:22-bookworm-slim`; final-stage runtime + `node v22.22.3` (NodeSource 22.x). Matches CI's `node-version: 22`. +- **Image size:** ~1.03 GB (reported). Dominated by production `node_modules` + (263 MB) + the Node runtime + podman/ubuntu base; `dist/` is 456 KB. Not + optimised for Stage 2 (Stage 4 can trim if it matters). +- **Build times:** cold (build-cache pruned, base images present) **~55 s** + end-to-end, npm ci dominating; warm rebuild after a source-only edit is a few + seconds (layer cache on the `npm ci` stage). +- **Inner multi-stage Vite build under nested rootless podman:** **~13 s cold** + (consistent with the inner-app spike); warm redeploy is sub-second on the cached + deps layer. + +## Open items carried forward + +- Tailored AppArmor profile (Stage 0 item 7) still deferred — bounded containment + loss; seccomp + userns + cap-bounding still apply. +- The one Stage 0 caveat ("re-verify on genuine Ubuntu 24.04") is **unchanged by + Stage 2** — this VM is 26.04, same family the spike was validated on; the + in-image podman target is still ubuntu:24.04. No OS-divergence re-check was in + scope here. +- Image-size trim, resource limits (`--memory`/`--cpus`), and the bash-tool + `*_API_KEY` strip are Stage 4. diff --git a/container/entrypoint.sh b/container/entrypoint.sh index aed9caf..8bea127 100755 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Outer-container entrypoint (Stage 0 spike). +# Outer-container entrypoint (Stage 2). # # 1. Provision the runtime dir rootless podman expects (no systemd-logind here). -# 2. Warm up podman storage so the first real build/run isn't slow and so a +# 2. Wipe stale XDG_RUNTIME_DIR transient state so `docker restart` recovers +# cleanly (Stage 0 finding — load-bearing for Stage 4 podman start --all). +# 3. Warm up podman storage so the first real build/run isn't slow and so a # broken nested environment is visible in `docker logs` immediately. -# 3. Exec the CMD (spike: sleep infinity; Stage 2: agent-server). +# 4. exec the CMD — Stage 2: agent-server (node dist/server.js). exec keeps Node +# as PID 1 so docker stop/restart signals reach it directly. set -euo pipefail mkdir -p "${XDG_RUNTIME_DIR:-/tmp/runtime-$(id -un)}" @@ -25,9 +28,32 @@ echo "[entrypoint] podman warmup starting ($(date -Is))" if time podman info > /tmp/podman-info.log 2>&1; then echo "[entrypoint] podman warmup OK" else - # Don't die: keep the container alive so the spike agent can exec in and debug. + # Don't die on a warmup failure: agent-server still starts (so /v1 is + # reachable and the failure is visible via logs / the agent's first podman + # call) instead of crash-looping the whole container. echo "[entrypoint] WARNING: podman info FAILED — see /tmp/podman-info.log:" tail -n 20 /tmp/podman-info.log || true fi +# Resurrect inner app containers after an outer restart/crash/reboot. The named +# ~/.local/share/containers volume preserves each app container's definition, but +# `docker restart`/a daemon-driven restart leaves them in 'Created'/'Exited', so +# without this the user's deployed apps stay DOWN until the next redeploy. +# +# `|| true` is load-bearing: this runs under `set -euo pipefail`, so one +# un-startable inner container must NOT abort the entrypoint and crash-loop the +# whole outer container (same fail-soft stance as the warmup above). Runs after +# the XDG_RUNTIME_DIR wipe so podman's pause process is freshly valid. +# +# SHORT-TERM FIX (Stage 5 follow-up): `--all` is deliberately blunt — it starts +# every container that exists, including intentionally-stopped or stale ones from +# old deploys, with no DEV/PROD intent and possible published-port clashes. The +# principled replacement is registry-driven reconciliation (start exactly the +# containers the project registry says should be running). Acceptable today given +# the strict one-DEV/one-PROD-per-project model. Pairs with the Stage 5 HTTP-probe +# health fix (the appRunning TCP dial false-positives during this startup window). +echo "[entrypoint] resurrecting inner app containers (podman start --all)" +podman start --all > /tmp/podman-start-all.log 2>&1 || \ + echo "[entrypoint] WARNING: 'podman start --all' had failures — see /tmp/podman-start-all.log (continuing)" + exec "$@" diff --git a/container/gen-seccomp.sh b/container/gen-seccomp.sh index baaadea..a1e4194 100755 --- a/container/gen-seccomp.sh +++ b/container/gen-seccomp.sh @@ -17,7 +17,8 @@ # Net result: a tailored profile that is strictly tighter than `unconfined`. set -euo pipefail cd "$(dirname "$0")" -docker build -t builder-outer . >/dev/null +# Built from the repo root (Stage 2 context change); the Dockerfile lives here. +docker build -f Dockerfile -t builder-outer .. >/dev/null cid=$(docker create builder-outer) docker cp "$cid:/usr/share/containers/seccomp.json" /tmp/stock-seccomp.json docker rm "$cid" >/dev/null diff --git a/container/run-outer.sh b/container/run-outer.sh index ddb4531..b6f2cb3 100755 --- a/container/run-outer.sh +++ b/container/run-outer.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -# Build and (re)start the outer builder container — Stage 0 spike. +# Build and (re)start the outer builder container — Stage 2. # -# The flag set below is the FINAL PROVEN minimal set (task T2 complete): each -# flag was deletion-tested and carries a one-line justification below and in -# SPIKE-FINDINGS.md. `./smoke.sh` exits 0 with exactly these flags. +# Promotes the Stage 0 spike to RUN agent-server. The security flag set below is +# the FINAL PROVEN minimal set (Stage 0 task T2): each flag was deletion-tested +# and carries a one-line justification below and in SPIKE-FINDINGS.md. Stage 2 +# adds ONLY the two publishes (4001 API + 10000-10199 app range) and the two +# secret -e pass-throughs; the security flags are byte-for-byte unchanged. # -# Hard constraints honoured: no --privileged, no --cap-add SYS_ADMIN, non-root -# user (outer main process is uid 1000 'builder'). +# Hard constraints honoured: no --privileged, no --cap-add SYS_ADMIN, no +# /dev/fuse, no seccomp=unconfined, no no-new-privileges; non-root user (outer +# main process is uid 1000 'builder'). set -euo pipefail cd "$(dirname "$0")" @@ -14,17 +17,27 @@ readonly IMAGE="builder-outer" readonly NAME="builder-outer" readonly SECCOMP="$(pwd)/seccomp-builder.json" -docker build -t "$IMAGE" . +# Build from the REPO ROOT (..) so the Node build stage can compile agent-server; +# the Dockerfile lives in container/. .dockerignore keeps the context lean. +docker build -f Dockerfile -t "$IMAGE" .. docker rm -f "$NAME" 2>/dev/null || true +# Secrets are passed by NAME (-e VAR with no =value): docker forwards the host's +# value if set, and simply omits the var otherwise. Never bake keys into the +# image. ANTHROPIC_API_KEY + AGENT_SERVER_TOKEN are both optional for the +# deterministic smoke (the agent never runs an LLM there); set them for the +# Stage 1 e2e. docker run -d --name "$NAME" \ --device /dev/net/tun \ --security-opt seccomp="$SECCOMP" \ --security-opt apparmor=unconfined \ --security-opt systempaths=unconfined \ + -e ANTHROPIC_API_KEY \ + -e AGENT_SERVER_TOKEN \ -v builder-workspace:/workspace \ -v builder-podman-storage:/home/builder/.local/share/containers \ - -p 127.0.0.1:10000-10009:10000-10009 \ + -p 127.0.0.1:4001:4001 \ + -p 127.0.0.1:10000-10199:10000-10199 \ "$IMAGE" # Final proven flag set (deletion-tested in T2; see SPIKE-FINDINGS.md): @@ -46,9 +59,13 @@ docker run -d --name "$NAME" \ # proc: Operation not permitted'. No caps/privilege # builder-workspace volume project files must survive container recreate # builder-podman-storage vol inner images/containers must survive recreate -# -p 127.0.0.1:10000-10009 app port range, loopback-only (appx proxies in) +# -p 127.0.0.1:4001 agent-server API, loopback-only (appx proxies in) +# -p 127.0.0.1:10000-10199 app port range (200 = 100 projects x DEV+PROD +# pair; matches appx PublishedPortRangeEnd=10199), +# loopback-only (appx proxies in) sleep 2 docker logs "$NAME" echo -echo "outer container '$NAME' is up. Try: docker exec -it $NAME podman info" +echo "outer container '$NAME' is up. agent-server API: http://127.0.0.1:4001/" +echo "Try: docker exec -it $NAME podman info" diff --git a/docs/plans/builder-containers-plan.md b/docs/plans/builder-containers-plan.md index ff5bc1d..d54f20e 100644 --- a/docs/plans/builder-containers-plan.md +++ b/docs/plans/builder-containers-plan.md @@ -1,7 +1,7 @@ # Plan: Containerised Apps — agent-server Side -**Date:** 2026-06-11 -**Status:** Draft +**Date:** 2026-06-11 (updated 2026-06-12 with Stage 3 results) +**Status:** Stage 0 ✅ done · Stage 1 ✅ code-complete + unit-tested (manual e2e pending) · Stage 2 ✅ smoke-green · Stage 3 ✅ done (appx-side, smoke-green) · Stage 4 ✅ done (appx-side, productionized + reboot-soaked) · Stage 5 (hardening) pending **Scope:** Deployment metadata contract (dev + prod), app template seeding, two-container (dev/prod) deploy model, builder deploy skill/prompt, outer container image (nested rootless podman), smoke tests **Canonical architecture:** `docs/architecture/important/builder-container-architecture.md` **Sibling plan:** appx repo, `docs/plans/phase_9_plan.md` (control plane: port allocation, container supervision, subdomain routing) @@ -27,7 +27,12 @@ Implement agent-server's half of the containerised apps architecture: agent-server stays appx-agnostic: it receives a generic `deployment` object (dev + prod `{port, url}` pairs) on project create and makes it available to the agent. It never knows how appx mints ports or subdomains — only that two pairs were handed to it. -> **appx-side implication (track in `phase_9_plan.md`):** appx must allocate a **pair** of ports per project and route **two** subdomains (prod `…`, dev e.g. `…-dev.`). The 100-port publish cap therefore means ~50 projects, not 100 — revisit the cap there. +> **appx-side implication (track in `phase_9_plan.md`):** appx must allocate a +> **pair** of ports per project and route **two** subdomains (prod `…`, +> dev e.g. `…-dev.`). **Resolved (2026-06-12):** the published/allocated +> range was set to `10000–10199` (200 ports) so the pair model still supports +> **100 projects**. The outer-container publish range (Stage 2/3 `run-outer.sh`) +> and `phase_9_plan.md` D1 must match `10000-10199`. --- @@ -81,7 +86,7 @@ File-only would risk the agent never reading it; prompt-only would risk loss on ### D4 — Deploy conventions live in a skill, not only in AGENTS.md -Ship a `deploy-app` skill in this repo (`skills/deploy-app/SKILL.md`), loaded via `PI_SKILL_PATHS` in the outer image. Skills are versioned with agent-server, independent of any one project's `.pi/`, and the prompt section stays short (conventions load only when the agent deploys). +Ship a `deploy-app` skill in this repo (`builder-agent/skills/deploy-app/SKILL.md`), loaded via `PI_SKILL_PATHS` in the outer image. Skills are versioned with agent-server, independent of any one project's `.pi/`, and the prompt section stays short (conventions load only when the agent deploys). ### D5 — New projects are seeded from a baked-in app template @@ -143,15 +148,16 @@ Each project deploys as two inner containers built from the **same Dockerfile** ## Staging (shared with appx plan) -| Stage | What | Repo focus | -|---|---|---| -| 0 | Nested rootless podman spike (timeboxed ~1 day) | agent-server | -| 1 | Full user flow with agent-server **on host** ("podman without outer container") | both | -| 2 | agent-server inside the outer container, started manually | agent-server | -| 3 | appx creates/supervises the outer container at startup | appx | -| 4 | Hardening (restarts, key stripping, resource limits) | both | +| Stage | What | Repo focus | Status | +|---|---|---|---| +| 0 | Nested rootless podman spike (timeboxed ~1 day) | agent-server | ✅ done | +| 1 | Full user flow with agent-server **on host** ("podman without outer container") | both | ✅ code + unit tests; manual e2e pending | +| 2 | agent-server inside the outer container, started manually | agent-server | ✅ smoke-green | +| 3 | appx creates/supervises the outer container at startup | appx | ✅ smoke-green (`smoke-deploy.sh` 38/38) | +| 4 | **Productionize**: deploy is container-mode only (remove host mode), appx as a systemd service, secrets, docker access, soak | appx (+ both) | ✅ done (appx-side; reboot-soaked) | +| 5 | Hardening (restarts, key stripping, resource limits, security review) | both | pending | -Rationale: the user-visible flow (Stage 1) is ~80% of the value and is independent of the outer container; the outer container is packaging. The Stage 0 spike de-risks the one thing that could invalidate Stage 1 decisions — nested podman flag fragility ("works on host, breaks nested"). +Rationale: the user-visible flow (Stage 1) is ~80% of the value and is independent of the outer container; the outer container is packaging. The Stage 0 spike de-risks the one thing that could invalidate Stage 1 decisions — nested podman flag fragility ("works on host, breaks nested"). **Stage 3→4 split (2026-06-12):** Stage 3 proved appx can supervise the container when hand-run with env vars; running it as the production systemd service (secrets, docker access, boot ordering) is a distinct, soak-worthy chunk, so it was carved out as **Stage 4 (productionize)** and hardening moved to **Stage 5**. **Host mode dropped from deploy (2026-06-12):** container mode supersedes it, so Stage 4 removes the host-mode deploy/systemd path entirely (no `appx-agent` user, no `agent-server.service`, no host Node/Pi install); local development becomes a manual, no-systemd flow (run agent-server by hand + `appx --http`). The appx binary keeps its `APPX_AGENT_SERVER_URL` runtime path for that local/macOS use. --- @@ -173,49 +179,110 @@ hardened host defaults intact. --- -## Stage 1 — Deployment metadata + deploy skill (agent-server on host) - -### Contract & registry - -- [ ] `src/contract`: add `deployment` (optional `{ dev?: {port?, url?}; prod?: {port?, url?} }`) to the create-project request and the `ProjectInfo` response schemas; regenerate `openapi.json` -- [ ] `src/runtime/projectStore.ts`: `ProjectRecord` gains optional `deployment`; loader tolerates records without it (backward compatible) -- [ ] `src/runtime/projectRegistry.ts`: - - `createProject({ name, deployment })` persists metadata; **same-name re-POST updates `deployment`** and rewrites the materialised file - - materialise `/.pi/deployment.json` (pretty-printed, stable key order) on create/update - - **template seeding (D5):** when the project dir is created fresh and `APPX_TEMPLATE_DIR` is set, recursively copy it in (skip `node_modules`/`.next`/`dist`/caches); leave existing dirs untouched. Lift orchestrator's `cpSync` + filter implementation -- [ ] `src/http/projectsRoutes.ts`: accept/return the new field; validation: each present port must be an integer in 1024–65535 (reject privileged/garbage values at the boundary — fail fast) -- [ ] `src/config.ts`: add `APPX_TEMPLATE_DIR` (optional; absent ⇒ no seeding) - -### Runtime / prompt - -- [ ] `src/config.ts`: add `APP_CONTAINER_RUNTIME` (default `"podman"`), validated non-empty string -- [ ] `src/runtime/projectRuntime.ts`: extend `resolveSystemPrompt` (or a sibling helper) to append the generated Deployment section when the project has metadata. Keep generation in one pure function (`buildDeploymentPromptSection(deployment, containerRuntime)`) so it is unit-testable without a runtime - -### Deploy skill - -- [ ] `skills/deploy-app/SKILL.md` with the conventions (DEV + PROD, per D6 — same build, two instances): - - read `.pi/deployment.json` for the dev/prod ports and URLs - - DEV (refine): `$APP_CONTAINER_RUNTIME build -t -app:dev .` → `run -d --name -app-dev -p : -app:dev` - - PROD (promote): `$APP_CONTAINER_RUNTIME build -t -app:prod .` → `run -d --name -app-prod -p : -app:prod` - - no `--target`: the template's Dockerfile has one final (lean, non-root) image; DEV and PROD differ only by tag/instance/port - - redeploy: `stop && rm && build && run` under the same `--name` (idempotent; never accumulate containers); refinements rebuild **DEV only**, promote rebuilds PROD - - `` is a template detail (e.g. 8080); always map `-p :`, never assume they're equal - - multi-container apps (db etc.): suffix names `-db`, only the app publishes the reserved port(s); inter-container traffic via a `` podman network - - health check before declaring success: `curl -fsS 127.0.0.1:` with retries; report the relevant public URL to the user - - **never** pass `*_API_KEY` env vars into app containers -- [ ] Wire the skill into local dev runs via `PI_SKILL_PATHS` (document in README); the outer image bakes it in at Stage 2 - -### Tests (Stage 1) - -- [ ] `test/projectLifecycle.test.ts`: deployment metadata (dev+prod) round-trips create → get → list; re-POST same name updates it; `.pi/deployment.json` written and rewritten; absent metadata ⇒ no file, no prompt section; **template seeding** copies into a fresh dir and skips an existing one -- [ ] New `test/deploymentPrompt.test.ts`: `buildDeploymentPromptSection` output for dev-only / prod-only / both / absent metadata -- [ ] Manual e2e (with appx running locally — see appx plan): create project in UI (seeded template runs immediately) → prompt a small change → DEV URL updates → promote → PROD URL reflects it. This is where skill iteration happens. - -**Acceptance:** the full create → deploy → view → refine → redeploy loop works locally with agent-server run via `npm run dev` and Docker Desktop/podman as `APP_CONTAINER_RUNTIME`. +## Stage 1 — Deployment metadata + deploy skill (agent-server on host) ✅ CODE COMPLETE + +**Status (2026-06-12):** all code + unit tests landed in both repos; checks green +(agent-server `typecheck`/`test` 116 pass/`check`; appx `task test`). The +cross-repo **manual LLM e2e is the one remaining item** (needs a Linux box with +a container runtime + an LLM key — see *Stage 1 e2e environment* below). + +### What landed + +**agent-server (this repo):** +- [x] `src/contract/schemas.ts` + `openapi.json`: optional `deployment + { dev?, prod?: { port?, url? } }` on the create request and `ProjectInfo`; + port validated as an integer **1024–65535** → fail-fast **400** at the boundary. +- [x] `src/runtime/projectStore.ts`: `ProjectRecord.deployment?` (loader tolerates + its absence — backward compatible) + `setDeployment`. +- [x] `src/runtime/projectRegistry.ts`: `createProject({ name, deployment })` + persists metadata; **same-name re-POST updates it**; materialises + `.pi/deployment.json` (stable key order `dev→prod`, `port→url`; absent ⇒ no + file); **template seeding** via `cpSync` + skip-filter into fresh dirs only. +- [x] `src/runtime/deployment.ts` (new): pure `buildDeploymentPromptSection()` + + `buildDeploymentJson()` — unit-tested without a runtime. +- [x] `src/runtime/projectRuntime.ts`: appends the Deployment section **after** + `.pi/AGENTS.md` (`composeSystemPrompt`, never replacing it). +- [x] `src/config.ts`: `APPX_TEMPLATE_DIR` (optional, existence-checked) + + `APP_CONTAINER_RUNTIME` (default `podman`). +- [x] `builder-agent/skills/deploy-app/SKILL.md` (D6 conventions; references + `$APP_CONTAINER_RUNTIME`; never passes `*_API_KEY`). +- [x] `builder-agent/templates/vite-spa/` (new): provisional Vite SPA — lean + multi-stage Dockerfile, single nginx runtime target, `USER nginx`, `listen + 8080`, FQ image refs. +- [x] Tests: `test/projectLifecycle.test.ts` (metadata round-trip, re-POST + update, file written/rewritten, absent ⇒ no file, seeding fresh-vs-existing) + + `test/deploymentPrompt.test.ts`. +- [x] Local-dev wiring documented (`.env.example`, README): `APPX_TEMPLATE_DIR`, + `APP_CONTAINER_RUNTIME`, `PI_SKILL_PATHS` → `builder-agent/...`. + +**appx (sibling repo):** +- [x] `internal/agentserver/client.go`: `EnsureProject(ctx, name, dep)` marshals + the nested `deployment` object, omitting empty environments/fields. +- [x] `internal/project/store.go` + migration `000006_project_dev_port`: + atomic **DEV+PROD pair allocation**, capped at `PublishedPortRangeEnd = + 10199` (**100 projects**); `assigned_port` kept as PROD, new `dev_port` column. +- [x] `internal/project/project.go`: `Deployment`/`EnvTarget` types, + `ValidateName` rejects the reserved `-dev` suffix. +- [x] `internal/project/manager.go`: `appURL()` builds prod/dev public URLs from + appx's external scheme/host/listen-port; payload sent on create + reconcile. +- [x] `internal/server/router.go`: subdomain dispatcher selects DEV vs PROD port + from the `-dev` label; both stay behind auth; session cookie stripped to apps; + WebSocket upgrade passthrough verified by test. + +**agent-client (consumer):** +- [x] Re-synced `openapi/agent-server.json` + regenerated + `src/core/agent-server.generated.ts`; `AgentProject` (= `ProjectInfo`) gains an + optional `deployment?`. Additive, typecheck clean, 65 tests pass. + +### Deviations / notes from the original checklist + +- **Cap raised to 100 projects** (`10000–10199`) per follow-up decision — the + original "~50 projects" note is superseded (see the blockquote above). +- **Repo reorg:** the deploy skill + template moved under `builder-agent/` + (`builder-agent/skills/deploy-app`, `builder-agent/templates/vite-spa`); all + docs/paths updated. Stage 2 must bake from these paths. +- **Deferred security hardening** (tracked in Stage 4): validate `deployment.url` + as a bounded URL in the zod schema (defence-in-depth against prompt injection + if the metadata source ever becomes less trusted — today the only producer is + appx, which builds it from a slug-validated name); add `.pi` to the template + `.dockerignore` (hygiene — keeps builder metadata out of the build context). + +### Tests (Stage 1) — done + +- [x] `test/projectLifecycle.test.ts` (see above) +- [x] `test/deploymentPrompt.test.ts` (dev-only / prod-only / both / absent) +- [ ] **Manual e2e** (with appx running): create project in UI (seeded template + runs immediately) → prompt a small change → DEV URL updates → promote → PROD + URL reflects it. This is where skill iteration happens. **Pending — run on a + Linux box (see below).** + +### Stage 1 e2e environment + +The code path is host-mode (no outer container yet), so it can run anywhere with +a container runtime + an LLM key. Two viable setups: +- **macOS local** with Docker Desktop (`APP_CONTAINER_RUNTIME=docker`) — fastest + feedback loop for **prompt/skill iteration** (Risk #3), which is the real + purpose of the manual e2e. Recommended for the skill-quality pass. +- **Linux box** (`podman`) — closer to the eventual nested target; do this once + to confirm the skill's literal commands behave the same under podman. + +**Acceptance:** the full create → deploy → view → refine → redeploy loop works +locally with agent-server via `npm run dev` and Docker Desktop/podman as +`APP_CONTAINER_RUNTIME`. *(Code + unit tests done; manual loop pending.)* --- -## Stage 2 — Outer container image +## Stage 2 — Outer container image ✅ DONE (smoke green; manual e2e pending) + +**Status (2026-06-12):** agent-server now runs **inside** the outer container. +`scripts/container-smoke.sh` is **green (31/31)** on the Ubuntu 26.04 / kernel +7.0 Hetzner VM (the Stage 0 box), and the Stage 0 `container/smoke.sh` still +passes (11/11) — packaging didn't regress the nesting recipe. The security +boundary is byte-for-byte the proven Stage 0 set: `docker inspect` confirms +`Privileged=false`, `CapAdd=[]`, no `no-new-privileges`, no `/dev/fuse`, with +only the `4001` + `10000-10199` publishes added. Full writeup: +`container/SPIKE-FINDINGS.md` ("Stage 2 Findings"). **The cross-repo manual LLM +e2e (host appx → container) is the one remaining item.** Promote the **committed Stage 0 artifacts** (`container/Dockerfile`, `run-outer.sh`, `entrypoint.sh`, `seccomp-builder.json`) from "keeps the container @@ -223,35 +290,356 @@ alive for exec" to "runs agent-server". Keep the proven flag set and the `newuidmap` file-cap + native-overlay fixes **verbatim** — do not reintroduce `/dev/fuse`, `SYS_ADMIN`, or `seccomp=unconfined`. -- [ ] `container/Dockerfile` — extend the spike image: - - **multi-stage build** (lift orchestrator's pattern): a Node build stage that compiles agent-server, then copy the pruned runtime into the spike's ubuntu:24.04 stage (e.g. `npm ci && build` then copy `dist/` + production deps; orchestrator uses `pnpm deploy --prod /app`) - - keep the spike's rootless-podman setup (file-cap helpers, native-overlay `storage.conf`, `containers.conf`, subuid/subgid) unchanged - - bake `skills/deploy-app` at a fixed path; set `PI_SKILL_PATHS` - - bake the **app template** (provisional: a minimal Vite SPA, see D5 — lean multi-stage, single runtime target, non-root) at a fixed path; set `APPX_TEMPLATE_DIR`. `container-smoke.sh` builds it under nested rootless podman (proven in the inner-app spike; the smoke guards against regression) -- [ ] `container/entrypoint.sh` — extend the spike entrypoint: - - keep the stale-runtime-state wipe + `podman info` warmup - - replace `sleep infinity` with agent-server (env: `WORKSPACE_DIR=/workspace`, `ANTHROPIC_API_KEY`, `AGENT_SERVER_TOKEN`, `APP_CONTAINER_RUNTIME=podman`, `APPX_TEMPLATE_DIR=...`, `AGENT_SERVER_HOST=0.0.0.0` — the container boundary takes over loopback's role; the **publish** stays loopback-only on the host side) -- [ ] `container/run-outer.sh` — extend the spike script: - - add `-p 127.0.0.1:4001:4001` (API) alongside the existing app-port range publish (now a **pair-aware** range; see appx plan for the revised cap given two ports/project) - - keep volumes (workspace + named podman-storage volume) and the proven security flags -- [ ] Run the **same Stage 1 manual e2e** with host-run appx pointed at the container via `APPX_AGENT_SERVER_URL=http://127.0.0.1:4001` — zero appx code changes expected +- [x] `container/Dockerfile` — extended the spike image: + - **multi-stage build**: a Node 22 build stage (`node:22-bookworm-slim`, + `npm ci && npm run build && npm prune --omit=dev`) compiles agent-server; + the pruned runtime (`dist/` + production `node_modules` + `package.json`) is + copied into the unchanged ubuntu:24.04 podman stage. Built from the **repo + root** (`docker build -f container/Dockerfile ..`) + a root `.dockerignore`. + - kept the spike's rootless-podman setup (file-cap helpers, native-overlay + `storage.conf`, `containers.conf`, subuid/subgid, volume mountpoints) **verbatim** + - baked `builder-agent/skills/deploy-app` → `/opt/builder-agent/skills/deploy-app`; + set `PI_SKILL_PATHS` + - baked the Vite SPA template → `/opt/builder-agent/templates/vite-spa`; set + `APPX_TEMPLATE_DIR`. `container-smoke.sh` builds it nested (~13 s cold) and + runs DEV+PROD — guards against regression. + - **Node in the final stage: NodeSource `setup_22.x`** (industry-standard for a + pinned Node LTS on Ubuntu; keeps the proven ubuntu:24.04 base rather than a + `node:*` base). Image ~1.03 GB (node_modules 263 MB dominates), cold build ~55 s. +- [x] `container/entrypoint.sh` — extended the spike entrypoint: + - kept the stale-runtime-state wipe + `podman info` warmup **verbatim** + - replaced `sleep infinity` with `node /opt/agent-server/dist/server.js` + (`exec`'d as PID 1). Env baked: `WORKSPACE_DIR=/workspace`, + `AGENT_SERVER_HOST=0.0.0.0`, `AGENT_SERVER_PORT=4001`, + `APP_CONTAINER_RUNTIME=podman`, `APPX_TEMPLATE_DIR`, `PI_SKILL_PATHS`. + Secrets (`ANTHROPIC_API_KEY`, `AGENT_SERVER_TOKEN`) arrive via `docker run -e`. +- [x] `container/run-outer.sh` — extended the spike script: + - added `-p 127.0.0.1:4001:4001` (API) + changed the app publish to + `-p 127.0.0.1:10000-10199:10000-10199` (200 ports; matches appx + `PublishedPortRangeEnd = 10199`) + - passes `-e ANTHROPIC_API_KEY -e AGENT_SERVER_TOKEN` by name (never baked); + volumes + the proven security flags untouched +- [ ] Run the **same Stage 1 manual e2e** with host-run appx pointed at the container via `APPX_AGENT_SERVER_URL=http://127.0.0.1:4001` — zero appx code changes expected. **Pending** (needs an `ANTHROPIC_API_KEY` + host appx in `--http` mode on this VM). + +### Stage 2 environment — run on Linux, not macOS, and not the live prod box + +**Recommendation: do Stage 2 on a dedicated, disposable Ubuntu 24.04 Linux VM +(a Hetzner box is ideal), separate from the production server.** + +Why: +- **macOS cannot run the nested setup natively.** The proven recipe (file-cap + `newuidmap`, native rootless overlay, tailored seccomp) targets a real Linux + kernel's user namespaces. Nested rootless podman inside a container inside + Docker Desktop's VM is exactly the fragile "works on host, breaks nested" + territory the staging split exists to avoid. Keep macOS for Stage 1 flow/prompt + dev (host mode) only. +- **A fresh Ubuntu 24.04 VM also retires the one open Stage 0 caveat** — the + spike box was 26.04 / kernel 7.0, and the in-image target is 24.04. Stage 2 on + 24.04 doubles as that re-verification. +- **Not the live production server:** Stage 2 installs docker, builds images, and + runs experimental nested containers — don't do that next to live appx + real + user apps. A separate cheap VM matching the prod OS gives "Linux truth" without + risking production. (When Stage 3 lands, the *production* box runs the + appx-supervised container; Stage 2 is the manual dress rehearsal for it.) +- **Appx stays in host mode** for Stage 2: run appx on the same VM in `--http` + mode pointed at `APPX_AGENT_SERVER_URL=http://127.0.0.1:4001` — no appx code + changes, so this isolates "nested environment breaks the flow" from "appx + manages containers correctly" (that's Stage 3). +- **CI parallel:** the deterministic `container-smoke.sh` (below) runs on GitHub + ubuntu runners (full VMs), so the infra chain is guarded on every relevant PR + independently of whichever VM you iterate on manually. ### Tests (Stage 2) -- [ ] `scripts/container-smoke.sh` (Linux): build image → run → poll `GET /` until healthy → `POST /v1/projects` with deployment metadata → assert `deployment.json` inside the container → `docker exec` the skill's literal command sequence to build **the seeded template** once and run it as DEV + PROD instances on the two ports (a realistic multi-stage build under nested rootless podman — not just nginx) → `curl 127.0.0.1:` and `` from the host → restart outer container → assert registry + workspace survived. - This deliberately **bypasses the LLM**: the agent only ever runs bash commands, so executing the skill's exact commands validates all infrastructure (ports, volumes, nesting) deterministically. -- [ ] CI: nightly/on-demand GitHub Actions job (ubuntu runners are full VMs; `--device /dev/fuse` works there) running `container-smoke.sh` +- [x] `scripts/container-smoke.sh` (Linux): build image → run → poll `GET /` until healthy → `POST /v1/projects` with deployment metadata → assert `deployment.json` inside the container → `docker exec` the skill's literal command sequence to build **the seeded template** once and run it as DEV + PROD instances on the two ports (a realistic multi-stage build under nested rootless podman — not just nginx) → `curl 127.0.0.1:` and `` from the host → restart outer container → assert registry + workspace survived + `podman start --all` recovers. + This deliberately **bypasses the LLM**: the agent only ever runs bash commands, so executing the skill's exact commands validates all infrastructure (ports, volumes, nesting) deterministically. **Green: 31/31.** Also adds `docker inspect` assertions for the security invariants and a redeploy-isolation check (DEV changes, PROD untouched). +- [x] CI: nightly/on-demand GitHub Actions job running `container-smoke.sh` (`.github/workflows/container-smoke.yml`, `workflow_dispatch` + nightly `schedule`, ubuntu-latest full VM). + +**Acceptance:** `container-smoke.sh` green on Ubuntu 26.04 ✅; Stage 1 e2e with agent-server containerised **pending** (manual LLM loop). + +### Deviations / notes (Stage 2) + +- **Build context moved to the repo root** so the Node build stage can compile + agent-server (`docker build -f container/Dockerfile ..`); added a root + `.dockerignore`. `gen-seccomp.sh` updated to match. +- **Smoke drops the named volumes up front** for determinism — a box polluted by + earlier manual/spike runs leaves inner containers that collide on the app ports + and would otherwise break `podman start --all`. Test hygiene, not a regression. +- **"Build once, two instances":** the smoke builds `:dev` once and `podman tag`s + `:prod` from it (faithful to D6); redeploy rebuilds `:dev` only. +- The Stage 0 `container/smoke.sh` is kept and still passes (11/11) as the bare + nesting baseline. + +### Stage 2 → Stage 3 handoff: what's still missing for the live flow + +Stage 2 proves the *nested environment* and the *contract* (appx Stage 1 already +landed: nested deployment metadata via `EnsureProject`, DEV+PROD pair allocation +capped at `PublishedPortRangeEnd = 10199` — in lock-step with this repo's +`run-outer.sh` range — and `-dev`/prod subdomain selection). What is **not** yet +built (all appx-side, tracked in the sibling `phase_9_plan.md` Stage 3): + +- **appx does not manage the container.** Today the live box runs agent-server as + a host systemd unit (`deploy/agent-server.service`); appx just talks to + `APPX_AGENT_SERVER_URL`. Stage 3 adds `internal/containerruntime` (a docker-CLI + `Supervisor` + fake) that `EnsureRunning`s the outer container from the **proven + flag set transcribed verbatim from this repo's `run-outer.sh`** (the source of + truth), behind the `APPX_AGENT_CONTAINER=true` switch, before reconcile. +- **Egress from inside the container is the most likely silent breakage.** appx's + CONNECT egress proxy (the agent's path to the LLM) listens on loopback today; + once agent-server is *in* the container, loopback no longer reaches it. Stage 3 + must bind it on the docker-bridge gateway and set `HTTPS_PROXY` + + `NODE_USE_ENV_PROXY` + `--add-host=host.docker.internal:host-gateway` in the + container env. Without this you can create/deploy but the **prompt step dies**. +- **Token becomes mandatory** in container mode (generate + persist 0600), since + the published API port means loopback is no longer a trust boundary. +- **Mismatch detection** (never silently recreate — it kills running user apps). +- **Deploy scripts** (`system-setup.sh`/`bootstrap.sh`/`tools-install.sh`) still + install host Node + the systemd agent-server; container mode installs docker + + the pinned outer image instead. ~~Open decision: how the appx user invokes + docker~~ **Resolved (Stage 4):** outer = rootful host Docker (spike T2); + rootless-docker-outer is non-viable (nested subuid), so the `appx` user uses the + `docker` group (root-equivalent, accepted on a dedicated box) — tighter scoping + is Stage 5. +- **`appx/scripts/smoke-deploy.sh`** is the missing cross-service gate — the + sibling of this repo's `container-smoke.sh`, but curling **through the appx + subdomain proxy** (`-dev.` / `.`) rather than the + loopback publish directly. That proves the full outside→appx→outer→podman→inner + path, which `container-smoke.sh` deliberately stops short of. + +Good news for Stage 3: the proxy *target* is unchanged across all stages +(`127.0.0.1:` means the same thing host- or container-side), the port +ranges already match, and the handshake is live — so Stage 3 is "wrap + supervise ++ wire egress," not a re-architecture. + +--- + +## Stage 3 — appx supervises the outer container ✅ DONE (smoke-green) + +**Status (2026-06-12):** landed **appx-side** (full detail in the sibling +`phase_9_plan.md` "Stage 3 — Results"). `appx/scripts/smoke-deploy.sh` is +**green (38/38)** on the same Ubuntu 26.04 / kernel 7.0 VM: appx in container mode +creates a **healthy** outer container, registers a project, and the full deploy +chain works **through the appx subdomain proxy** (create → DEV+PROD → curl both +via the proxy → redeploy DEV (PROD unchanged) → promote → outer restart → +appx restart). `docker inspect` on the **appx-created** container confirms the +proven flag set byte-for-byte (`Privileged=false`, `CapAdd=[]`, no +`no-new-privileges`, no `/dev/fuse`, loopback-only `4001` + `10000-10199`). + +What appx added (all appx-side; agent-server/the image unchanged): +`internal/containerruntime` (docker-CLI `Supervisor` + fake; verbatim `RunArgs`), +container-mode wiring in `cmd/appx/main.go` (`APPX_AGENT_CONTAINER`, token +mandatory + persisted 0600, `--recreate-agent-container`), egress bound on the +docker bridge gateway with `HTTPS_PROXY`/`NODE_USE_ENV_PROXY`/`--add-host`, +container-mode branches in the deploy scripts, and the `smoke-deploy.sh` gate. + +### Cross-cutting findings (recorded for both repos) + +These surfaced during Stage 3 bring-up + manual testing and affect the whole +system / upstream Pi, not just appx: + +1. **Bedrock API key set via the agent-client Settings UI does NOT work — it's an + upstream Pi gap, not appx/agent-server/the container.** The coding-agent SDK + (`pi/packages/coding-agent/src/core/sdk.ts` `streamFn`) passes the stored + AuthStorage credential as `options.apiKey`, but the Bedrock provider + (`pi/packages/ai/src/providers/amazon-bedrock.ts:141`) authenticates **only** + from `options.bearerToken` or `process.env.AWS_BEARER_TOKEN_BEDROCK`; nothing + maps `apiKey → bearerToken` for `amazon-bedrock` (and `streamSimpleBedrock` → + `buildBaseOptions` never sets `bearerToken`). So the key is silently ignored, + the AWS SDK falls back to its default credential chain, and chat fails with + **"Could not load credentials from any providers."** Reproduces identically in + host mode. **Workaround (works today):** supply `AWS_BEARER_TOKEN_BEDROCK` (+ + `AWS_REGION`) as env vars — in container mode appx forwards them by name via + `APPX_AGENT_ENV_PASSTHROUGH=AWS_BEARER_TOKEN_BEDROCK,AWS_REGION`. **Proper fix + (upstream Pi):** map a stored `amazon-bedrock` api_key credential to + `bearerToken`, or have the provider accept `options.apiKey` as the bearer token. +2. **Non-default provider endpoints need an egress allowlist entry.** appx's + CONNECT proxy fails closed, and the default allowlist only had Anthropic/OpenAI/ + Go/npm. Bedrock inference (`bedrock-runtime..amazonaws.com:443`) was + blocked. appx now ships `bedrock-runtime.*.amazonaws.com:443` in the default + allowlist with **scoped DNS-wildcard matching** (`*` = one label, like a + wildcard cert). Any other provider (Vertex, Azure, self-hosted) similarly needs + its endpoint allowlisted. +3. **`HTTPS_PROXY` is honoured by podman, not just Node.** Injecting it + container-wide (so agent-server's LLM traffic traverses the egress proxy) also + routed `podman pull` of base images through the LLM allowlist → 403 on + `registry-1.docker.io`. Fixed in appx: the container-mode default `NO_PROXY` + bypasses common image registries (`.docker.io`, `.docker.com`, `ghcr.io`, + `quay.io`, `gcr.io`, `registry.k8s.io`); LLM endpoints (not listed) still go + through the proxy. +4. **`appRunning` (TCP-dial health) false-positives after an outer restart.** + Loopback publishes use docker's userland `docker-proxy`, which accepts the + host-side connection even when the inner backend is down, so the UI can show an + app "running" while inner containers are `created`. Ground truth is the inner + `podman inspect` state. Fix (Stage 5): make the health/degraded signal an + HTTP-level probe, not a bare TCP dial. + +--- -**Acceptance:** Stage 1 e2e passes with agent-server containerised; `container-smoke.sh` green on Linux. +## Stage 4 — Productionize (deploy is container-mode only; appx runs as a systemd service) ✅ DONE (appx-side; reboot + crash soaked) + +Stage 3 proved appx supervises the container when **hand-run with env vars**. +Stage 4 makes that *the* production deployment and **removes host mode from the +deploy path entirely** — owned mostly by appx (`phase_9_plan.md`), listed here so +the shared staging stays in sync. + +**Decision (2026-06-12): drop host mode from `deploy/`.** The deploy scripts + +systemd become **container-mode only**. There is no longer a host-mode toggle to +maintain, no `appx-agent` user, no `agent-server.service`, and no host install of +Node/Pi/agent-server. Local development does **not** use these scripts: a +developer runs agent-server manually (e.g. `npm run dev`) and appx in `--http` +mode pointed at `APPX_AGENT_SERVER_URL` — by hand, no systemd. This keeps the +appx **binary's** host-mode runtime capability (the `APPX_AGENT_SERVER_URL` path +is still in the code for local/macOS dev) while deleting the host-mode +**deployment** machinery that container mode supersedes. + +- [x] **Strip host mode from the deploy scripts** — `system-setup.sh`: remove the + `appx-agent` user/group, `/home/appx-agent` dirs, and the + `agent-server.service` install/enable; **delete** `deploy/agent-server.service` + and remove the `APPX_AGENT_CONTAINER` branch (container mode is now the only + path). `tools-install.sh`: drop the host Pi/agent-server install; **build the + outer image from the agent-server checkout** + (`docker build -f /container/Dockerfile`), tagged + `APPX_AGENT_IMAGE` (the Dockerfile's own multi-stage build compiles agent-server + inside a `node:22` stage, so the prod box needs docker + the source, not host + Node). Pinned by **tag** for now; publishing a registry image + deploy-by-digest + is a deferred *Potential improvement* (below). `bootstrap.sh`: stop prompting for / writing + a mode toggle; always write the container-mode `appx.env`; start only `appx`. + Existing boxes with a stale `agent-server.service` should have it disabled + + removed on upgrade (idempotent cleanup), not left dangling. +- [x] **systemd ordering** — appx must start after the container runtime: in + `appx.service` (or a drop-in) `Wants=docker.service` + + `After=docker.service network.target`. On reboot docker comes up → appx's + idempotent `EnsureRunning` re-attaches to the existing container (no recreate). + (No host-mode base unit to keep clean anymore, so this can live directly in + `appx.service`.) +- [x] **Container restart policy + supervision model** — add `--restart + unless-stopped` to `ContainerSpec.RunArgs` so the **Docker daemon** resurrects + the outer container on process crash *and* on reboot, independent of appx. This + closes a real Stage 3 gap: appx runs `EnsureRunning` **only at startup** (it is + not a continuous watchdog), and the spec set no restart policy, so a + `builder-outer` crash *while appx keeps running* was not auto-healed. The model + to document: **the daemon keeps the container's process alive** (`--restart`); + **appx ensures it exists / is correct / is healthy** at startup (`EnsureRunning`, + drift detection, health poll); **`appx.service` `Restart=on-failure`** covers + appx itself. (Whether appx also needs a periodic re-`EnsureRunning`/health loop + vs. relying on the restart policy + the Stage 5 degraded banner is a Stage 5 + call.) Verify `--restart unless-stopped` plays well with the entrypoint's + stale-`XDG_RUNTIME_DIR` wipe on a daemon-driven restart. +- [x] **Secrets to the service** — provider creds reach appx's process env (appx + forwards them **by name** into the container; never baked). Put + `ANTHROPIC_API_KEY` and/or `AWS_BEARER_TOKEN_BEDROCK` + `AWS_REGION` in + `/etc/appx/appx.env` (0600) or an optional `EnvironmentFile=-/etc/appx/secrets.env` + (`root:root 0600`), and set `APPX_AGENT_ENV_PASSTHROUGH` to list the extra + names. `AGENT_SERVER_TOKEN` is generated + persisted 0600 by appx (no manual step). + **Revised post-implementation (2026-06-13):** most providers — incl. Anthropic — + are configured via the agent **Settings UI** like any other key (stored in the + agent's Pi credential storage in the `builder-workspace` volume), so + `bootstrap.sh` no longer prompts for `ANTHROPIC_API_KEY`. The service-env path + (`secrets.env` + `APPX_AGENT_ENV_PASSTHROUGH`) is reserved for creds the UI + can't carry — i.e. Bedrock's `AWS_BEARER_TOKEN_BEDROCK` + `AWS_REGION`. +- [x] **appx.env** — always container mode: `APPX_AGENT_CONTAINER=true`, + `APPX_AGENT_IMAGE=`, + `APPX_AGENT_SECCOMP=/etc/appx/seccomp-builder.json`. `system-setup.sh` installs + the seccomp profile to `/etc/appx/` and sets up docker access for the appx user. +- [x] **`appx` service user → Docker access** — *Runtime is already decided + + validated (SPIKE-FINDINGS T2): outer = **rootful host Docker**, inner = rootless + podman. Not open* (rootless-docker-outer would reintroduce the nested + subuid-exhaustion that killed rootless-podman-outer, and would break the + rootful-bridge egress auto-detect — so it is **not** an option). The only thing + to wire is how the unprivileged `appx` system user reaches the root daemon: + **Decision — add `appx` to the `docker` group** (proven in Stages 2–3; under + systemd `User=appx` the service inherits it after `usermod` + `daemon-reload` + + restart). Document the residual risk honestly — docker-group membership is + **root-equivalent** — mitigated by this being a dedicated single-purpose box + + dedicated `appx` user. Tightening that access (a docker-socket proxy restricting + the API surface, or a narrow sudoers rule) is **Stage 5 hardening**, not a + blocker here. +- [x] **port 443 without root** — already handled: `appx.service` sets + `AmbientCapabilities=CAP_NET_BIND_SERVICE` (the manual `setcap` is only for + hand-running the binary outside systemd). +- [x] **start/restart semantics** — `Type=simple` (systemd doesn't block on + EnsureRunning readiness). On EnsureRunning failure appx `log.Fatal`s → exits → + `Restart=on-failure`; pick a `RestartSec` large enough that a missing image / + down daemon doesn't hot-loop. First boot: `tools-install.sh` builds/pulls the + pinned image before `appx.service` starts (bootstrap order: system-setup → + tools-install → start). +- [x] **verify-installation.sh** — container-mode checks (unit active, container + healthy, proven flags present, publishes loopback-only, secret reachable); + remove host-mode (agent-server.service) assertions. +- [x] **Docs** — update `README`/`.env.example` so local dev is described as the + manual no-systemd flow (run agent-server yourself + `appx --http` with + `APPX_AGENT_SERVER_URL`); production = `bootstrap.sh` (container only). +- [x] **Soak** on a prod-like box: reboot recovery, outer-container restart + recovery, secrets reach the container, full UI e2e over public HTTPS. + *(Reboot + genuine-crash recovery validated on the VM; the LLM-in-the-loop UI + e2e remains the manual pre-release step, as in Stage 3.)* + +**Acceptance:** a fresh box → `bootstrap.sh` → reboot → the `appx` systemd unit is +active, the outer container is healthy, and the full UI e2e works over the public +HTTPS URL with provider creds supplied only via the service env. No `appx-agent` +user, no `agent-server.service`, no host Node/Pi/agent-server install exist on the +box. Local dev still works by hand (manual agent-server + `appx --http`). + +### Stage 4 — Results (2026-06-13, appx-side; recorded here for the shared staging) + +**Status:** COMPLETE (appx-side). Full detail in the sibling `phase_9_plan.md` +"Stage 4 — Results". On a fresh disposable Ubuntu 26.04 VM with rootful Docker: +appx now runs as the `appx` systemd service in container mode, ordered +`After=docker.service`; the outer container carries `--restart unless-stopped` +so the **Docker daemon** keeps it alive across crash + reboot, while appx's +startup `EnsureRunning` re-attaches idempotently (no recreate). A real VM reboot +recovered docker → appx → healthy container with no manual step; +`scripts/smoke-deploy.sh` is **41/41** (now asserting `RestartPolicy=unless-stopped` ++ daemon-driven crash recovery) and `verify-installation.sh` **61/61**. Host mode +is gone from `deploy/` (no `appx-agent` user, no `agent-server.service`, no host +Pi/agent-server); secrets reach the agent **only** via the service env +(`/etc/appx/secrets.env`, `root:root 0600`) forwarded into the container **by +name** — verified present via `docker exec printenv` and **absent** from +`journalctl -u appx`. + +**Cross-repo finding (affects this repo's entrypoint):** `docker kill`/`docker +stop` do **not** trigger an `unless-stopped` restart — they set the container's +manual-stop flag, which `unless-stopped` honours by design (vs `always`). A +genuine crash (the container's main process dying) **is** restarted by the +daemon, and the entrypoint's stale-`XDG_RUNTIME_DIR` wipe composes cleanly with +that daemon-driven restart (confirmed on the live box). + +**Follow-on finding + short-term fix (2026-06-13):** the entrypoint originally +wiped `XDG_RUNTIME_DIR` but did **not** `podman start --all` (despite earlier +plan wording claiming it did), so after a crash/recreate/reboot the inner DEV + +PROD app containers came back as `Created`/`Exited` and stayed **down** until the +next redeploy. Added a **short-term fix** to `container/entrypoint.sh`: after the +wipe + warmup, `podman start --all` (fail-soft via `|| true` so one bad inner +container can't crash-loop the outer one). Validated end-to-end on the live box: +a full outer recreate **and** a genuine `kill -9` crash now bring both apps back +up automatically. `--all` is deliberately blunt (see the Stage 5 item below for +the principled replacement). --- -## Stage 4 — Hardening (agent-server items) +## Stage 5 — Hardening (agent-server items) -(Stage 3 is appx-side; see sibling plan.) +(Stages 3–4 are appx-side; see sibling plan.) -- [ ] Entrypoint resurrects inner apps after an outer restart: **wipe stale `XDG_RUNTIME_DIR` runtime state first**, then `podman start --all` (the spike proved bare `podman start --all` fails without the wipe; `entrypoint.sh` already does this — confirm it covers both DEV and PROD containers). Architecture doc limitation #6 +- [ ] **Upstream Pi: Bedrock credential mapping** (cross-repo, see Stage 3 finding + #1). Today a Bedrock key set via the Settings UI is ignored because the SDK + passes it as `options.apiKey` while the provider only reads `options.bearerToken` + / `AWS_BEARER_TOKEN_BEDROCK`. Fix in Pi: map a stored `amazon-bedrock` api_key + credential to `bearerToken` (or accept `options.apiKey` in the bedrock provider), + so the UI path works without the `AWS_BEARER_TOKEN_BEDROCK` env workaround. +- [~] Entrypoint resurrects inner apps after an outer restart: **wipe stale `XDG_RUNTIME_DIR` runtime state first**, then `podman start --all` (the spike proved bare `podman start --all` fails without the wipe). **Short-term fix landed (2026-06-13):** `entrypoint.sh` now runs `podman start --all` (fail-soft) after the wipe + warmup, covering both DEV and PROD — proven on the live box across recreate + `kill -9` crash. **Still open for Stage 5:** replace the blunt `--all` with **registry-driven reconciliation** (start exactly the containers the project registry says should be running, with DEV/PROD intent) so stale/intentionally-stopped containers aren't resurrected and published-port clashes can't occur. Architecture doc limitation #6 - [ ] Bash tool `spawnHook` strips `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `*_API_KEY` from child process env — defence in depth so keys can't leak into `podman run -e`-style invocations even by accident (OWASP secrets-management alignment; keys live in the process heap, not child envs) +- [ ] **Validate `deployment.url`** in the create-project zod schema as a bounded + URL (`z.string().url().max(2048).optional()`) — defence-in-depth against prompt + injection via the URL that is interpolated into the agent's system prompt. + Today the only producer is appx (URL built from a slug-validated name + operator + base domain), so it is not attacker-controlled; this makes that a guarantee + rather than a property of the current caller. Requires an `openapi.json` regen + + agent-client snapshot re-sync. +- [ ] **Add `.pi` to the app template `.dockerignore`** — hygiene so a generated + app never copies `.pi/deployment.json` / `.pi/AGENTS.md` into its build context + (no secrets there, and the final image already excludes it, but keep builder + metadata out of build layers). - [ ] Optional golden-prompt LLM smoke (manual, pre-release): "build a single-page todo app and deploy it" → assert HTTP 200 on the reserved port within N minutes. Catches prompt/skill regressions; not CI --- @@ -272,6 +660,52 @@ Every networking boundary is tested by a real connection at exactly one layer an Validated or low-risk upgrades we defer to keep v1 simple and uniform. None require app-specific logic in appx. +### Publish the outer image (registry + pinned digest) + +Stage 4 builds `builder-outer` from the agent-server checkout **on the box** (prod +carries the source). A later improvement: build it in **CI**, push to a registry, +and set `APPX_AGENT_IMAGE=/builder-outer@sha256:…` so deploy **pulls a +pinned digest** instead of building — removing the agent-server source + build +step from prod, and making "what's running" immutable, reproducible, and +auditable. `tools-install.sh` already takes the pull path when `APPX_AGENT_IMAGE` +is a registry ref, so this is mostly a CI/registry task (decide tagging + signing, +e.g. cosign, and who owns the registry), not appx code. Deferred — building from +source is fine while the image and its base recipe are still moving. + +### Durable app data (persistent volumes for stateful apps) + +The deploy model rebuilds containers on every refinement/redeploy, so anything an +app writes to its **container layer is lost** on the next deploy; and the skill's +*Multi-container apps* section invites a `-db` sibling without saying how +to persist it. Today this is harmless (the seeded template is a static Vite SPA +with no data), but it becomes load-bearing the first time a template/app has a +database. Add a **"Persistent data"** convention to the `deploy-app` skill — but +as a deliberate design item, not a one-line `-v` flag, because it changes the +deploy model's statefulness semantics. It must cover: + +- **Named volumes, reused on redeploy, never `rm`'d.** e.g. + `-v -db-data:/var/lib/postgresql/data`. Redeploy replaces the + *container*, never the *data volume* (a "clean up" `volume rm` = data loss). + Volumes live under the `builder-podman-storage` named docker volume, so they + already survive outer restart/recreate. +- **DEV/PROD data isolation.** The skill currently runs **one** shared + `-db` for both env instances — which already half-breaks "iterate on + DEV without touching PROD," and durable data makes a bad DEV migration able to + corrupt PROD's real data. Persistence forces a choice: separate volumes (and + likely separate db containers) per env (`-db-dev-data` / + `-db-prod-data`), or an explicit, documented "DEV and PROD share data" + stance. +- **Migrations, not resets.** Once data survives rebuilds, schema changes must be + migrations; "drop and recreate" silently destroys user data. +- **Cleanup on project delete.** Durable volumes leak — today + `appx Delete → agent-server DeleteProject` removes the project dir + sessions + but **not** podman containers/images/volumes. A stateful model needs a teardown + hook (agent-server or the skill's "remove app" path) so volumes don't accumulate. + +Deferred because v1's template has no data, and getting it right is a small design +pass (the four points above) rather than a flag. Owned by this repo (skill + D6 +deploy model + template); the only appx touch-point is delete-time volume cleanup. + ### Hot-reload DEV (instant refinements) The inner-app spike (`container/INNER-APP-SPIKE.md`, T3) **proved** a faster @@ -300,7 +734,10 @@ rebuild-redeploy latency (a few seconds) proves to be real friction. 2. **"Works on host, breaks nested"** — mitigated by D3 (`APP_CONTAINER_RUNTIME`) + skill conventions written against `deployment.json`, not host assumptions. 3. **Skill quality** — the only part needing real-LLM iteration; isolated in Stage 1 where the feedback loop is fastest (no containers in the way). 4. **Outer restart kills inner apps** — addressed in Stage 4 (stale-state wipe + `podman start --all`); appx UI already shows honest per-port health. -5. **Two ports/project halves project density** under appx's published-port cap and doubles subdomains — tracked in `phase_9_plan.md`; revisit the cap. +5. **Two ports/project** doubles subdomains and halves density per published + port — **resolved (2026-06-12):** allocation range set to `10000–10199` (200 + ports = 100 projects). The outer-container publish range and `phase_9_plan.md` + D1 must be kept in lock-step with `PublishedPortRangeEnd = 10199`. 6. **Refinement latency** — dev=prod means every refinement is a rebuild + redeploy (~seconds, not instant). Accepted for v1; hot-reload (see *Potential improvements*) is the escape hatch and needs no appx change. (Realistic multi-stage builds under nesting — once a risk — are now **validated** by `container/INNER-APP-SPIKE.md`: dev+prod instances on two ports, redeploy with layer cache, and a Python app all worked unprivileged; Stage 2 smoke guards against regression.) diff --git a/openapi.json b/openapi.json index 22b1022..2b6752b 100644 --- a/openapi.json +++ b/openapi.json @@ -500,6 +500,35 @@ "channels" ] }, + "DeploymentTarget": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1024, + "maximum": 65535, + "description": "Reserved host port (non-privileged, 1024–65535).", + "example": 10007 + }, + "url": { + "type": "string", + "description": "Public URL appx exposes for this environment.", + "example": "https://eventx.example.com" + } + } + }, + "Deployment": { + "type": "object", + "properties": { + "dev": { + "$ref": "#/components/schemas/DeploymentTarget" + }, + "prod": { + "$ref": "#/components/schemas/DeploymentTarget" + } + }, + "description": "Control-plane deployment metadata, if registered." + }, "ProjectInfo": { "type": "object", "properties": { @@ -521,6 +550,9 @@ "type": "string", "description": "ISO-8601 UTC timestamp", "example": "2026-06-03T10:00:00.000Z" + }, + "deployment": { + "$ref": "#/components/schemas/Deployment" } }, "required": [ @@ -538,6 +570,16 @@ "minLength": 1, "description": "Human-facing project name. Slugified into the immutable id and directory name.", "example": "My Cool App" + }, + "deployment": { + "allOf": [ + { + "$ref": "#/components/schemas/Deployment" + }, + { + "description": "Optional control-plane deployment metadata (dev + prod ports/URLs). Idempotent re-POST updates it." + } + ] } }, "required": [ diff --git a/scripts/container-smoke.sh b/scripts/container-smoke.sh new file mode 100755 index 0000000..bba9ed6 --- /dev/null +++ b/scripts/container-smoke.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Stage 2 infra smoke — DETERMINISTIC, NO LLM. Exits 0 iff every REQUIRED check +# passes. This is the Stage 2 gate (docs/plans/builder-containers-plan.md). +# +# The agent only ever runs bash, so executing the deploy-app skill's LITERAL +# command sequence validates the entire chain without an LLM: +# +# host → docker publish → outer container → agent-server (4001) +# ↘ rootless podman → inner nginx (app) +# +# It builds the REAL seeded Vite template (a multi-stage build under nested +# rootless podman — not just nginx), runs it as DEV + PROD instances, redeploys +# DEV in isolation, and verifies survival across an outer-container restart. +# +# Checks marked [observe] never fail the run; their outcome is recorded for the +# findings. Everything else exits non-zero on failure. +# +# Determinism: this script removes the named volumes up front so a polluted box +# (e.g. leftover containers from manual spike runs that collide on the app +# ports) can never make the gate flap. Run it on a disposable Linux VM. +set -uo pipefail +cd "$(dirname "$0")/../container" + +readonly NAME="builder-outer" +readonly TOKEN="container-smoke-token" +readonly PROJECT="smoke-app" +readonly DEV_PORT=10000 +readonly PROD_PORT=10001 +readonly APP_PORT=8080 # the vite-spa template's container port (nginx listen) +PASS_COUNT=0 +FAIL_COUNT=0 + +# ── helpers ────────────────────────────────────────────────────────────────── + +pass() { echo " PASS: $1"; PASS_COUNT=$((PASS_COUNT + 1)); } +fail() { echo " FAIL: $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); } + +check() { # check + local description="$1" + shift + if "$@" > /tmp/csmoke-last.log 2>&1; then + pass "$description" + else + fail "$description" + sed 's/^/ | /' /tmp/csmoke-last.log | tail -n 15 + fi +} + +outer_exec() { docker exec "$NAME" "$@"; } +# Run a podman command inside the project dir, exactly as the deploy skill does. +proj_podman() { docker exec -w "/workspace/${PROJECT}" "$NAME" podman "$@"; } + +api() { # api [data] — authenticated, returns body on stdout + local method="$1" path="$2" data="${3:-}" + if [ -n "$data" ]; then + curl -fsS -X "$method" "http://127.0.0.1:4001${path}" \ + -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' -d "$data" + else + curl -fsS -X "$method" "http://127.0.0.1:4001${path}" \ + -H "Authorization: Bearer ${TOKEN}" + fi +} + +wait_health() { # poll GET / until agent-server answers (or time out) + for _ in $(seq 1 30); do + curl -fsS "http://127.0.0.1:4001/" > /dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +curl_app() { # curl_app — full host→inner chain, with retry for startup + curl -fsS --retry 15 --retry-delay 1 --retry-connrefused --retry-all-errors \ + "http://127.0.0.1:${1}/" > /dev/null +} + +# Fetch the hashed JS bundle the SPA's index.html references on and grep +# it for . This is how we prove a redeploy did/didn't change an instance. +bundle_contains() { # bundle_contains + local port="$1" marker="$2" asset + asset=$(curl -fsS "http://127.0.0.1:${port}/" | grep -oE '/assets/[^"]+\.js' | head -1) + [ -n "$asset" ] || return 2 + curl -fsS "http://127.0.0.1:${port}${asset}" | grep -q "$marker" +} + +# Inverse of bundle_contains: succeeds iff the marker is ABSENT (used to prove +# a redeploy left the other instance untouched). A fetch failure is a hard error +# (return 2), not a silent "absent". +bundle_lacks() { # bundle_lacks + local port="$1" marker="$2" asset + asset=$(curl -fsS "http://127.0.0.1:${port}/" | grep -oE '/assets/[^"]+\.js' | head -1) + [ -n "$asset" ] || return 2 + ! curl -fsS "http://127.0.0.1:${port}${asset}" | grep -q "$marker" +} + +# ── 0. clean slate ─────────────────────────────────────────────────────────── + +echo "[0] clean slate (deterministic: drop outer + both named volumes)" +docker rm -f "$NAME" > /dev/null 2>&1 || true +docker volume rm builder-workspace builder-podman-storage > /dev/null 2>&1 || true + +# ── 1. build + start (agent-server inside) ─────────────────────────────────── + +echo "[1] build image + start outer container (runs agent-server)" +# run-outer.sh reads AGENT_SERVER_TOKEN from the env and passes it via -e. +check "run-outer.sh builds + starts the outer container" \ + env AGENT_SERVER_TOKEN="$TOKEN" ./run-outer.sh + +echo "[2] security boundary unchanged (acceptance: docker inspect)" +check "outer main process uid is 1000 (builder)" \ + bash -c "[ \"\$(docker exec $NAME id -u)\" = '1000' ]" +check "Privileged=false" \ + bash -c "[ \"\$(docker inspect -f '{{.HostConfig.Privileged}}' $NAME)\" = 'false' ]" +check "CapAdd is empty" \ + bash -c "[ \"\$(docker inspect -f '{{.HostConfig.CapAdd}}' $NAME)\" = '[]' ]" +check "no no-new-privileges in SecurityOpt" \ + bash -c "! docker inspect -f '{{.HostConfig.SecurityOpt}}' $NAME | grep -q 'no-new-privileges'" +check "no /dev/fuse device" \ + bash -c "! docker inspect -f '{{.HostConfig.Devices}}' $NAME | grep -q '/dev/fuse'" +check "the two expected publishes are present (4001 + 10000-10199)" \ + bash -c "docker inspect -f '{{json .HostConfig.PortBindings}}' $NAME | grep -q '4001/tcp' \ + && docker inspect -f '{{json .HostConfig.PortBindings}}' $NAME | grep -q '10000/tcp'" + +# ── 3. agent-server reachable + auth enforced ──────────────────────────────── + +echo "[3] agent-server API healthy + bearer auth enforced" +check "GET / becomes healthy" wait_health +check "GET /v1/projects without token → 401" \ + bash -c "[ \"\$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4001/v1/projects)\" = '401' ]" +check "GET /v1/projects with token → 200" api GET /v1/projects + +# ── 4. project create: metadata + seeding ──────────────────────────────────── + +echo "[4] POST /v1/projects (dev+prod metadata) → create + seed" +check "create project with deployment metadata" \ + api POST /v1/projects \ + "{\"name\":\"${PROJECT}\",\"deployment\":{\"dev\":{\"port\":${DEV_PORT},\"url\":\"https://${PROJECT}-dev.example.com\"},\"prod\":{\"port\":${PROD_PORT},\"url\":\"https://${PROJECT}.example.com\"}}}" + +check ".pi/deployment.json exists inside the container with the right ports" \ + bash -c "docker exec $NAME cat /workspace/${PROJECT}/.pi/deployment.json \ + | tr -d ' \n' | grep -q '\"port\":${DEV_PORT}' \ + && docker exec $NAME cat /workspace/${PROJECT}/.pi/deployment.json \ + | tr -d ' \n' | grep -q '\"port\":${PROD_PORT}'" + +check "seeded template landed (vite-spa Dockerfile + index.html)" \ + bash -c "docker exec $NAME test -f /workspace/${PROJECT}/Dockerfile \ + && docker exec $NAME test -f /workspace/${PROJECT}/index.html" + +# ── 5. deploy: build the seeded template once, run DEV + PROD ───────────────── +# The deploy-app skill's literal commands (APP_CONTAINER_RUNTIME=podman). D6: +# DEV and PROD are the SAME build (two instances) — build :dev once, tag :prod. + +echo "[5] build seeded template once + run DEV + PROD instances" +build_start=$(date +%s) +check "podman build ${PROJECT}-app:dev (real multi-stage Vite build, nested)" \ + proj_podman build -t "${PROJECT}-app:dev" . +build_end=$(date +%s) +echo " [observe] cold multi-stage build under nested rootless podman: $((build_end - build_start))s" + +check "tag ${PROJECT}-app:prod = :dev (same build, D6)" \ + outer_exec podman tag "${PROJECT}-app:dev" "${PROJECT}-app:prod" + +outer_exec podman rm -f "${PROJECT}-app-dev" "${PROJECT}-app-prod" > /dev/null 2>&1 +check "run DEV instance on :${DEV_PORT}" \ + outer_exec podman run -d --name "${PROJECT}-app-dev" -p "${DEV_PORT}:${APP_PORT}" "${PROJECT}-app:dev" +check "run PROD instance on :${PROD_PORT}" \ + outer_exec podman run -d --name "${PROJECT}-app-prod" -p "${PROD_PORT}:${APP_PORT}" "${PROJECT}-app:prod" + +echo "[6] full chain: host curl reaches both inner apps" +check "host curl 127.0.0.1:${DEV_PORT} (DEV) returns the app" curl_app "$DEV_PORT" +check "host curl 127.0.0.1:${PROD_PORT} (PROD) returns the app" curl_app "$PROD_PORT" + +# ── 7. redeploy isolation: modify DEV, PROD must not change ─────────────────── + +echo "[7] redeploy modified DEV → DEV changes, PROD unchanged" +outer_exec sh -c "sed -i 's/Your app is running/CSMOKE_MARKER_V2 redeployed/' /workspace/${PROJECT}/src/main.js" +check "rebuild DEV only" proj_podman build -t "${PROJECT}-app:dev" . +outer_exec podman rm -f "${PROJECT}-app-dev" > /dev/null 2>&1 +check "redeploy DEV instance" \ + outer_exec podman run -d --name "${PROJECT}-app-dev" -p "${DEV_PORT}:${APP_PORT}" "${PROJECT}-app:dev" +check "host curl DEV reachable after redeploy" curl_app "$DEV_PORT" +check "DEV bundle now contains the marker" bundle_contains "$DEV_PORT" "CSMOKE_MARKER_V2" +check "PROD bundle does NOT contain the marker (untouched)" \ + bundle_lacks "$PROD_PORT" "CSMOKE_MARKER_V2" + +# ── 8. restart survival + recovery ─────────────────────────────────────────── + +echo "[8] outer restart: registry + workspace survive, podman start --all recovers" +docker restart "$NAME" > /dev/null +check "agent-server healthy again after restart" wait_health +check "project registry survived restart" \ + bash -c "docker exec $NAME cat /workspace/${PROJECT}/.pi/deployment.json | grep -q ${DEV_PORT} \ + && curl -fsS -H 'Authorization: Bearer ${TOKEN}' http://127.0.0.1:4001/v1/projects | grep -q ${PROJECT}" +check "workspace edit survived restart (DEV marker still in source)" \ + bash -c "docker exec $NAME grep -q CSMOKE_MARKER_V2 /workspace/${PROJECT}/src/main.js" +check "podman image store survived restart" \ + bash -c "docker exec $NAME podman images --format '{{.Repository}}' | grep -q ${PROJECT}-app" + +inner_state=$(outer_exec podman inspect -f '{{.State.Status}}' "${PROJECT}-app-dev" 2>/dev/null || echo "gone") +echo " [observe] inner DEV container state after outer restart: ${inner_state}" +# Stage 4 recovery mechanism: entrypoint wiped stale runtime state, now resurrect. +check "podman start --all resurrects the inner apps" outer_exec podman start --all +check "host curl DEV reachable after restart+recovery" curl_app "$DEV_PORT" +check "host curl PROD reachable after restart+recovery" curl_app "$PROD_PORT" + +# ── summary ────────────────────────────────────────────────────────────────── + +echo +echo "──────────────────────────────────────────" +echo "container-smoke result: ${PASS_COUNT} passed, ${FAIL_COUNT} failed" +if [ "$FAIL_COUNT" -eq 0 ]; then + echo "STAGE 2 CONTAINER SMOKE: PASS" + exit 0 +fi +echo "STAGE 2 CONTAINER SMOKE: FAIL" +exit 1 diff --git a/src/config.ts b/src/config.ts index a578628..b419476 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,12 @@ * AGENT_SERVER_TOKEN if set, /v1/* requires Bearer auth. * APPX_AGENT_SERVER_TOKEN is a legacy alias. * + * APPX_TEMPLATE_DIR optional app template recursively copied into a + * project dir the first time it is created. Absent ⇒ + * projects start empty. Must exist if set. + * APP_CONTAINER_RUNTIME container runtime the deploy skill + prompt section + * reference (default "podman"). docker in macOS dev. + * * LITELLM_* variables are owned by `./providers/litellm.ts` and parsed * separately at the same boundary. */ @@ -146,6 +152,9 @@ const RawEnv = z.object({ AGENT_SERVER_PORT: z.preprocess(blankToUndefined, z.coerce.number().int().positive().max(65535).default(4001)), AGENT_SERVER_TOKEN: optionalString, APPX_AGENT_SERVER_TOKEN: optionalString, + + APPX_TEMPLATE_DIR: optionalString, + APP_CONTAINER_RUNTIME: stringWithDefault("podman"), }); /** Fully resolved, validated server configuration. */ @@ -164,6 +173,10 @@ export type ServerConfig = { host: string; port: number; token: string | undefined; + /** Optional app template seeded into brand-new project dirs (absent ⇒ no seeding). */ + templateDir: string | undefined; + /** Container runtime the agent's deploy skill + prompt reference. */ + appContainerRuntime: string; }; /** @@ -205,6 +218,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { // alias when both are set. const token = raw.AGENT_SERVER_TOKEN ?? raw.APPX_AGENT_SERVER_TOKEN; + // Resolve + existence-check the optional template dir so a misconfigured path + // fails fast at boot rather than on the first project create. + const templateDir = raw.APPX_TEMPLATE_DIR ? resolve(raw.APPX_TEMPLATE_DIR) : undefined; + if (templateDir && !existsSync(templateDir)) { + throw new ConfigError([`APPX_TEMPLATE_DIR does not exist: ${templateDir}`]); + } + return { workspaceDir, anthropicApiKey: raw.ANTHROPIC_API_KEY, @@ -219,5 +239,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { host: raw.AGENT_SERVER_HOST, port: raw.AGENT_SERVER_PORT, token, + templateDir, + appContainerRuntime: raw.APP_CONTAINER_RUNTIME, }; } diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 3c0f484..389833d 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -315,13 +315,54 @@ export const ProjectIdParamSchema = z.object({ .openapi({ param: { name: "id", in: "path" } }), }); -/** Body for `POST /v1/projects`. Name-only — the id/dir are derived server-side. */ +/** + * One deployment environment's address. The control plane (appx) owns ports + * and URLs and pushes them down here; agent-server never invents or reports a + * port back. Both fields are optional so a partial registration is still valid. + * + * Security (OWASP fail-fast at the boundary): the port must be a non-privileged, + * in-range TCP port — privileged (<1024) or out-of-range values are rejected + * with a 400 before any persistence happens. + */ +export const DeploymentTargetSchema = z + .object({ + port: z + .number() + .int() + .min(1024) + .max(65535) + .optional() + .openapi({ example: 10007, description: "Reserved host port (non-privileged, 1024–65535)." }), + url: z.string().optional().openapi({ + example: "https://eventx.example.com", + description: "Public URL appx exposes for this environment.", + }), + }) + .openapi("DeploymentTarget"); + +/** + * Deployment metadata for a project: a DEV and a PROD environment built from + * the same image (two instances, not two builds). Authored by the control + * plane, read by the agent — an instruction, never a discovery. + */ +export const DeploymentSchema = z + .object({ + dev: DeploymentTargetSchema.optional(), + prod: DeploymentTargetSchema.optional(), + }) + .openapi("Deployment"); + +/** Body for `POST /v1/projects`. Name plus optional control-plane deployment metadata. */ export const CreateProjectRequestSchema = z .object({ name: z.string().min(1).openapi({ example: "My Cool App", description: "Human-facing project name. Slugified into the immutable id and directory name.", }), + deployment: DeploymentSchema.optional().openapi({ + description: + "Optional control-plane deployment metadata (dev + prod ports/URLs). Idempotent re-POST updates it.", + }), }) .openapi("CreateProjectRequest"); @@ -341,6 +382,9 @@ export const ProjectInfoSchema = z example: "2026-06-03T10:00:00.000Z", description: "ISO-8601 UTC timestamp", }), + deployment: DeploymentSchema.optional().openapi({ + description: "Control-plane deployment metadata, if registered.", + }), }) .openapi("ProjectInfo"); diff --git a/src/http/projectsRoutes.ts b/src/http/projectsRoutes.ts index bde6f08..8052a21 100644 --- a/src/http/projectsRoutes.ts +++ b/src/http/projectsRoutes.ts @@ -57,9 +57,9 @@ export function createProjectsApp(registry: ProjectRegistry): OpenAPIHono { }, }), (c) => { - const { name } = c.req.valid("json"); + const { name, deployment } = c.req.valid("json"); try { - return c.json(registry.createProject({ name }), 200); + return c.json(registry.createProject({ name, deployment }), 200); } catch (err) { if (err instanceof InvalidProjectNameError) { return c.json({ error: err.message }, 400); diff --git a/src/runtime/deployment.ts b/src/runtime/deployment.ts new file mode 100644 index 0000000..09ad4c2 --- /dev/null +++ b/src/runtime/deployment.ts @@ -0,0 +1,83 @@ +/** + * Pure helpers for project deployment metadata. + * + * Two consumers share these: the registry materialises `.pi/deployment.json` + * (machine-readable copy the agent can `cat`), and the runtime injects a short + * "Deployment" section into the system prompt. Both are derived from the same + * control-plane-authored record, so the agent's instructions can never drift + * from the file. See docs/plans/builder-containers-plan.md D2 + D6. + */ +import type { Deployment, DeploymentTarget } from "./projectStore.js"; + +export type { Deployment, DeploymentTarget }; + +/** True when neither environment carries a port or URL (nothing to surface). */ +export function isDeploymentEmpty(deployment: Deployment | undefined): boolean { + if (!deployment) return true; + return isTargetEmpty(deployment.dev) && isTargetEmpty(deployment.prod); +} + +function isTargetEmpty(target: DeploymentTarget | undefined): boolean { + return !target || (target.port === undefined && target.url === undefined); +} + +/** + * Serialise deployment metadata with a stable key order (dev before prod, port + * before url) so the materialised `.pi/deployment.json` is diff-friendly and + * reproducible regardless of the input object's property order. + */ +export function buildDeploymentJson(deployment: Deployment): string { + const ordered: Deployment = {}; + if (!isTargetEmpty(deployment.dev)) ordered.dev = orderTarget(deployment.dev); + if (!isTargetEmpty(deployment.prod)) ordered.prod = orderTarget(deployment.prod); + return `${JSON.stringify(ordered, null, 2)}\n`; +} + +function orderTarget(target: DeploymentTarget | undefined): DeploymentTarget { + const ordered: DeploymentTarget = {}; + if (target?.port !== undefined) ordered.port = target.port; + if (target?.url !== undefined) ordered.url = target.url; + return ordered; +} + +/** + * Build the generated "Deployment" system-prompt section appended after the + * project's `.pi/AGENTS.md`. Returns undefined when there is nothing to surface + * so callers can skip injection entirely. + * + * Stack-agnostic: it states the two-container (DEV/PROD, same build) model, the + * ports/URLs, the container-port mapping caveat, and points at the deploy skill + * and the machine-readable copy. It encodes no framework assumptions. + */ +export function buildDeploymentPromptSection( + deployment: Deployment | undefined, + appContainerRuntime: string, +): string | undefined { + if (isDeploymentEmpty(deployment)) return undefined; + const dev = deployment?.dev; + const prod = deployment?.prod; + + const lines: string[] = [ + "## Deployment", + "This project runs as TWO containers from the SAME build (two instances, not two builds):", + ]; + if (!isTargetEmpty(dev)) { + lines.push(`- DEV (iterate here): ${describeTarget(dev)} (container -app-dev)`); + } + if (!isTargetEmpty(prod)) { + lines.push(`- PROD (stable, shared): ${describeTarget(prod)} (container -app-prod)`); + } + lines.push( + 'Refinements rebuild + redeploy DEV; PROD changes only when you "promote".', + "The app listens on its container port; map it with -p :.", + `Container runtime: ${appContainerRuntime}. See the deploy-app skill for build/run/redeploy/promote conventions.`, + "Machine-readable copy: .pi/deployment.json", + ); + return lines.join("\n"); +} + +/** Render `host port `, gracefully degrading when a field is absent. */ +function describeTarget(target: DeploymentTarget | undefined): string { + const port = target?.port !== undefined ? `host port ${target.port}` : "host port (unset)"; + return target?.url ? `${port} → ${target.url}` : port; +} diff --git a/src/runtime/projectRegistry.ts b/src/runtime/projectRegistry.ts index ebdc3eb..6056370 100644 --- a/src/runtime/projectRegistry.ts +++ b/src/runtime/projectRegistry.ts @@ -1,8 +1,9 @@ -import { mkdirSync, rmSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; import { AuthStorage, ModelRegistry, type ModelRegistry as ModelRegistryType } from "@earendil-works/pi-coding-agent"; import { AgentCredentialsService } from "../credentials/credentialsService.js"; import { isValidProjectSlug, slugify, withCollisionSuffix } from "../utils/slug.js"; +import { buildDeploymentJson, type Deployment, isDeploymentEmpty } from "./deployment.js"; import { ProjectRuntime, type ProjectRuntimeConfig } from "./projectRuntime.js"; import { type ProjectRecord, ProjectStore } from "./projectStore.js"; @@ -12,6 +13,12 @@ export const GLOBAL_DIR_NAME = ".pi-global"; const SESSIONS_DIR_NAME = "sessions"; /** Filename of the durable project metadata registry. */ const PROJECTS_FILE_NAME = "projects.json"; +/** Pi's per-project config dir (under each project root). */ +const PROJECT_PI_DIR = ".pi"; +/** Machine-readable deployment metadata the agent can `cat` (control-plane-authored). */ +const DEPLOYMENT_FILE_NAME = "deployment.json"; +/** Directory names skipped when seeding a project from a template (build caches). */ +const TEMPLATE_SKIP_DIRS: ReadonlySet = new Set(["node_modules", ".next", ".next-build-check", "dist"]); /** * Public, serialisable view of a project — the shape returned by the @@ -34,10 +41,16 @@ export type ProjectInfo = ProjectRecord & { */ export type ProjectRegistryConfig = Omit< ProjectRuntimeConfig, - "authStorage" | "modelRegistry" | "credentials" | "projectDir" | "sessionsDir" + "authStorage" | "modelRegistry" | "credentials" | "projectDir" | "sessionsDir" | "deployment" > & { /** Absolute root holding every project dir plus `.pi-global/`. Must exist. */ workspaceDir: string; + /** + * Optional app template recursively copied into a project dir the first time + * it is created (D5). Absent ⇒ projects start empty, as before. Build caches + * (`node_modules`/`.next`/`dist`) are skipped during the copy. + */ + templateDir?: string; }; type RuntimeEntry = { @@ -159,17 +172,21 @@ export class ProjectRegistry { } /** - * Create a project, or return the existing one (idempotent). + * Create a project, or update the existing one (idempotent on name). * - * Idempotency key is the exact `name`: re-creating the same name (e.g. an - * upstream caller re-POSTing after a restart) returns the existing project - * untouched. A *different* name that slugifies to an already-taken id is a - * genuine collision and gets a short random suffix so both coexist. + * Idempotency key is the exact `name`: re-creating the same name (e.g. appx + * re-POSTing on startup reconcile) updates the project's `deployment` + * metadata and rewrites the materialised file, then returns it. A *different* + * name that slugifies to an already-taken id is a genuine collision and gets a + * short random suffix so both coexist. * - * Side effects on a fresh create: makes `WORKSPACE_DIR/{id}/` and persists the - * record to `projects.json`. The runtime is built lazily on first `getRuntime`. + * Side effects on a fresh create: makes `WORKSPACE_DIR/{id}/`, seeds it from + * the configured template if the dir did not already exist, persists the + * record to `projects.json`, and materialises `.pi/deployment.json` when + * deployment metadata is present. The runtime is built lazily on first + * `getRuntime`. */ - createProject({ name }: { name: string }): ProjectInfo { + createProject({ name, deployment }: { name: string; deployment?: Deployment }): ProjectInfo { const trimmedName = name.trim(); if (!trimmedName) throw new InvalidProjectNameError("project name is required"); @@ -180,11 +197,12 @@ export class ProjectRegistry { const existing = this.store.get(baseSlug); if (existing) { - // Same name → idempotent return. Different name → collision, suffix it. - if (existing.name === trimmedName) return this.toInfo(existing); - return this.insertProject(this.freeCollisionSlug(baseSlug), trimmedName); + // Same name → idempotent update of deployment metadata. Different name → + // collision, suffix it as a fresh project. + if (existing.name === trimmedName) return this.updateDeployment(existing, deployment); + return this.insertProject(this.freeCollisionSlug(baseSlug), trimmedName, deployment); } - return this.insertProject(baseSlug, trimmedName); + return this.insertProject(baseSlug, trimmedName, deployment); } /** Generate a suffixed slug not already taken by another project. */ @@ -197,17 +215,66 @@ export class ProjectRegistry { } /** Materialise a new project on disk + in the durable registry. */ - private insertProject(id: string, name: string): ProjectInfo { - mkdirSync(this.projectDir(id), { recursive: true }); + private insertProject(id: string, name: string, deployment?: Deployment): ProjectInfo { + const projectDir = this.projectDir(id); + const shouldSeedTemplate = !existsSync(projectDir); + mkdirSync(projectDir, { recursive: true }); + if (shouldSeedTemplate) this.seedProjectTemplate(projectDir); const record = this.store.add({ id, name, createdAt: new Date().toISOString(), + ...(deployment ? { deployment } : {}), }); - this.config.logger?.log(`[agent-server] created project id=${id} dir=${this.projectDir(id)}`); + this.materialiseDeployment(projectDir, deployment); + this.config.logger?.log(`[agent-server] created project id=${id} dir=${projectDir}`); return this.toInfo(record); } + /** Idempotent same-name re-POST: refresh deployment metadata + file, then return. */ + private updateDeployment(existing: ProjectRecord, deployment?: Deployment): ProjectInfo { + const updated = this.store.setDeployment(existing.id, deployment) ?? existing; + this.materialiseDeployment(this.projectDir(existing.id), deployment); + return this.toInfo(updated); + } + + /** + * Write (or remove) `/.pi/deployment.json`. Present metadata is + * written pretty-printed with a stable key order; absent/empty metadata leaves + * no file behind (and clears a stale one on a re-POST that drops it). + */ + private materialiseDeployment(projectDir: string, deployment?: Deployment): void { + const filePath = join(projectDir, PROJECT_PI_DIR, DEPLOYMENT_FILE_NAME); + if (isDeploymentEmpty(deployment)) { + rmSync(filePath, { force: true }); + return; + } + mkdirSync(join(projectDir, PROJECT_PI_DIR), { recursive: true }); + writeFileSync(filePath, buildDeploymentJson(deployment as Deployment), { mode: 0o644 }); + } + + /** + * Copy template contents into a freshly-created project dir, skipping build + * caches. Lifted from appx-orchestrator (comparison §1). A missing configured + * template is a loud failure — misconfiguration should not silently no-op. + */ + private seedProjectTemplate(projectDir: string): void { + const templateDir = this.config.templateDir ? resolve(this.config.templateDir) : undefined; + if (!templateDir) return; + if (!existsSync(templateDir)) { + throw new Error(`template directory does not exist: ${templateDir}`); + } + for (const entry of readdirSync(templateDir, { withFileTypes: true })) { + cpSync(join(templateDir, entry.name), join(projectDir, entry.name), { + recursive: true, + errorOnExist: true, + force: false, + filter: (path) => shouldCopyTemplatePath(templateDir, path), + }); + } + this.config.logger?.log(`[agent-server] seeded project template from ${templateDir}`); + } + /** Metadata for one registered project, or null if unknown. */ getProject(id: string): ProjectInfo | null { const record = this.store.get(id); @@ -236,6 +303,8 @@ export class ProjectRegistry { const runtime = await ProjectRuntime.create({ ...this.config, projectDir, + // Per-project deployment metadata drives the injected prompt section. + deployment: record.deployment, // Centralise transcripts under .pi-global/sessions/{id} so the project's // own .pi/ stays config-only (committable) and transcripts survive on the // workspace volume independently of the project tree. @@ -279,3 +348,10 @@ export class InvalidProjectNameError extends Error { this.name = "InvalidProjectNameError"; } } + +/** Skip build-cache directories (node_modules/.next/dist) when copying a template. */ +function shouldCopyTemplatePath(templateDir: string, path: string): boolean { + const rel = relative(templateDir, path); + if (!rel) return true; + return !rel.split(sep).some((part) => TEMPLATE_SKIP_DIRS.has(part)); +} diff --git a/src/runtime/projectRuntime.ts b/src/runtime/projectRuntime.ts index 58b59e0..91bbb8c 100644 --- a/src/runtime/projectRuntime.ts +++ b/src/runtime/projectRuntime.ts @@ -52,6 +52,7 @@ import { } from "@earendil-works/pi-coding-agent"; import type { AgentCredentialsService } from "../credentials/credentialsService.js"; import type { ThinkingLevel } from "../shared/thinking.js"; +import { buildDeploymentPromptSection, type Deployment } from "./deployment.js"; import { ProjectSession } from "./projectSession.js"; type SessionModel = NonNullable; @@ -150,6 +151,19 @@ export type ProjectRuntimeConfig = { agentsFile?: string; /** Optional logger; defaults to console. */ logger?: Pick; + /** + * Optional control-plane deployment metadata. When present, a generated + * "Deployment" section is appended to the resolved system prompt (after + * `.pi/AGENTS.md`, never replacing it) so the agent knows the DEV/PROD + * ports + URLs without reading a file. + */ + deployment?: Deployment; + /** + * Container runtime the deploy skill + prompt reference (default `"podman"`). + * Env config, never hardcoded, so Stage 1 host dev (docker) and the nested + * outer container (podman) share one skill. + */ + appContainerRuntime?: string; }; /** @@ -214,7 +228,12 @@ export class ProjectRuntime { // Read pinned system prompt up-front so we can both feed it into // the resource loader and suppress Pi's ancestor AGENTS.md walk. - const { systemPrompt, agentsFilePath } = resolveSystemPrompt(config, projectDir, logger); + const { systemPrompt: agentsPrompt, agentsFilePath } = resolveSystemPrompt(config, projectDir, logger); + + // Append the generated Deployment section after .pi/AGENTS.md (never + // replacing it) when the project carries control-plane metadata. + const deploymentSection = buildDeploymentPromptSection(config.deployment, config.appContainerRuntime ?? "podman"); + const systemPrompt = composeSystemPrompt(agentsPrompt, deploymentSection); // Caller may share an AuthStorage across projects; otherwise build a // project-local one against the resolved agentDir so our auth.json @@ -606,3 +625,18 @@ function resolveSystemPrompt( const systemPrompt = readFileSync(conventionPath, "utf8"); return { systemPrompt, agentsFilePath: conventionPath }; } + +/** + * Combine the resolved AGENTS.md prompt with the generated Deployment section. + * The deployment section is appended after the project prompt, never replacing + * it; either may be absent. Returns undefined when neither exists so the caller + * preserves Pi's default context-file discovery. + */ +function composeSystemPrompt( + agentsPrompt: string | undefined, + deploymentSection: string | undefined, +): string | undefined { + if (deploymentSection === undefined) return agentsPrompt; + if (agentsPrompt === undefined) return deploymentSection; + return `${agentsPrompt}\n\n${deploymentSection}`; +} diff --git a/src/runtime/projectStore.ts b/src/runtime/projectStore.ts index 30cdfdc..8db7ed1 100644 --- a/src/runtime/projectStore.ts +++ b/src/runtime/projectStore.ts @@ -20,6 +20,22 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +/** One deployment environment's address (control-plane-owned port + public URL). */ +export type DeploymentTarget = { + port?: number; + url?: string; +}; + +/** + * Deployment metadata for a project: DEV + PROD environments built from the + * same image. Authored by the control plane (appx), read by the agent. See + * docs/architecture/other/orchestrator-comparison.md §2.3. + */ +export type Deployment = { + dev?: DeploymentTarget; + prod?: DeploymentTarget; +}; + /** Persisted, agent-server-owned metadata for one project. */ export type ProjectRecord = { /** Immutable slug; registry key, route param, and on-disk directory name. */ @@ -28,6 +44,12 @@ export type ProjectRecord = { name: string; /** ISO-8601 creation timestamp. */ createdAt: string; + /** + * Optional control-plane deployment metadata (dev + prod ports/URLs). + * Absent on records created before this feature — the loader tolerates its + * absence so the registry stays backward compatible. + */ + deployment?: Deployment; }; /** On-disk envelope. Versioned so the schema can evolve without ambiguity. */ @@ -109,6 +131,20 @@ export class ProjectStore { if (this.records.delete(id)) this.persist(); } + /** + * Replace an existing record's deployment metadata and persist. Returns the + * updated record, or undefined if the id is unknown. Used for the idempotent + * same-name re-POST path where appx heals deployment drift. + */ + setDeployment(id: string, deployment: Deployment | undefined): ProjectRecord | undefined { + const existing = this.records.get(id); + if (!existing) return undefined; + const updated: ProjectRecord = { ...existing, deployment }; + this.records.set(id, updated); + this.persist(); + return updated; + } + /** Atomically write the registry to disk (temp file + rename). */ private persist(): void { const payload: ProjectStoreFile = { diff --git a/src/server.ts b/src/server.ts index 2b6878d..e2350b9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,6 +52,8 @@ const projectRegistry = await ProjectRegistry.create({ noSkills: config.noSkills, noPromptTemplates: config.noPromptTemplates, noThemes: config.noThemes, + templateDir: config.templateDir, + appContainerRuntime: config.appContainerRuntime, ...litellmRuntimeConfig(), }); diff --git a/test/deploymentPrompt.test.ts b/test/deploymentPrompt.test.ts new file mode 100644 index 0000000..04ef2bc --- /dev/null +++ b/test/deploymentPrompt.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for the pure deployment helpers: prompt-section generation and + * stable JSON serialisation. No runtime, no filesystem. + */ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { buildDeploymentJson, buildDeploymentPromptSection, isDeploymentEmpty } from "../src/runtime/deployment.js"; + +describe("buildDeploymentPromptSection", () => { + test("renders both dev and prod when present", () => { + const section = buildDeploymentPromptSection( + { + dev: { port: 10006, url: "https://eventx-dev.example.com" }, + prod: { port: 10007, url: "https://eventx.example.com" }, + }, + "podman", + ); + assert.ok(section); + assert.match(section, /## Deployment/); + assert.match(section, /DEV.*host port 10006 → https:\/\/eventx-dev\.example\.com.*-app-dev/); + assert.match(section, /PROD.*host port 10007 → https:\/\/eventx\.example\.com.*-app-prod/); + assert.match(section, /Container runtime: podman/); + assert.match(section, /\.pi\/deployment\.json/); + assert.match(section, /-p :/); + }); + + test("dev-only metadata omits the PROD line", () => { + const section = buildDeploymentPromptSection({ dev: { port: 10006, url: "https://d.example" } }, "docker"); + assert.ok(section); + assert.match(section, /- DEV/); + assert.doesNotMatch(section, /- PROD/); + assert.match(section, /Container runtime: docker/); + }); + + test("prod-only metadata omits the DEV line", () => { + const section = buildDeploymentPromptSection({ prod: { port: 10007 } }, "podman"); + assert.ok(section); + assert.match(section, /PROD/); + assert.doesNotMatch(section, /- DEV/); + // URL absent → just the host port. + assert.match(section, /host port 10007/); + }); + + test("absent / empty metadata yields no section", () => { + assert.equal(buildDeploymentPromptSection(undefined, "podman"), undefined); + assert.equal(buildDeploymentPromptSection({}, "podman"), undefined); + assert.equal(buildDeploymentPromptSection({ dev: {}, prod: {} }, "podman"), undefined); + }); +}); + +describe("buildDeploymentJson", () => { + test("stable key order regardless of input order", () => { + const json = buildDeploymentJson({ + prod: { url: "https://eventx.example.com", port: 10007 }, + dev: { url: "https://eventx-dev.example.com", port: 10006 }, + }); + assert.equal( + json, + `${JSON.stringify( + { + dev: { port: 10006, url: "https://eventx-dev.example.com" }, + prod: { port: 10007, url: "https://eventx.example.com" }, + }, + null, + 2, + )}\n`, + ); + }); + + test("omits empty environments and fields", () => { + const json = buildDeploymentJson({ dev: { port: 10006 }, prod: {} }); + assert.equal(json, `${JSON.stringify({ dev: { port: 10006 } }, null, 2)}\n`); + }); +}); + +describe("isDeploymentEmpty", () => { + test("true for undefined / empty, false when any field set", () => { + assert.equal(isDeploymentEmpty(undefined), true); + assert.equal(isDeploymentEmpty({}), true); + assert.equal(isDeploymentEmpty({ dev: {}, prod: {} }), true); + assert.equal(isDeploymentEmpty({ dev: { port: 10006 } }), false); + assert.equal(isDeploymentEmpty({ prod: { url: "https://x" } }), false); + }); +}); diff --git a/test/projectLifecycle.test.ts b/test/projectLifecycle.test.ts index f89ef87..84346e8 100644 --- a/test/projectLifecycle.test.ts +++ b/test/projectLifecycle.test.ts @@ -4,7 +4,7 @@ * removal behaviour. No HTTP, no LLM calls. */ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, test } from "node:test"; @@ -221,3 +221,132 @@ describe("ProjectRegistry lifecycle", () => { } }); }); + +describe("ProjectRegistry deployment metadata", () => { + const deployment = { + dev: { port: 10006, url: "https://eventx-dev.example.com" }, + prod: { port: 10007, url: "https://eventx.example.com" }, + }; + + function deploymentFile(projectDir: string): string { + return join(projectDir, ".pi", "deployment.json"); + } + + test("dev+prod metadata round-trips create → get → list and writes deployment.json", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ workspaceDir: ws.dir, logger: silentLogger }); + const created = registry.createProject({ name: "eventx", deployment }); + + assert.deepEqual(created.deployment, deployment); + assert.deepEqual(registry.getProject("eventx")?.deployment, deployment); + assert.deepEqual(registry.listProjects()[0]?.deployment, deployment); + + const file = deploymentFile(created.projectDir); + assert.ok(existsSync(file), ".pi/deployment.json materialised"); + // Pretty-printed, stable key order (dev before prod, port before url). + assert.equal(readFileSync(file, "utf8"), `${JSON.stringify(deployment, null, 2)}\n`); + + // Survives a fresh registry (rehydrated from projects.json). + const reopened = await ProjectRegistry.create({ workspaceDir: ws.dir, logger: silentLogger }); + assert.deepEqual(reopened.getProject("eventx")?.deployment, deployment); + } finally { + ws.cleanup(); + } + }); + + test("same-name re-POST updates deployment and rewrites the file", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ workspaceDir: ws.dir, logger: silentLogger }); + const first = registry.createProject({ name: "eventx", deployment }); + + const updatedDeployment = { + dev: { port: 10010, url: "https://eventx-dev.example.com" }, + prod: { port: 10011, url: "https://eventx.example.com" }, + }; + const again = registry.createProject({ name: "eventx", deployment: updatedDeployment }); + + assert.equal(again.id, first.id); + assert.deepEqual(again.deployment, updatedDeployment); + assert.equal(registry.listProjects().length, 1); + assert.equal( + readFileSync(deploymentFile(again.projectDir), "utf8"), + `${JSON.stringify(updatedDeployment, null, 2)}\n`, + ); + } finally { + ws.cleanup(); + } + }); + + test("absent metadata writes no deployment.json", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ workspaceDir: ws.dir, logger: silentLogger }); + const created = registry.createProject({ name: "plain" }); + assert.equal(created.deployment, undefined); + assert.equal(existsSync(deploymentFile(created.projectDir)), false); + } finally { + ws.cleanup(); + } + }); +}); + +describe("ProjectRegistry template seeding", () => { + function makeTemplate(): string { + const dir = mkdtempSync(resolve(tmpdir(), "agent-server-template-")); + writeFileSync(join(dir, "Dockerfile"), "FROM scratch\n"); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "main.js"), "console.log('hi')\n"); + // A build-cache dir that must be skipped during the copy. + mkdirSync(join(dir, "node_modules", "left-pad"), { recursive: true }); + writeFileSync(join(dir, "node_modules", "left-pad", "index.js"), "// junk\n"); + return dir; + } + + test("copies template into a fresh project dir, skipping build caches", async () => { + const ws = makeWorkspace(); + const templateDir = makeTemplate(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + templateDir, + logger: silentLogger, + }); + const project = registry.createProject({ name: "seeded" }); + assert.ok(existsSync(join(project.projectDir, "Dockerfile")), "Dockerfile seeded"); + assert.ok(existsSync(join(project.projectDir, "src", "main.js")), "src seeded"); + assert.equal(existsSync(join(project.projectDir, "node_modules")), false, "node_modules skipped"); + } finally { + ws.cleanup(); + rmSync(templateDir, { recursive: true, force: true }); + } + }); + + test("leaves an existing project dir untouched (no seeding)", async () => { + const ws = makeWorkspace(); + const templateDir = makeTemplate(); + try { + // Pre-create the project dir with existing content. + const existingDir = join(ws.dir, "seeded"); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, "keep.txt"), "mine"); + + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + templateDir, + logger: silentLogger, + }); + const project = registry.createProject({ name: "seeded" }); + assert.ok(existsSync(join(project.projectDir, "keep.txt")), "existing content preserved"); + assert.equal( + existsSync(join(project.projectDir, "Dockerfile")), + false, + "no template copied over existing dir", + ); + } finally { + ws.cleanup(); + rmSync(templateDir, { recursive: true, force: true }); + } + }); +});