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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/devcontainer-spike.yml
Original file line number Diff line number Diff line change
@@ -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'
35 changes: 35 additions & 0 deletions devcontainer/Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
55 changes: 55 additions & 0 deletions devcontainer/README.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions devcontainer/spike/run-acceptance.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading