Headless Linux dev container: Godot + .NET + Xvfb for autonomous testing

Claude Code running inside the project's dev container can now build the
game, launch a real Godot instance under Xvfb, and drive the automation
harness end-to-end — no Windows dependency.

Dockerfile adds (as root, before USER node):
- X11 / Mesa software GL / audio runtime deps + python3
- .NET SDK 9.0 via upstream dot.net install script -> /usr/local/dotnet
- Godot 4.6.2-stable mono Linux x86_64 -> /opt/godot/godot
- /usr/local/bin/godot-xvfb wrapper: auto-wraps invocations in
  xvfb-run -a --server-args="-screen 0 1280x720x24 ..."

harness.py picks GODOT_BIN from env, defaults to /opt/godot/godot on
Linux, and auto-wraps the subprocess in xvfb-run when DISPLAY is unset.
Windows code path unchanged.

init-firewall.sh adds api.nuget.org to the allowlist so dotnet restore
works post-boot. Godot + .NET SDK are fetched at image build time, before
the firewall exists.

New docs:
- autonomous_plan.md: design rationale, alternatives considered
- README.md: launch instructions for Windows terminal / Docker Desktop /
  VS Code Dev Containers / WSL2 natif
- CLAUDE.md already documents the harness (done in previous commit)

Validation: docker build succeeds; inside the container, dotnet --version
=9.0.313, godot --version=4.6.2.stable.mono, dotnet test=102/102,
python3 tools/automation/smoke.py passes end-to-end with 14 non-black
1280x720 PNGs. Mission 1 screenshot is visually identical to the Windows
build, and Xvfb determinism is a bonus (det_a.png ≡ det_b.png bytewise).
This commit is contained in:
Samuel Bouchet 2026-04-17 16:57:56 +02:00
parent 8f3b1b39e7
commit c451a50349
6 changed files with 442 additions and 4 deletions

View file

@ -25,8 +25,80 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
nano \
vim \
ca-certificates \
curl \
wget \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# -----------------------------------------------------------------------------
# Chessistics: headless Godot + .NET SDK
# -----------------------------------------------------------------------------
# 1. Xvfb + Mesa software GL + X/audio runtime deps for Godot's GL-compatibility renderer
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
xauth \
x11-utils \
libx11-6 \
libxcursor1 \
libxinerama1 \
libxrandr2 \
libxi6 \
libxext6 \
libxrender1 \
libxfixes3 \
libxss1 \
libxkbcommon0 \
libxkbcommon-x11-0 \
libgl1 \
libglx-mesa0 \
libgl1-mesa-dri \
libglu1-mesa \
libegl1 \
libgles2 \
libasound2 \
libpulse0 \
libfontconfig1 \
libfreetype6 \
libdbus-1-3 \
libudev1 \
fonts-dejavu-core \
python3 \
python3-pip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 2. .NET SDK 9.0 via the upstream install script (arch-agnostic, no apt repo needed)
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
&& bash /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/local/dotnet \
&& ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet \
&& rm /tmp/dotnet-install.sh
ENV DOTNET_ROOT=/usr/local/dotnet
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_NOLOGO=1
# 3. Godot 4.6.2-stable Mono for Linux x86_64
# The zip contains a directory like "Godot_v..._mono_linux_x86_64/" with
# an executable whose exact filename has varied across releases
# ("Godot_v..._mono_linux.x86_64" on 4.x). Locate it dynamically.
ARG GODOT_VERSION=4.6.2-stable
RUN mkdir -p /opt/godot \
&& cd /tmp \
&& wget -q "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" \
&& unzip -q "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" -d /opt/godot \
&& GODOT_EXE="$(find /opt/godot -maxdepth 3 -type f \( -name 'Godot_v*mono_linux*x86_64' -o -name 'Godot_v*mono_linux*x86_64' \) | head -1)" \
&& if [ -z "$GODOT_EXE" ]; then echo "Godot executable not found in zip" && ls -R /opt/godot && exit 1; fi \
&& chmod +x "$GODOT_EXE" \
&& ln -sf "$GODOT_EXE" /opt/godot/godot \
&& rm "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip"
ENV GODOT_BIN=/opt/godot/godot
ENV PATH=$PATH:/opt/godot:/usr/local/dotnet
# 4. xvfb wrapper — any Godot invocation gets its own virtual 1280x720x24 display.
# Usage: `godot-xvfb --path /workspace ...` or let tools/automation/harness.py
# invoke it automatically on Linux.
COPY godot-xvfb.sh /usr/local/bin/godot-xvfb
RUN chmod +x /usr/local/bin/godot-xvfb
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share

View file

@ -0,0 +1,15 @@
#!/bin/bash
# Wrap a Godot invocation in a fresh Xvfb 1280x720x24 display so the GL
# renderer has something to draw into. If DISPLAY is already set (real
# display / nested X server), skip xvfb-run and exec Godot directly.
set -euo pipefail
: "${GODOT_BIN:=/opt/godot/godot}"
if [[ -n "${DISPLAY:-}" ]]; then
exec "$GODOT_BIN" "$@"
fi
exec xvfb-run -a \
--server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" \
"$GODOT_BIN" "$@"

View file

@ -69,7 +69,8 @@ for domain in \
"api.anthropic.com" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com"; do
"statsig.com" \
"api.nuget.org"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then

174
README.md Normal file
View file

@ -0,0 +1,174 @@
# Chessistics
Jeu de logistique sur échiquier en Godot 4 / C#. Le joueur place des pièces
d'échecs sur un plateau ; elles se déplacent automatiquement et transportent
des ressources entre des productions et des demandes.
Voir [`CLAUDE.md`](CLAUDE.md) pour l'architecture (black-box simulation) et
les conventions internes.
## Arborescence
```
Chessistics/
├─ Scripts/ # Code Godot (présentation)
│ ├─ Automation/ # Harness autonome (CLI --automation=<dir>)
│ ├─ Board/ Input/ UI/ Pieces/ Presentation/
│ └─ Main.cs
├─ chessistics-engine/ # Moteur pur .NET, sans Godot
├─ chessistics-tests/ # Tests unitaires xUnit (sans Godot)
├─ Data/
│ ├─ campaigns/ # campaign_01.json (7 missions)
│ └─ levels/ # niveaux legacy
├─ tools/automation/ # Harness Python stdlib pour piloter Godot
├─ .devcontainer/ # Image Docker + firewall + Xvfb + Godot Linux
├─ Scenes/ icon.svg project.godot …
```
## Lancer le jeu (poste Windows direct)
Prérequis : Godot 4.6 mono + .NET 9 SDK installés localement.
```powershell
dotnet build Chessistics.csproj
"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64.exe" --path .
```
Tests headless du moteur :
```powershell
dotnet test chessistics-tests/
```
Smoke test du harness d'automatisation :
```powershell
python tools/automation/smoke.py
```
## Dev container autonome (recommandé pour Claude Code)
Le dossier `.devcontainer/` contient une image Docker qui embarque :
- **.NET SDK 9.0** (build + tests)
- **Godot 4.6.2-stable mono Linux x86_64** sous `/opt/godot/godot`
- **Xvfb** + Mesa software GL pour un framebuffer virtuel 1280×720
- **Python 3** pour le harness d'automatisation
- **Claude Code** installé automatiquement (`@anthropic-ai/claude-code`)
- Un firewall `iptables` qui restreint les sorties réseau à une allow-list
(GitHub, npm, Anthropic, NuGet, Sentry, Statsig)
Claude Code, lancé à l'intérieur, peut donc compiler le projet, exécuter
les tests, **démarrer une vraie instance de Godot en headless** et lire les
captures PNG produites — sans dépendance Windows.
Voir [`autonomous_plan.md`](autonomous_plan.md) pour le design détaillé.
### Option 1 — Docker Desktop + terminal Windows (le plus simple)
1. Installer [Docker Desktop pour Windows](https://www.docker.com/products/docker-desktop/).
Docker Desktop utilise WSL2 en coulisses ; aucune configuration WSL
manuelle n'est nécessaire.
2. Installer la CLI devcontainers :
```powershell
npm install -g @devcontainers/cli
```
3. Depuis PowerShell ou Terminal Windows, à la racine du repo :
```powershell
cd C:\Projets\Chessistics
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . zsh
```
La première commande construit l'image (long le premier coup : Godot +
.NET à télécharger). La seconde ouvre un shell interactif dans le
container.
4. Dans le container :
```bash
dotnet build Chessistics.csproj
python3 tools/automation/smoke.py
claude # lance Claude Code inside the container
```
### Option 2 — VS Code + extension Dev Containers
1. Installer Docker Desktop + [VS Code](https://code.visualstudio.com/) +
l'extension **Dev Containers**.
2. Ouvrir le dossier du repo dans VS Code.
3. Palette de commandes → `Dev Containers: Reopen in Container`.
4. Le terminal intégré est déjà dans le container. Lancer `claude` pour
démarrer Claude Code.
### Option 3 — WSL2 natif (plus performant si Docker Desktop rame)
Les bind-mounts Docker Desktop sur `C:\` sont plus lents qu'un fichier
stocké directement dans le système de fichiers WSL2. Pour un gros projet
c'est négligeable, mais si la lenteur devient visible :
1. Installer WSL2 + Ubuntu depuis le Microsoft Store.
2. Cloner le repo **à l'intérieur** de WSL2 (pas sur `/mnt/c/`) :
```bash
cd ~ && git clone <url> chessistics && cd chessistics
```
3. Installer Docker dans WSL2 (via `docker.io` apt ou Docker Desktop avec
intégration WSL2 activée).
4. Installer la CLI devcontainers :
```bash
npm install -g @devcontainers/cli
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . zsh
```
Avantage : I/O disque natif Linux. Inconvénient : pas d'accès Explorer
direct aux fichiers (`\\wsl$\Ubuntu\home\…`).
### Vérifier que tout fonctionne dans le container
```bash
dotnet --version # -> 9.0.x
godot --version # -> 4.6.2.stable.mono.official.*
dotnet test chessistics-tests/ # 102/102
python3 tools/automation/smoke.py # Godot boots, PNG screenshots written
ls .automation_runs/smoke/screens/ # 01_loaded.png, 02_placed.png, ...
```
Le harness Python détecte automatiquement Linux et enveloppe Godot dans
`xvfb-run`. Aucune variable d'environnement à positionner.
### Personnaliser la version de Godot
L'image par défaut pose Godot 4.6.2-stable. Pour changer, modifier
l'argument de build :
```jsonc
// .devcontainer/devcontainer.json
"build": {
"args": {
"GODOT_VERSION": "4.7.0-stable"
}
}
```
Puis `devcontainer up --workspace-folder . --build-no-cache`.
## Dépannage
| Symptôme | Cause probable | Fix |
|----------|----------------|-----|
| `godot --version` donne *no such file* | PATH non mis à jour | `source /etc/profile` ou relancer le shell |
| Screenshot tout noir | Aucun DISPLAY + pas d'xvfb | Vérifier `which xvfb-run` ; utiliser `godot-xvfb` au lieu de `godot` directement |
| `dotnet restore` bloque | Firewall bloque `api.nuget.org` | Vérifier que `init-firewall.sh` s'est bien exécuté avec les changements récents |
| Build Docker échoue au download de Godot | Réseau restreint côté hôte | Retry, ou installer Godot manuellement et commenter les lignes correspondantes |
| Claude Code demande `--dangerously-skip-permissions` | Comportement normal en container sandbox | Accepter si tu es conscient du modèle de confiance |
## Licence
Voir les fichiers du repo.

149
autonomous_plan.md Normal file
View file

@ -0,0 +1,149 @@
# Headless Linux dev container for autonomous Chessistics testing
## Why
Today the automation harness only runs on Windows because the Godot binary is
hardcoded to `C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`. When
Claude Code runs inside the project's dev container (Linux / `node:20`), it
can read source code and run `dotnet test`, but it **cannot launch the actual
game** — there's no Godot binary, no .NET SDK, and no display server for the
renderer.
The goal: make the dev container a self-contained environment where Claude
Code can build the project, launch a real Godot instance in headless Linux
mode, drive it via the automation harness, and read back 1280×720 PNG
screenshots — all without any Windows dependency.
## Design
### Pieces required
1. **Godot 4.6.2-stable Mono for Linux** — matches the Windows editor the
project already uses. Installed once at image build time to `/opt/godot/`
with a symlink `/opt/godot/godot`.
2. **.NET SDK 9.0** — the project targets `net9.0`. Installed via the
upstream `dot.net` install script to `/usr/local/dotnet/`.
3. **Xvfb + Mesa software GL** — a virtual framebuffer at `:99` so Godot's
GL-compatibility renderer has somewhere to draw. `xvfb-run` wraps any
command transparently.
4. **Python 3** — the automation harness is stdlib-only Python.
5. **Minimal X / audio runtime deps**`libx11`, `libxcursor`, `libxrandr`,
`libxi`, `libgl1`, `libgles2`, `libasound2`, `libxkbcommon0`, etc.
Without these, Godot exits on startup with `libXext not found`-style errors.
### How Godot reaches the framebuffer
Two options considered:
- **(A)** Run `Xvfb :99 -screen 0 1280x720x24 &` as a background process,
export `DISPLAY=:99`, launch Godot normally. Persistent display, shared by
many Godot runs.
- **(B)** Use `xvfb-run -a --server-args="-screen 0 1280x720x24"` as a prefix
on every Godot invocation. A fresh display per launch; cleans up
automatically on exit.
**Chosen: (B)**, because the automation harness already spawns Godot once per
`Harness.launch()` and cleans up on context exit — matches the per-launch
lifecycle naturally, no daemon to keep alive, no race on the display number.
A tiny wrapper `/usr/local/bin/godot-xvfb` wraps `xvfb-run … $GODOT_BIN
"$@"`, so the harness (or a human) only has to invoke one path.
### Integration with the existing harness
`tools/automation/harness.py` currently hardcodes the Windows Godot path. We
teach it two things:
1. Read `GODOT_BIN` from the environment first; fall back to the platform
default (Windows path on Windows, `/opt/godot/godot` on Linux).
2. On Linux, auto-prepend `["xvfb-run", "-a", "--server-args=-screen 0
1280x720x24"]` to the Godot launch command unless `DISPLAY` is already
set (someone has a real display, skip the wrap).
With those two tweaks, every existing Python helper (`smoke.py`,
`run_game.py`, `solve_*.py`) works unchanged inside the container.
### Firewall considerations
The container's `init-firewall.sh` runs at `postStartCommand`, **after** the
image is built, and drops all outbound traffic except to a small allowlist
(GitHub, npmjs, Anthropic, Sentry, Statsig). Impact on our pieces:
- **Godot binary + .NET SDK**: downloaded during `docker build`, which runs
_before_ the firewall exists → works unconditionally.
- **`dotnet restore`** (runtime, e.g. after a `git pull`): needs
`api.nuget.org`. Added to the allowlist.
- **Godot runtime**: no outbound traffic required — the engine runs fully
offline once installed.
### Build sequence inside the Dockerfile
As `root`, before the existing `USER node` switch:
```
# 1. X/GL/audio runtime + python + xvfb
apt-get install xvfb xauth libx11-6 libxcursor1 libxinerama1 libxrandr2 \
libxi6 libxext6 libgl1 libglx-mesa0 libgl1-mesa-dri libglu1-mesa \
libasound2 libxkbcommon0 libxkbcommon-x11-0 libfontconfig1 libdbus-1-3 \
python3 python3-pip
# 2. .NET SDK 9.0 via upstream install script
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- \
--channel 9.0 --install-dir /usr/local/dotnet
ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet
# 3. Godot 4.6.2-stable mono, Linux x86_64
wget https://github.com/godotengine/godot/releases/download/${VERSION}/\
Godot_v${VERSION}_mono_linux_x86_64.zip
unzip … -d /opt/godot
ln -s /opt/godot/Godot_v…/Godot_v…_mono_linux.x86_64 /opt/godot/godot
```
Then:
- `ENV GODOT_BIN=/opt/godot/godot`
- `ENV PATH=$PATH:/opt/godot:/usr/local/dotnet`
- Drop in `/usr/local/bin/godot-xvfb` wrapper
## File-by-file change list
| File | Change |
|------|--------|
| `.devcontainer/Dockerfile` | Add Godot / dotnet / xvfb installs before `USER node` |
| `.devcontainer/init-firewall.sh` | Append `api.nuget.org` to the domain allowlist |
| `.devcontainer/godot-xvfb.sh` *(new)* | `exec xvfb-run -a … "$GODOT_BIN" "$@"` |
| `tools/automation/harness.py` | Env-aware Godot path + Linux xvfb auto-wrap |
| `README.md` *(new)* | Windows / WSL2 launch instructions for the dev container |
Nothing inside `Scripts/` or `chessistics-engine/` changes. The harness
contract (inbox/outbox/screens) is platform-agnostic already.
## Verification
After rebuild:
1. `docker build .devcontainer -t chessistics-dev` succeeds.
2. `devcontainer up --workspace-folder .` starts the container and
post-start firewall passes.
3. Inside: `dotnet --version``9.0.x`, `godot --version`
`4.6.2.stable.mono.official.*`.
4. `dotnet build Chessistics.csproj` → green.
5. `dotnet test chessistics-tests/` → 102 / 102.
6. `python3 tools/automation/smoke.py` → loads mission 1, takes PNG
screenshots that are non-black, quits cleanly.
7. `Read` one of the PNGs — it should show the same mission 1 UI as the
Windows run (title bar, board, objectives, stock panel).
## Out of scope (explicit non-goals)
- **GPU acceleration**: we use Mesa software rendering. Xvfb + llvmpipe is
enough for 1280×720 at a few FPS, which is what the harness needs.
- **Real display forwarding** (X11 forwarding, VNC, noVNC): doable but
unnecessary — Claude reads PNGs, not a live video feed.
- **Multi-arch images**: we ship x86_64 only. ARM (Apple Silicon via
Docker Desktop emulation) would need `Godot_v…_mono_linux_arm64.zip`
straightforward to add if needed, not done here.
- **Shrinking the image**: Godot + .NET SDK adds ~500 MB. Worth it;
multi-stage builds could trim later.
- **Keeping Xvfb warm across launches**: the single-launch pattern is clean
enough. If someone ever scripts dozens of rapid Godot starts and the
xvfb-run startup cost shows up, revisit approach (A).

View file

@ -36,10 +36,37 @@ from typing import Any
# Resolve defaults relative to the repo root (parent of tools/).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_DEFAULT_GODOT = Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe")
_DEFAULT_RUNS = _REPO_ROOT / ".automation_runs"
def _default_godot_exe() -> Path:
"""Locate a Godot binary. Env var wins; otherwise platform defaults."""
env = os.environ.get("GODOT_BIN")
if env:
return Path(env)
if sys.platform.startswith("linux"):
return Path("/opt/godot/godot")
return Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe")
_DEFAULT_GODOT = _default_godot_exe()
def _wrap_with_xvfb(argv: list[str]) -> list[str]:
"""On Linux without an existing DISPLAY, wrap Godot in xvfb-run so the
GL-compatibility renderer has a framebuffer to target.
"""
if not sys.platform.startswith("linux"):
return argv
if os.environ.get("DISPLAY"):
return argv
return [
"xvfb-run", "-a",
"--server-args=-screen 0 1280x720x24 -ac +extension GLX +render -noreset",
*argv,
]
class HarnessError(RuntimeError):
"""Raised when a command fails or times out."""
@ -98,11 +125,11 @@ class Harness:
if not self.project_path.exists():
raise HarnessError(f"Project path not found: {self.project_path}")
args = [
args = _wrap_with_xvfb([
str(self.godot_exe),
"--path", str(self.project_path),
f"--automation={self.root}",
]
])
print(f"[harness] launching: {' '.join(args)}", file=sys.stderr)
# Inherit stdout/stderr so GD.Print output is visible.
self._proc = subprocess.Popen(args)