diff --git a/.github/workflows/devcontainer-spike.yml b/.github/workflows/devcontainer-spike.yml new file mode 100644 index 0000000..24225dc --- /dev/null +++ b/.github/workflows/devcontainer-spike.yml @@ -0,0 +1,50 @@ +# Phase-0 feasibility spike (SPEC DEC-031 / Appendix E.6). +# +# Proves on a GitHub-hosted ubuntu-latest runner that the devkit devcontainer +# can boot Agent Zero NESTED via ROOTLESS podman — no --privileged, no host +# docker.sock — and that the harness reaches + authenticates against it. +# +# This is the gate for everything else in the plugin-quality-standard SPEC: +# if this is red, the devcontainer model (DEC-018) needs the self-hosted-runner +# fallback before Phase 1. +name: devcontainer-spike + +on: + workflow_dispatch: + inputs: + a0_image: + description: "A0 image to boot nested (DEC-019 parameter)" + required: false + default: "docker.io/agent0ai/agent-zero:latest" + push: + branches: ["spike/devcontainer-phase0"] + +jobs: + rootless-nested-boot: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Tooling versions (rootless, no privilege) + run: | + podman --version + echo "rootless: $(podman info --format '{{.Host.Security.Rootless}}')" + ls -l /dev/fuse || true + + - name: Build the devkit devcontainer image (rootless) + run: podman build -t plugin-devkit:spike -f devcontainer/Containerfile devcontainer + + - name: Run Phase-0 acceptance INSIDE the devcontainer (nested, unprivileged) + env: + A0_IMAGE: ${{ github.event.inputs.a0_image || 'docker.io/agent0ai/agent-zero:latest' }} + run: | + podman run --rm \ + --device /dev/fuse \ + --security-opt label=disable \ + --sysctl net.ipv4.ip_unprivileged_port_start=0 \ + -e A0_IMAGE="$A0_IMAGE" \ + -v "$PWD":/workspace:Z \ + -w /workspace \ + plugin-devkit:spike \ + -lc 'bash devcontainer/spike/run-acceptance.sh' diff --git a/devcontainer/Containerfile b/devcontainer/Containerfile new file mode 100644 index 0000000..e974572 --- /dev/null +++ b/devcontainer/Containerfile @@ -0,0 +1,35 @@ +# Agent Zero plugin devkit — unified execution environment. +# +# One image, three uses (SPEC DEC-018): CI runner, developer dev container, +# AI-agent local env. It boots the Agent Zero container *nested and +# unprivileged* via rootless podman — no --privileged, no host docker.sock. +# +# BASE CHOICE (Phase-0 spike finding, 2026-06-19): +# The obvious base — the official Playwright image (Ubuntu jammy) + apt +# `podman` — FAILS to nest rootlessly: jammy ships podman 3.4.4, and nested +# rootless `podman run` dies with "cannot setup namespace using newuidmap". +# quay.io/podman/stable (modern podman, purpose-built + pre-configured for +# rootless nesting) WORKS. So we base on it and add the harness tooling. +# +# Trade-off: it's Fedora-based, so Playwright browser system-deps are a +# Phase-1 addition (Playwright's `--with-deps` is Debian/Ubuntu-only; on +# Fedora the deps are installed explicitly). The Phase-0 GATE is *nesting*, +# which needs no browser — so the spike image carries only what the +# acceptance needs (podman + curl). Node/Playwright land in Phase 1. + +FROM quay.io/podman/stable + +# Harness tooling the Phase-0 acceptance needs. podman + fuse-overlayfs + +# the rootless plumbing already ship in this base and are pre-configured. +RUN microdnf install -y curl jq \ + && microdnf clean all + +# The base ships an unprivileged `podman` user with a working subuid/subgid +# range and newuidmap/newgidmap caps — the exact setup the Ubuntu path lacked. +USER podman +WORKDIR /workspace + +# Sanity: report the (modern) podman this base provides. +RUN podman --version + +ENTRYPOINT ["/bin/bash"] diff --git a/devcontainer/README.md b/devcontainer/README.md new file mode 100644 index 0000000..8bf2265 --- /dev/null +++ b/devcontainer/README.md @@ -0,0 +1,55 @@ +# Devkit devcontainer (Phase-0 spike) + +One image, three uses (SPEC `DEC-018`): + +1. **CI** runs the plugin e2e lifecycle inside it. +2. **Developers** use it as a dev container. +3. **AI agents** run it (docker or podman) to develop + test locally before pushing. + +It boots the Agent Zero container **nested and unprivileged** via **rootless podman** — +no `--privileged`, no host `docker.sock`. + +> **Spike finding (base image is load-bearing):** basing on the official Playwright image +> (Ubuntu jammy) + apt `podman` (3.4.4) **fails** to nest rootlessly — +> `cannot setup namespace using newuidmap`. Basing on `quay.io/podman/stable` +> (podman 5.8.2, purpose-built for nesting) **works** (verified locally: +> `NESTED_IN_DEVKIT_OK`). So the image bases on podman/stable; **Playwright** browsers are a +> **Phase-1** Fedora addition (the Phase-0 gate is *nesting*, which needs no browser). See +> SPEC `DEC-039`. + +## Why this exists + +The whole plugin-quality-standard rests on one risky assumption (`Critical-4` in +`SPEC-REVIEW-001.md`): that rootless podman-in-podman works on GitHub-hosted runners. +This directory is the **Phase-0 gate** that proves it before anything else is built. + +The mechanism was confirmed on a real Linux host: + +``` +podman run --device /dev/fuse --security-opt label=disable --user podman \ + quay.io/podman/stable \ + podman run --rm docker.io/library/alpine echo OK # → OK, no --privileged +``` + +## Run it + +Locally (needs rootless podman + `/dev/fuse` + subuid/subgid on the host): + +```bash +podman build -t plugin-devkit:spike -f devcontainer/Containerfile devcontainer +podman run --rm --device /dev/fuse --security-opt label=disable \ + -v "$PWD":/workspace -w /workspace plugin-devkit:spike \ + -lc 'bash devcontainer/spike/run-acceptance.sh' +``` + +In CI: the `devcontainer-spike` workflow runs exactly this on `ubuntu-latest`. + +## Acceptance (`spike/run-acceptance.sh`) + +Passes iff, unprivileged: (1) a nested rootless container runs, (2) the real A0 image +boots nested and `/login` becomes healthy, (3) the harness logs in and gets an +authenticated response. The full `install → verify-installed → uninstall → +verify-uninstalled` cycle layers on top via the existing Playwright harness (Phase 2). + +> Status: spike. The image pins `mcr.microsoft.com/playwright:v1.48.0-jammy`; bump in +> lockstep with the harness's Playwright version when this graduates out of spike. diff --git a/devcontainer/spike/run-acceptance.sh b/devcontainer/spike/run-acceptance.sh new file mode 100755 index 0000000..465551e --- /dev/null +++ b/devcontainer/spike/run-acceptance.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Phase-0 feasibility acceptance (SPEC DEC-031 / Appendix E.6). +# +# Runs INSIDE the devkit devcontainer. Proves the gating assumption behind the +# whole standard: that the Agent Zero container can be booted *nested* inside +# this container by *rootless* podman — no --privileged, no host docker.sock — +# and that the harness can reach + authenticate against it. +# +# Pass criteria (the Critical-4 gate): +# 1. rootless podman runs nested (a throwaway alpine container runs) +# 2. the real A0 image boots nested and /login becomes healthy +# 3. the harness logs in and gets an authenticated response +# The full install/verify/uninstall cycle runs on top of this via the existing +# Playwright harness (proven on livekit) — it is Phase-2, not this gate. +set -euo pipefail + +# Fully-qualified: podman/stable enforces short-name resolution (no TTY to prompt in CI). +A0_IMAGE="${A0_IMAGE:-docker.io/agent0ai/agent-zero:latest}" # DEC-019: parameterized +# A0 serves on port 80. Under nested rootless, the inner A0 container shares the +# devcontainer's network namespace (--network=host); binding 80 there is allowed +# because the WORKFLOW runs the devcontainer with +# --sysctl net.ipv4.ip_unprivileged_port_start=0. So we reach A0 at localhost:80. +A0_HTTP_PORT="${A0_HTTP_PORT:-80}" +A0_NAME="a0-spike" +AUTH_LOGIN="${AUTH_LOGIN:-admin}" +AUTH_PASSWORD="${AUTH_PASSWORD:-admin}" +# First boot in cold CI = image pull + initialize.sh (clone BRANCH + pip install), +# which comfortably exceeds 5 min on a fresh runner. CI run cd3db4e showed A0's +# services reaching RUNNING right as a 300s window expired — so give it room. +BOOT_TIMEOUT="${BOOT_TIMEOUT:-720}" +BASE="http://localhost:${A0_HTTP_PORT}" +COOKIES="$(mktemp)" + +log(){ printf '\n\033[1;36m[spike]\033[0m %s\n' "$*"; } +fail(){ printf '\n\033[1;31m[spike FAIL]\033[0m %s\n' "$*"; exit 1; } + +cleanup(){ podman rm -f "$A0_NAME" >/dev/null 2>&1 || true; rm -f "$COOKIES"; } +trap cleanup EXIT + +# --- 0. environment sanity ---------------------------------------------------- +log "podman: $(podman --version) | rootless: $(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null || echo '?')" + +# --- 1. nested rootless podman can run a container at all --------------------- +log "1/3 nested rootless container smoke test" +podman run --rm docker.io/library/alpine:3.20 echo NESTED_OK | grep -q NESTED_OK \ + || fail "rootless podman could not run a nested container" +log " ✓ nested rootless container ran" + +# --- 2. boot the real A0 image nested ----------------------------------------- +log "2/3 booting A0 nested: $A0_IMAGE on :$A0_HTTP_PORT" +podman run -d --name "$A0_NAME" \ + --network=host \ + -e AUTH_LOGIN="$AUTH_LOGIN" -e AUTH_PASSWORD="$AUTH_PASSWORD" \ + -e A0_SEEDED_SETTINGS_JSON='{}' \ + -e BRANCH=main \ + "$A0_IMAGE" \ + bash -c 'set -e; mkdir -p /a0/usr; + printf "AUTH_LOGIN=%s\nAUTH_PASSWORD=%s\n" "$AUTH_LOGIN" "$AUTH_PASSWORD" > /a0/usr/.env; + printf "%s\n" "$A0_SEEDED_SETTINGS_JSON" > /a0/usr/settings.json; + # Rootless-nesting workaround: /usr/sbin/sshd exits 255 under rootless + # podman (privsep needs caps a mapped-root lacks). A0s supervisor + # event-listener kills the WHOLE instance on any process FATAL, so a + # flapping sshd takes A0 down ~8s after boot. SSH is irrelevant to the + # HTTP e2e, so replace sshd with a no-op that never exits → run_sshd + # stays RUNNING, watchdog stays armed for genuinely-critical procs. + printf "#!/bin/sh\nexec tail -f /dev/null\n" > /usr/sbin/sshd; chmod +x /usr/sbin/sshd; + exec /exe/initialize.sh "$BRANCH"' >/dev/null + +log " waiting up to ${BOOT_TIMEOUT}s for /login ..." +deadline=$(( SECONDS + BOOT_TIMEOUT )) +until curl -sf "$BASE/login" >/dev/null 2>&1; do + if [ $SECONDS -ge $deadline ]; then + echo "--- curl -v (last attempt) ---"; curl -v "$BASE/login" 2>&1 | tail -15 || true + echo "--- podman logs (tail) ---"; podman logs --tail 80 "$A0_NAME" || true + fail "A0 did not become healthy in ${BOOT_TIMEOUT}s" + fi + sleep 3 +done +log " ✓ A0 booted nested; /login is healthy" + +# --- 3. harness can authenticate ---------------------------------------------- +log "3/3 logging in as $AUTH_LOGIN" +curl -sf -c "$COOKIES" -b "$COOKIES" \ + --data-urlencode "username=$AUTH_LOGIN" \ + --data-urlencode "password=$AUTH_PASSWORD" \ + -o /dev/null "$BASE/login" || fail "login POST failed" + +# Authenticated fetch: unauthenticated requests to / redirect to /login; an +# authenticated session returns the app. -L follows; we assert we did NOT land +# back on the login form. +body="$(curl -s -L -b "$COOKIES" "$BASE/")" +echo "$body" | grep -qi 'name="password"' && fail "still seeing login form — auth did not stick" +log " ✓ authenticated session established" + +# Bonus (logged, not gating): try the plugins API with best-effort CSRF. +csrf="$(awk '/csrf_token/ {print $7}' "$COOKIES" | tail -1)" +if [ -n "${csrf:-}" ]; then + code="$(curl -s -o /dev/null -w '%{http_code}' -b "$COOKIES" \ + -H "X-CSRF-Token: $csrf" -H 'Content-Type: application/json' \ + --data '{"filter":{"custom":true}}' "$BASE/api/plugins_list" || true)" + log " (bonus) POST /api/plugins_list → HTTP $code" +fi + +log "PHASE-0 ACCEPTANCE PASSED — rootless nested A0 boot + auth confirmed."