Validation containers (operator)

August 24, 2026 · View on GitHub

Executive overview

  • For: operators whose acceptance scenarios need the software actually running — an HTTP API, a CLI, or a TUI.
  • What it is: throwaway OCI containers, Podman-first with a Docker fallback.
  • When to reach for it: only when a scenario needs a live service; if a fast local test or HTTP probe suffices, skip the container.
  • Golden rules: run rootless, bind localhost on a free port, never bake in secrets, always clean up — with one scoped exception when the consumer is on the other side of a virtualization boundary (see Reaching a service across an OS boundary).
  • Not what you want if: you're trying to sandbox the agent loop itself (not the app under test) — see devcontainers.md instead.
  • Next: running-the-loop.md (--container) · devcontainers.md (sandbox the loop itself) · scenarios-and-scoring.md.

Some acceptance scenarios can only be judged against the software actually running — an HTTP API, a CLI, or a TUI. wgm selects a throwaway OCI container engine, Podman-first (Docker is a drop-in fallback), and reports the choice to the validating host/agent. The runner does not pretend that selecting an engine proves the host adapter built, started, waited for, and cleaned up the app. Containers are optional: reach for one only when a scenario needs a live service.

The terse rules live in references/validation-env.md; this is the operator-facing version.

The validation flow

flowchart TD
  B[Build image from the implementation] --> R[Run container: publish port / exec / PTY]
  R --> H{Ready? poll healthcheck}
  H -- no --> H
  H -- yes --> D[Drive each scenario step]
  D --> J[Judge satisfaction 0-100]
  J --> C[Clean up: remove container/image]

Podman-first, Docker fallback

Prefer rootless Podman with OCI images and a Containerfile (fall back to Dockerfile). The commands are argument-compatible, so the same flow works under either engine:

ActionPodmanDocker
Buildpodman build -t wgm-app -f Containerfile .docker build -t wgm-app -f Dockerfile .
Runpodman run --rm -d -p 8080:8080 --name wgm-app wgm-appdocker run --rm -d -p 8080:8080 --name wgm-app wgm-app
Execpodman exec wgm-app COMMANDdocker exec wgm-app COMMAND
Logspodman logs wgm-appdocker logs wgm-app
Stop/rmpodman rm -f wgm-appdocker rm -f wgm-app

Force one explicitly with loop.sh --container podman|docker; otherwise --container auto uses Podman when it is available and falls back to Docker. If neither is available, the runner reports that scenario container validation is unavailable instead of claiming it ran.

When to skip the container

If a fast local deterministic check suffices — a unit test, a type-check, a local HTTP probe — skip the container entirely. Don't make Podman/Docker a hard dependency of every wgm run; it is only for scenarios that need a running service.

Safety

  • Run rootless and as a non-root user inside the image.
  • Bind to localhost and pick a free port; parameterize it so iterations don't collide.
  • Never bake secrets into the image or mount credential files — pass throwaway test config only.
  • Always clean up (--rm, prune dangling images) so iterations don't leak containers.

Reaching a service across an OS boundary

localhost stays the secure default for same-side probes: if the thing calling your service runs on the same OS instance, bind loopback and stop there.

It is the wrong default when the consumer sits across a virtualization boundary — a Windows browser, editor, or CLI calling a service running inside WSL, or a host tool calling into a VM guest. The guest's 127.0.0.1 is not the host's, so a loopback-only bind is invisible from the other side even though the service is perfectly healthy.

Same-side consumerBind 127.0.0.1 on a free port. Probe with a local curl.
Cross-boundary consumerPublish on all interfaces (0.0.0.0, or -p / a port forward), and have the consumer connect to the guest's routable address — for WSL, the distro's IPv4 (ip -4 -o addr show scope global).

Verify from the consumer's own OS. A curl inside WSL never crosses the boundary, so it cannot tell you whether Windows can connect. On a Windows+WSL machine, run:

bash scripts/test-wsl-windows-boundary.sh

It publishes a disposable service on loopback and then on all interfaces, drives scripts/test-wsl-reachability.ps1 as a real Windows process, and prints the observed result per route (via=windows-localhost and via=wsl-ipv4), so the two binds are never conflated.

Green requires the documented working configuration to work end to end: on the all-interfaces bind over the wsl-ipv4 route, the page, the generated client asset and the WebSocket must all succeed — a 200 on the index page alone is not a pass. A WebSocket result of unsupported is tolerated only when the Windows host has no ClientWebSocket type, and it is reported as unsupported, never as a pass. The loopback bind is observational: its failure over wsl-ipv4 is the boundary itself, and a missing observation is reported as UNKNOWN, never as a confirmed boundary. A probe that never answers is reported as a timeout, not as a non-Windows process.

Exit codes: 0 green · 1 red · 2 usage · 3 unsupported host · 4 simulated.

--timeout (default 5s) is the network budget the probe applies per operation. The outer kill is (3 × --timeout) + a startup allowance — 20s by default, tunable with WGM_WSL_PWSH_STARTUP_ALLOWANCE — because Windows PowerShell's cold start over interop, plus on-access antivirus scanning, is not network time and must not be charged to it. That allowance only widens or narrows the grace, so it never marks a run simulated. Raise it on a cold or heavily scanned machine if probes are killed before they answer.

Every run opens with a seams-overridden= line. Only seams-overridden=none — a real distro, real interop, a Windows PowerShell the script found by itself — can print GREEN and count as evidence. If any WGM_WSL_* override is set, the run is labeled SIMULATED (UNVERIFIED) and exits 4, even when every check passed.

Two safety notes for the run itself: the second leg binds 0.0.0.0, which publishes the disposable fixture on all local interfaces (LAN-reachable) for the few seconds that leg lasts — that bind is the configuration under test — and it serves only static test content on an ephemeral port before being torn down. The Windows probe also disables the proxy for its own two requests, so a system proxy cannot silently reroute a local boundary probe; it changes no machine or user setting.

The portable half — bash scripts/test-wsl-boundary-harness.sh — runs anywhere (and in CI) and covers the harness's own accept/failure paths. Hosted CI cannot run the real boundary: Linux runners have no Windows interop and Windows runners have no WSL distro, so treat the boundary as verified only after a run on a real Windows+WSL machine. Source of this caveat: [learn] issue agent-frontier/wgm#101, which stays open until such a run is recorded.

See also: running-the-loop.md · scenarios-and-scoring.md.