AileronControlPlane
Sandbox launch runs the agent command inside the selected container image. Aileron prepares and validates the image, but the image must already contain the agent CLI.

Use `sandbox check` to validate an image before starting a daemon-backed session:

```bash
aileron sandbox check --runtime=docker --agent=claude
aileron sandbox check --runtime=docker --build=never --agent=codex
```

The check uses the same composition plan and minimal launch validation as `aileron launch --sandbox=...`: `/bin/sh`, the `/home/agent/workspace` mount, workspace write access, and the requested agent command on `PATH`.

## Support Matrix

| Agent | Command | Sandbox image support | MCP under `--sandbox=docker` | Notes |
|---|---|---|---|---|
| Claude Code | `claude` | Agent Feature (local build) | ✓ via `--mcp-config` | First-class Feature below. The default launch path builds the image **locally** from the Feature, because `@anthropic-ai/claude-code` is all-rights-reserved and cannot be redistributed as a published image ([#1451](https://github.com/ALRubinger/aileron/issues/1451)). Use `sandbox check --agent=claude` before launch. |
| Codex | `codex` | Agent Feature (published image) | ✓ via bind-mounted `config.toml` | Feature below. `@openai/codex` is Apache-2.0, so its per-agent image is published and pulled build-free. Sandbox launch writes a generated `config.toml` to a host tempdir and bind-mounts it into `/home/agent/.codex/config.toml` ([ADR-0024](/adr/0024-sandbox-mcp-parity/)). Host `~/.codex/config.toml` is never touched. |
| Goose | `goose` | Command contract only | ✓ via `--with-extension` | List the agent Feature in Tier 1, or install the CLI in a BYO image; no maintained Feature yet. |
| OpenCode | `opencode` | Command contract only | ✓ via workspace `opencode.json` | Launcher writes `opencode.json` into the launch directory; the workspace bind-mount makes it readable in-container. |
| Pi | `pi` | Command contract only | ✓ via `--mcp-config` | Shares Claude's MCP wiring. |
| Other agents | varies | Unsupported | n/a | Add an Aileron launch agent and an image recipe before relying on sandbox launch. |

Under `--sandbox=docker` the launcher resolves the host-built `aileron-mcp` binary, bind-mounts it read-only at `/usr/local/bin/aileron-mcp`, builds an `mcpEnv` rewritten for the runtime (`host.docker.internal` on Docker), and calls each agent's `ConfigureMCP` hook. Four of the five agents (Claude, Pi, Goose, OpenCode) work without any agent-side code change because their config is either inline-with-exec (`--mcp-config`, `--with-extension`) or workspace-local (`opencode.json` in the bind-mounted workspace). Codex is the one exception. Its host `~/.codex/config.toml` is irrelevant inside the container, so the launcher writes a generated `config.toml` to a host tempdir and bind-mounts it. See [ADR-0024](/adr/0024-sandbox-mcp-parity/) and the [manual walkthrough](/development/sandbox-mcp-walkthrough/) for the load-bearing flow.

Docker is the only supported sandbox runtime in v4. Podman is planned but not yet supported; its `host.containers.internal` host alias is the deferred re-add path, and passing `--runtime=podman` fails with `podman runtime is not supported yet (v4 is Docker-only); see ADR-0014` (see [ADR-0014](/adr/0014-spawn-sandbox-technology/)).

The harness-free `ghcr.io/alrubinger/aileron-sandbox-base` intentionally does not include agent CLIs ([ADR-0017](/adr/0017-sandbox-composition/)). Each agent install is authored once as a devcontainer Feature, the single source of truth that Aileron CI bakes into prebuilt per-agent images and that customers compose for Tier 1. The prebuilt per-agent image is the zero-build Tier 0 default, owned by [#965](https://github.com/ALRubinger/aileron/issues/965). Use Tier 1 when you want Aileron's base runtime plus an agent plus your own tools, or Tier 2 when your team owns the full image.

## Default Launch Paths

With no `.devcontainer` in the project, `aileron launch --sandbox=docker <agent>` resolves one of three default paths from the requested agent:

- **Published agent (build-free):** the launcher resolves the prebuilt per-agent image `ghcr.io/alrubinger/aileron-sandbox-<agent>` and pulls it. There is no `sandbox init`, no Dockerfile, and no local image build. The published agent today is `codex`, whose `@openai/codex` CLI is Apache-2.0 and so redistributable.
- **Recipe'd-but-not-publishable agent (local build):** the launcher composes the base image with the agent Feature and builds the image **locally** on your host through `@devcontainers/cli`, then launches it. `claude` takes this path: `@anthropic-ai/claude-code` is all-rights-reserved with no redistribution grant, so Aileron never bakes it into a published image ([#1451](https://github.com/ALRubinger/aileron/issues/1451)). The build needs only Docker on the host (the managed toolchain provisions Node and the CLI; see [Managed Toolchain Build](#managed-toolchain-build)). Aileron synthesizes the composed `devcontainer.json` into a managed temp workspace folder for the build, so your working directory is never written to. The resulting image is tagged locally and reused across launches, so only the first launch builds.
- **Empty or unsupported agent (local base):** `sandbox plan`/`build` (which pass no agent) and agents with no recipe keep the local agent-less base.

`sandbox check --agent=<agent>` resolves the same path, so a passing check matches what launch will run.

For a **published** agent, the launcher resolves the image in two ways. A release build with a recorded digest pin pulls a fixed image by `@sha256` (see [Reproducible releases](#reproducible-releases-digest-pinning) below). Otherwise it resolves a floating tag: a release build with no recorded pin pulls `latest` (which the image workflows move only on a `v*` tag), and a dev build off `main` pulls `edge` (republished on every merge to `main` that touches the image source, and on `workflow_dispatch`). So `latest` always names the most recent release and a dev run never clobbers it. A version-pinned tag (`<aileron-version>-<agent-cli-version>`) needs the agent CLI version, which the launcher does not know at resolve time, so the floating tag is the fallback when no digest is pinned. The freshness policy that keeps `edge` current is owned by [#1088](https://github.com/ALRubinger/aileron/issues/1088).

Credential sealing is runtime-side and image-provenance-agnostic ([ADR-0025](/adr/0025-vault-backed-agent-auth/)), so a locally-built Claude image launches with credentials sealed exactly as a published image would.

## Prebuilt Per-Agent Images

Aileron CI publishes one multi-arch image per **publishable** agent to GHCR. Each image is baked from the GHCR sandbox base plus that agent's devcontainer Feature install script, so the Feature stays the single source of truth. The published set is computed from `composition.PublishedAgents` (the publishable agents), so an agent whose CLI license forbids redistribution is never published.

| Agent | Image |
|---|---|
| Codex | `ghcr.io/alrubinger/aileron-sandbox-codex` |

Claude Code is **not** published: `@anthropic-ai/claude-code` is all-rights-reserved with no redistribution grant, so its image is built locally from the Feature at launch instead ([#1451](https://github.com/ALRubinger/aileron/issues/1451)). See the [Claude Code Feature](#claude-code-feature) section.

Each image is built for `linux/amd64` and `linux/arm64`, so a `docker pull` resolves the manifest for your platform automatically.

The base image (`sandbox-base.yml`) and the per-agent images (`sandbox-agents.yml`) are two separate workflows, both triggered directly by a `v*` tag push, so they run concurrently on the same tag. A per-agent build `FROM`s the version-pinned base tag, so before that `FROM` it waits for the base tag to publish: it polls the registry on a bounded retry loop until the concurrently-building base tag appears. A base that never publishes surfaces a precise timeout error naming the absent tag rather than failing mid-build.

On a merge to `main` the two workflows run in sequence rather than concurrently. The base workflow runs on the `main` push (filtered to image-affecting paths) and republishes `base:edge`. The per-agent workflow then runs as a `workflow_run` cascade keyed on the base workflow's completion, so it starts only after the base finishes its multi-arch push and composes the agents onto the freshly republished `base:edge`, never a stale one. The cascade leg runs only when the triggering base run succeeded on `main`. This keeps the image source and the `edge` tags in lockstep, so an in-repo change to the image (for example a new in-image entrypoint helper) cannot leave dev/main consumers pulling a base that predates it.

The tag scheme is `<aileron-version>-<agent-cli-version>` plus two floating tags: `latest`, moved only by a `v*` tag release, and `edge`, republished on every merge to `main` that touches the image source and on a `workflow_dispatch`. The `<aileron-version>` is the Aileron release the image was built from. The `<agent-cli-version>` is the agent CLI version baked into the image, resolved at build time from the installed package. A git-traceability tag `git-<sha>` is also published.

Pull the most recent release (`latest`), or the latest dev publish off `main` (`edge`):

```bash
docker pull ghcr.io/alrubinger/aileron-sandbox-codex:latest
# tip of main, republished on every image-affecting merge to main:
docker pull ghcr.io/alrubinger/aileron-sandbox-codex:edge
```

Pull a pinned version, for example Aileron `0.0.1` with the Codex CLI at `0.2.0`:

```bash
docker pull ghcr.io/alrubinger/aileron-sandbox-codex:0.0.1-0.2.0
```

CI smoke-tests every published image for launchability before it ships. The smoke asserts the agent CLI resolves on `PATH` and that the launcher's image validation succeeds.

The baked CLI is smoke-tested with `aileron --version`, which is vault-bypassing (see `commandsBypassingVault` in `cmd/aileron/vault_state.go`) and so prints without an unlocked vault. A real environment image boot with `AILERON_API_URL` injected is the other supported way to exercise the baked CLI end to end. Do not verify a published image with a bare `docker run <edge> aileron skill launch --help`. The `skill` command is not on the vault-bypass allowlist, so it enters the vault state machine and errors on vault state before printing help. That error is expected behavior for a bare invocation, not a defect in the image bake.

A daily watcher workflow (`sandbox-agents-watch.yml`) keeps the `edge` images fresh against upstream agent-CLI releases. It watches the publishable agents (computed from `composition.PublishedAgents`, so a non-publishable agent like Claude Code is never watched) by polling npm for each agent's latest CLI version, for example `@openai/codex`. It compares each against the CLI version baked into the `edge` image, recovered from the `dev-<cli-version>` tag co-located on the `edge` manifest digest. On drift it re-triggers `sandbox-agents.yml`, which rebuilds from the unpinned Feature install scripts and so bakes the latest CLI. The refreshed build publishes `edge` and `dev-<cli-version>`. Dev and main consumers pull `edge`, so they pick up new agent CLIs automatically without a release.

`latest` and the release-pinned `<aileron-version>-<agent-cli-version>` tags move on `v*` releases only, by design. A released user on `latest` stays pinned to that release's CLI version until the next release. This is intentional. A release is an immutable point and `latest` names the most-recent release, so a background job must never clobber it. Keeping `latest` fresh between releases is an accepted gap, not a bug.

The watcher supports a `dry_run` `workflow_dispatch` input for demonstration, which detects and reports drift without dispatching a rebuild. It uses only `GITHUB_TOKEN` and bakes no credentials anywhere.

## Reproducible Releases: Digest Pinning

The floating `latest` tag is mutable. The freshness watcher republishes images between releases, so a `latest`-resolving launcher could pull a different image than the one a release was cut against. Digest pinning ([#1233](https://github.com/ALRubinger/aileron/issues/1233)) removes that drift: a release pins each per-agent image to its immutable `@sha256` digest, so the same release binary always pulls the same image.

The pins live in a committed lockfile, `internal/sandbox/composition/agent-images.lock.json`, embedded into the binary via `go:embed`. It maps each published agent to a `sha256:...` digest. `PublishedAgentImage` consumes it: a release build (a real version, which resolves to `latest`) returns `ghcr.io/alrubinger/aileron-sandbox-<agent>@sha256:...` when the lockfile records the agent, and falls back to the floating tag otherwise. A dev build keeps `edge` and is never pinned, so tip-of-`main` development always tracks the freshest image. An empty lockfile means no agent is pinned yet and releases keep pulling `latest`, the pre-#1233 behavior.

Regenerate the lockfile at release prep, after the release's images are published and before tagging the binary:

```bash
task generate:agent-digests
```

The generator (`internal/tools/agentdigests`) resolves each published agent's manifest digest from the registry via `docker buildx imagetools inspect` and writes the lockfile in canonical form. Commit the result, then tag the release. Override the resolved tag with `TAG=<tag>` (default `latest`). The generated file is deterministic, so a regenerate with no registry change is a no-op diff. A test asserts the committed lockfile is canonical, so a hand-edit or a stale regenerate fails CI.

## Reproducible Toolchains: Node Version + Checksum Pin

The managed-toolchain devcontainer build ([#1525](https://github.com/ALRubinger/aileron/issues/1525)) installs a fixed Node.js into the sandbox. To keep that install reproducible it is pinned the same way images are: a committed lockfile, `internal/sandbox/container/tools.lock.json`, embedded into the binary via `go:embed`. It records the pinned Node version and, per supported platform (`darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win-x64`), the sha256 of that platform's distribution archive. The version pin lives alongside the `@devcontainers/cli` pin in `internal/sandbox/container/runtime.go`.

`VerifyNodeChecksum` is the regression guard the toolchain fetcher calls at the network boundary. It looks up the pin for the requested version and the build platform's `GOOS/GOARCH` and rejects a downloaded archive whose sha256 disagrees, naming the platform and both hashes, so a tampered or drifted distribution fails the build rather than installing silently. Unlike the agent-images lock, the Node toolchain has no floating fallback: a version pin with no checksums is rejected at parse time.

Regenerate the toolchain lock when bumping the pinned Node version:

```bash
task generate:tools-lock VERSION=22.14.0
```

The generator (`internal/tools/toolslock`) resolves each supported platform's archive sha256 from Node's published `SHASUMS256.txt` and writes the lockfile in canonical form. It does not verify the detached GPG signature on that file; the signature-verified fetch pipeline is a separate concern under #1525. The generated file is deterministic, so a regenerate with no version change is a no-op diff. A test asserts the committed lockfile is canonical, so a hand-edit or a stale regenerate fails CI.

### Refreshing the embedded Node release keyring

The signature-verified fetch pipeline trusts a baked-in set of GPG public keys, not a key fetched at runtime. Those keys live in `internal/sandbox/nodedist/nodekeys.asc`, embedded into the binary via `go:embed` and parsed by `DefaultKeyring` in `internal/sandbox/nodedist/keyring.go`. The asset holds one armored public-key block per current Node release signer.

The authoritative source for these keys is the [nodejs/node](https://github.com/nodejs/node) repository: its README maintains the canonical list of release-signing GPG key fingerprints (the "Release keys" / "Release Schedule" signer table). Those fingerprints, and only those, are what the embedded asset must contain. Do not derive or auto-pin fingerprint constants from the asset itself: the trust anchor is the human-verified nodejs/node key list, and re-deriving constants from the committed blob would launder an unverified key into a "pinned" one.

Refresh the asset by hand when Node rotates a release signer (for example when a new pinned version's `SHASUMS256.txt` is signed by a key not yet in the keyring):

1. Open the current release-key list in the [nodejs/node README](https://github.com/nodejs/node#release-keys) and note the full fingerprint of each active signer.
2. For each fingerprint, fetch the public key from a keyserver and verify its fingerprint matches the README exactly before trusting it:

   ```bash
   gpg --keyserver hkps://keys.openpgp.org --recv-keys <FINGERPRINT>
   gpg --fingerprint <FINGERPRINT>   # confirm it matches the nodejs/node README
   ```

3. Re-export the verified keys as armored blocks and write them, concatenated (one block per signer), to `internal/sandbox/nodedist/nodekeys.asc`:

   ```bash
   gpg --armor --export <FINGERPRINT_1> <FINGERPRINT_2> ... > internal/sandbox/nodedist/nodekeys.asc
   ```

`TestDefaultKeyringVerifiesPinnedRelease` (`internal/sandbox/nodedist/keyring_test.go`) is the regression guard for this asset. It verifies the genuine `SHASUMS256.txt` signature Node published for the pinned version against the embedded keyring, so it fails until the asset is refreshed with the signer of the pinned release. A red `TestDefaultKeyringVerifiesPinnedRelease` after a Node version bump is the signal that the keyring needs the procedure above.

## Managed Toolchain Build

A Tier 1 devcontainer that declares `features` cannot be built with raw `docker build`, so Aileron routes it through `@devcontainers/cli`, which needs a Node runtime. The managed toolchain is the default. Aileron provisions a verified, pinned Node and the pinned CLI itself, so Docker is the only host prerequisite for a Features build on Linux. The host does not need Node installed.

The host-npx toolchain is the opt-out. It resolves both Node and the CLI through the host's `npx` (`npx --yes @devcontainers/cli@<pinned>`), so the host must have Node installed when you select it. Opt out on a Features build with the `--toolchain` flag:

```bash
aileron sandbox build --toolchain=host-npx
aileron sandbox check --toolchain=host-npx --agent=claude
```

The same selection is available through the `AILERON_SANDBOX_TOOLCHAIN` environment variable, which `aileron launch` also reads. Precedence is flag, then environment, then the default. The default is the managed toolchain ([#1530](https://github.com/ALRubinger/aileron/issues/1530)); pass `--toolchain=host-npx` or set `AILERON_SANDBOX_TOOLCHAIN=host-npx` to opt out.

The managed toolchain is verified by a real `devcontainer build` in CI on Linux, which is the required, gating environment. macOS and Windows managed-build verification is a **known gap**: GitHub-hosted macOS runners ship no Docker engine and Windows runners default to Windows containers, so a real Linux-base Features build cannot run there, and CI does not run a leg that cannot pass. Managed-build coverage on those platforms is pending Docker-capable (self-hosted) runners. Until then, the host-npx path is the supported route on macOS and Windows: set `AILERON_SANDBOX_TOOLCHAIN=host-npx` (it needs host Node), or supply the escape hatch.

On first managed build Aileron fetches the pinned Node distribution into a content-addressed cache keyed by the distribution's verified sha256, and verifies it against `tools.lock.json` at the network boundary. It then installs the pinned `@devcontainers/cli` into a separate cache directory keyed by the CLI version. Both caches short-circuit on a warm hit, so subsequent builds reuse them without re-downloading. The two build paths differ only in the invocation prefix that precedes the `build` subcommand (`npx --yes @devcontainers/cli@<pinned>` for host-npx versus the managed node binary plus the CLI's JS entrypoint for managed); the `build --workspace-folder … --image-name … --build-arg …` tail is identical.

For a hermetic or offline host, point Aileron at a pre-staged Node binary and CLI entrypoint with the escape hatch, which skips provisioning entirely:

```bash
aileron sandbox build --toolchain=managed \
  --node=/opt/node/bin/node \
  --devcontainer-cli=/opt/devcontainer-cli/devcontainer.js
```

The escape hatch is also available through `AILERON_SANDBOX_NODE` and `AILERON_DEVCONTAINER_CLI`. Both paths must be supplied together and must exist on disk; a half-configured escape hatch is rejected rather than partially provisioned.

### Prefetch and offline builds

`aileron sandbox warm` pre-stages the managed toolchain ahead of the first build. It fetches and verifies the pinned Node distribution and installs the pinned `@devcontainers/cli` into the content-addressed cache, exactly as a managed build does on a cold cache. Run it once on a host with network access:

```bash
aileron sandbox warm
```

Warm is idempotent. A warm cache short-circuits without re-downloading. Warm applies only to the managed toolchain, so `aileron sandbox warm --toolchain=host-npx` is rejected because the host-npx path has nothing to pre-fetch.

Once warmed, `aileron sandbox build --offline` (and `aileron sandbox check --offline`) resolve the managed toolchain from that cache with no network access:

```bash
aileron sandbox build --offline
aileron sandbox check --offline --agent=claude
```

Offline mode computes the Node cache key from the committed `tools.lock.json` pin rather than downloading the signed checksums to learn it, so it never touches the network. The cache key already comes from that pin, so re-asserting it against the pin is a cheap consistency check rather than a tamper guard: the offline path does not re-hash the cached Node contents. On a cold cache the build fails with an actionable error that names `aileron sandbox warm`, so run warm with network access first.

This is distinct from the escape hatch above. The escape hatch points at pre-staged Node and CLI binaries you manage yourself. The `--offline` route resolves the Aileron-managed, pin-verified toolchain from Aileron's own cache.

## Claude Code Feature

The Claude Code agent Feature installs the `claude` CLI onto `ghcr.io/alrubinger/aileron-sandbox-base`. It is the single source of truth you compose for Tier 1, and the recipe Aileron builds **locally** on your host for the default launch path. The Feature lives at `images/sandbox-features/claude/` (`devcontainer-feature.json` plus `install.sh`).

Claude Code is not redistributed as a published image: `@anthropic-ai/claude-code` is all-rights-reserved with no redistribution grant, so Aileron never bakes it into a GHCR image ([#1451](https://github.com/ALRubinger/aileron/issues/1451)). The default launch path therefore builds the image locally from the Feature instead of pulling one. The build needs only Docker on the host; the managed toolchain provisions Node and `@devcontainers/cli` (see [Managed Toolchain Build](#managed-toolchain-build)). The composed `devcontainer.json` is synthesized into a managed temp workspace folder, so your working directory is never written to, and the locally-tagged image is reused across launches so only the first launch builds.

The locally-built Claude image needs no per-image `aileron-mcp` copy of its own. It composes `FROM ghcr.io/alrubinger/aileron-sandbox-base`, the published base that already bakes `aileron-mcp` at `/usr/local/bin/aileron-mcp` and stamps the `ai.aileron.mcp.version` label (see [`images/sandbox-base/Containerfile.published`](https://github.com/ALRubinger/aileron/blob/main/images/sandbox-base/Containerfile.published) and [ADR-0024](/adr/0024-sandbox-mcp-parity/)). A child image built `FROM` that base inherits both the baked binary and the label, and the devcontainer CLI adding its own metadata label does not clear the inherited one. So the launcher reads `ai.aileron.mcp.version` on the local Claude image, resolves the baked path, and skips the host bind-mount of `aileron-mcp` entirely. The local Claude path stays on the baked path and dodges the host-mount friction by inheritance, with no Claude-specific Dockerfile or `COPY` step.

For the default local-build path, launch directly with no `.devcontainer` in your project:

```bash
aileron launch --sandbox=docker claude
```

For the Tier 1 customization path, list the Claude Feature in your `devcontainer.json` (see [Scaffold a Starter Devcontainer](/development/sandbox-composition/#scaffold-a-starter-devcontainer)), then validate and launch:

```bash
aileron sandbox build --runtime=docker
aileron sandbox check --runtime=docker --agent=claude
aileron launch --sandbox=docker claude
```

Claude Code still owns its own authentication flow. Do not bake Claude, Anthropic, cloud, or Aileron credentials into the image.

## Codex Feature

The Codex agent Feature installs the `codex` CLI onto `ghcr.io/alrubinger/aileron-sandbox-base`. The `@openai/codex` npm package ships prebuilt musl binaries, so it installs cleanly on the Alpine base. `@openai/codex` is Apache-2.0, so unlike Claude its image is baked into the prebuilt, published Codex image; it is also composable for Tier 1. The Feature lives at `images/sandbox-features/codex/` (`devcontainer-feature.json` plus `install.sh`).

For the Tier 0 zero-build path:

```bash
aileron launch --sandbox=docker codex
```

For the Tier 1 customization path, list the Codex Feature in your `devcontainer.json`, then validate and launch:

```bash
aileron sandbox build --runtime=docker
aileron sandbox check --runtime=docker --agent=codex
aileron launch --sandbox=docker codex
```

Codex owns its own authentication flow. Do not bake OpenAI, cloud, or Aileron credentials into the image.

## BYO Image Contract

A BYO image must provide:

- `/bin/sh`
- a writable `/home/agent/workspace` bind mount when launched by Docker
- the requested agent command on `PATH`

On Linux + Docker the launcher additionally routes the container through the `aileron-remap-agent-uid` entrypoint (see the proxy contract below). A BYO image that runs as a non-root user must ship that helper so the workspace bind mount is writable by the agent. See [Workspace ownership on Linux](#workspace-ownership-on-linux).

Validate a BYO image by setting `customizations.aileron.image` in `.devcontainer/devcontainer.json` and running:

```bash
aileron sandbox check --runtime=docker --build=never --agent=claude
```

## BYO Image Proxy Contract

`aileron launch --sandbox=docker` runs the HTTPS proxy by default ([ADR-0019](/adr/0019-v4-https-data-plane/)). The launcher mounts a session-scoped CA at `/etc/aileron/proxy/ca.pem`, sets standard proxy env (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`), and runs the agent through the `aileron-run-with-proxy-ca` wrapper. For the proxy to terminate TLS without breaking the agent's HTTPS clients, the in-container trust store must include that CA before the agent starts.

`aileron sandbox check --agent=<command>` validates the proxy contract for every `--runtime=docker` invocation. The check exits non-zero with an actionable message when the image is missing any of the required pieces below. The launch-time validation runs the same script.

A BYO image meets the proxy contract by providing two helpers on `PATH`:

| Helper | Purpose |
|---|---|
| `aileron-install-proxy-ca` | Installs the mounted CA into the in-container trust store. Must accept `--check` to dry-run the trust-store probe without writing anything, and must accept an optional positional CA file argument (default `${AILERON_SANDBOX_PROXY_CA_FILE:-/etc/aileron/proxy/ca.pem}`). Exits 0 on success, 2 when the CA file is missing or empty, 126 when invoked unprivileged for an install, 127 when the underlying trust-store tooling is missing. |
| `aileron-run-with-proxy-ca` | Entrypoint wrapper that installs the CA as root, then drops privileges to the `agent` user and executes the requested agent command. The launcher always starts the container through this wrapper when the proxy is in force. |
| `aileron-remap-agent-uid` | Entrypoint wrapper that, started as root, remaps the in-container `agent` user/group to the numeric uid/gid owning the mounted workspace, then execs the rest of its argv still as root. The launcher prepends it on Linux + Docker so the workspace bind mount is writable by the agent. See [Workspace ownership on Linux](#workspace-ownership-on-linux). |

The canonical implementations ship with the `ghcr.io/alrubinger/aileron-sandbox-base` image. BYO authors who derive from another base distro can write drop-in equivalents. The launcher only cares about the CLI shape, not the trust-store mechanism. Pick the mechanism that matches the base:

| Base distro | Install file at | Apply with | Notes |
|---|---|---|---|
| Debian / Ubuntu | `/usr/local/share/ca-certificates/aileron-sandbox-proxy-ca.crt` | `update-ca-certificates` | Requires the `ca-certificates` package. |
| Alpine | `/usr/local/share/ca-certificates/aileron-sandbox-proxy-ca.crt` | `update-ca-certificates` | Provided by the `ca-certificates` package. The sandbox-base image's helper already works on Alpine because Alpine's `update-ca-certificates` accepts the same input directory. |
| RHEL / Fedora / Amazon Linux | `/etc/pki/ca-trust/source/anchors/aileron-sandbox-proxy-ca.crt` | `update-ca-trust extract` | Requires the `ca-certificates` package. Write a small wrapper that mirrors the Debian helper's CLI but switches the install path and update command. |

Two operational requirements apply to every equivalent helper:

1. The CA must be installed as `root` once at container start, before the agent process runs. This is what `aileron-run-with-proxy-ca` guarantees by switching back to the `agent` user with `exec` after the install.
2. The install step must be idempotent. The same helper is invoked on every container start, and the same CA is installed every time. Existing `update-ca-certificates` / `update-ca-trust` implementations are naturally idempotent.

Validate a BYO image meets both the agent and proxy contracts with:

```bash
aileron sandbox check --runtime=docker --build=never --agent=claude
```

The check reports `support: ok` only when the agent command and both proxy helpers are present and the `--check` probe succeeds. To launch without the proxy (useful for images that cannot meet the contract during initial bring-up), pass `--sandbox-proxy=off` on `aileron launch`. `sandbox check` does not honor that opt-out; it always exercises the full contract so BYO authors see the same failures the launcher would see.

## Workspace ownership on Linux

`aileron launch --sandbox=docker` bind-mounts your current working directory into the container at `/home/agent/workspace`. The container runs as the image's non-root `agent` user. On Linux + Docker the workspace bind mount keeps the host directory's owner uid (your operator uid, e.g. `1000`), while the `agent` user has its own uid baked into the image. When those differ, the directory's `0755` permissions deny the agent write access, so the agent cannot create files in your workspace.

To fix this, the launcher routes the container through the `aileron-remap-agent-uid` entrypoint on Linux + Docker. Started as root, it reads the workspace owner's uid/gid, remaps the in-container `agent` user/group to match, then drops to the agent user before the agent runs. The remap composes with the proxy contract: the launcher chains `aileron-remap-agent-uid` ahead of `aileron-run-with-proxy-ca` so the order is remap uid, then install the CA, then drop to the agent user.

This is scoped to Linux + Docker. On macOS and Windows, Docker Desktop's file-sharing layer translates uids at the boundary, so the mismatch never surfaces and the remap is skipped.

A third operational requirement therefore applies to BYO images that run as a non-root user on Linux:

3. The `aileron-remap-agent-uid` helper must be on `PATH` and the image must let it run as root at startup before dropping to the agent user. The helper needs `usermod`/`groupmod` (the `shadow` package on Alpine, `passwd`/`shadow-utils` on Debian/RHEL) and a `stat` that supports `-c` (GNU coreutils). The canonical implementation ships with the sandbox-base image.

> This addresses a different failure than SELinux relabeling: the `:z` relabel handles SELinux MAC denials on enforcing hosts, while the uid remap handles the DAC permission mismatch that occurs on every Linux + Docker host regardless of SELinux. Both can apply on the same host.

## Current Limits

The support matrix covers image contents only. It does not add live discovery refresh.