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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,14 @@ Chinese-speaking macOS users can use the
## Containers and local models

Pre-built `linux/amd64` and `linux/arm64` images are published to the GitHub
Container Registry, so no local Python environment is needed:
Container Registry, so no local Python environment is needed. That package is
private, so `docker login ghcr.io` (with an account authorized for it) is
required — otherwise build the checkout:

```bash
cp .env.example .env
docker compose run --rm agent
docker compose -f compose.yaml -f compose.dev.yaml build
docker compose -f compose.yaml -f compose.dev.yaml run --rm agent
```

- [Run FrontierAgent in Docker](docs/install/docker.md) — Compose, image
Expand Down
2 changes: 1 addition & 1 deletion docs/install/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ without keeping a checkout around, use
| macOS laptop or desktop | native, optionally Docker | hosted/remote endpoint | [macOS](macos.md) |
| macOS or Linux, the CLI as a globally installed tool | `uv tool install`, native or Docker | hosted/remote endpoint | [Global install](global-install.md) |
| Linux laptop, server, or CI without a local model | `scripts/run-linux.sh` (native, bubblewrap, or Docker) | hosted/remote endpoint | [Linux](linux.md) |
| Any host with Docker and no local Python environment | published agent container | hosted/remote endpoint | [Docker and Compose](docker.md) |
| Any host with Docker and no local Python environment | agent container built from this checkout (or the private published image) | hosted/remote endpoint | [Docker and Compose](docker.md) |
| Linux bare metal or VM with an NVIDIA GPU and Docker daemon | native or agent container | SGLang container | [Linux NVIDIA + Docker](linux-nvidia.md) |
| RunPod-style service that accepts your image at instance creation | inside the provider container | prebuilt FrontierAgent GPU image | [GPU cloud images](gpu-platforms.md) |
| Existing x86_64 Linux GPU environment without nested Docker | `scripts/run-linux-gpu.sh` | isolated native SGLang process | [Linux NVIDIA native](linux-nvidia-native.md) |
Expand Down
89 changes: 76 additions & 13 deletions docs/install/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,20 @@

FrontierAgent publishes pre-built `linux/amd64` and `linux/arm64` images to the
GitHub Container Registry. Using them requires no local Python environment and
no system dependencies beyond Docker itself. The default `compose.yaml` pulls
that published image; it does not build the repository locally.
no system dependencies beyond Docker itself. The default `compose.yaml` uses
that published image.

That package is **private**, so an anonymous pull fails with `unauthorized`.
You need one of:

- a GitHub account or token already authorized for the package, in which case
run `docker login ghcr.io` first; or
- a local build of this checkout, which is what the commands below do and
needs no registry access.

Because `compose.yaml` sets `pull_policy: always`, the build path keeps the
`compose.dev.yaml` override on every command — its `pull_policy: build` keeps
the local image in use instead of retrying the registry.

This page covers the CPU agent container. For a **local NVIDIA model server**,
the GPU belongs to a separate SGLang container or process — use
Expand All @@ -20,14 +32,27 @@ git clone https://github.com/ApodexAI/FrontierAgent.git
cd FrontierAgent
cp .env.example .env

# Interactive CLI
# With registry access, the published image needs no build:
docker compose run --rm agent
```

Without it, build from this checkout and keep the override on every command:

```bash
git clone https://github.com/ApodexAI/FrontierAgent.git
cd FrontierAgent
cp .env.example .env
docker compose -f compose.yaml -f compose.dev.yaml build

# Interactive CLI
docker compose -f compose.yaml -f compose.dev.yaml run --rm agent

# One-shot agent command
docker compose run --rm agent -p "explain pyproject.toml"
docker compose -f compose.yaml -f compose.dev.yaml run --rm agent \
-p "explain pyproject.toml"

# Default benchmark evaluation (BrowseComp, one task)
docker compose run --rm eval
docker compose -f compose.yaml -f compose.dev.yaml run --rm eval
```

Compose writes session records and deliverables to `.apodex/runs/<session-id>/`.
Expand All @@ -47,6 +72,10 @@ The convenience helper wraps the same thing:
./docker/run.sh eval --limit 5
```

`run.sh` uses `compose.yaml` on its own, so it needs registry access to the
private package. Keep the `compose.dev.yaml` override instead when building
locally.

## Pin a release or another image

Set `FRONTIER_AGENT_IMAGE` before running Compose:
Expand All @@ -56,13 +85,35 @@ FRONTIER_AGENT_IMAGE=ghcr.io/apodexai/frontieragent:latest \
docker compose run --rm agent -p "explain pyproject.toml"
```

Any image name works here, including one you built and tagged yourself, or one
mirrored to a registry you can reach.

A tag that exists only on this machine is the exception. `compose.yaml` sets
`pull_policy: always`, so Compose would still try to resolve it from a registry.
Pass `--pull never` so it uses the local image:

```bash
FRONTIER_AGENT_IMAGE=frontier-agent:local \
docker compose run --pull never --rm agent
```

## Direct `docker run`

Compose is the supported path; this is the equivalent for environments that
cannot use it. The environment variables and mounts are not optional — they are
what tells the runtime it is inside a container and where the three sandbox
roots live.

The command below runs `frontier-agent:local`, which you build from this
checkout first, so it needs no registry access:

```bash
docker build -t frontier-agent:local .
```

To use the private published image instead, replace that tag with
`ghcr.io/apodexai/frontieragent:latest` and `docker login ghcr.io` first.

```bash
docker run --rm -it \
--env-file .env \
Expand All @@ -84,7 +135,7 @@ docker run --rm -it \
-v frontier-agent-state:/root/.apodex \
-v frontier-agent-config:/root/.config/apodex \
-w /workspace \
ghcr.io/apodexai/frontieragent:latest \
frontier-agent:local \
-p "explain main workflow"
```

Expand All @@ -94,26 +145,33 @@ For a terminal deployment accessed over SSH:

1. Provision an EC2 or ECS Linux instance with Docker and the Compose plugin.
2. Clone this repository and create `.env` from `.env.example`.
3. Pull and launch the pre-built container:
3. Launch the container:

```bash
git clone https://github.com/ApodexAI/FrontierAgent.git
cd FrontierAgent
cp .env.example .env
# Edit .env, then:
# Edit .env, then — one of:

# With registry access, use the published image:
docker login ghcr.io
docker compose pull agent
docker compose run --rm agent

# Or build this checkout on the instance, no registry access needed:
docker compose -f compose.yaml -f compose.dev.yaml build
docker compose -f compose.yaml -f compose.dev.yaml run --rm agent
```

The container itself is disposable; Compose persists sessions, configuration,
attachments, and deliverables in volumes or the checked-out workspace. Pull the
image again to upgrade. This is an interactive SSH/TUI deployment, not a
long-running HTTP service.
attachments, and deliverables in volumes or the checked-out workspace. Rebuild
(or `docker compose pull agent`) to upgrade. This is an interactive SSH/TUI
deployment, not a long-running HTTP service.

## Build from the current checkout

To run your own changes instead of the published image, add the development
override:
The development override builds this checkout instead of using the published
image, which is the only path that needs no registry access:

```bash
cp .env.example .env
Expand All @@ -123,6 +181,11 @@ docker compose -f compose.yaml -f compose.dev.yaml run --rm eval \
--benchmark browsecomp --limit 1 --out /app/results/smoke
```

Once the image is built, keep the `compose.dev.yaml` override on the run
command too. `compose.yaml` sets `pull_policy: always`, so it reaches for the
private registry on every start and fails for anyone without access — the
override's `pull_policy: build` keeps the local image in use.

`docker build -t apodex:local .` builds the same image under the name that the
macOS `--docker` path expects. See [Contributing](../../CONTRIBUTING.md) for the
rest of the development loop.
Expand Down
141 changes: 141 additions & 0 deletions tests/test_container_image_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""The documented container quick start must work without registry credentials.

`ghcr.io/apodexai/frontieragent` is private by org policy — see the comment in
`.github/workflows/docker-publish.yml` and the note in
`docs/install/global-install.md`. An anonymous pull of it fails, so the
user-facing quick start cannot promise a build-free `docker compose run`.

`README.md`, `docs/install/docker.md` and the chooser table in
`docs/install/README.md` all did promise exactly that, which sent anyone
outside the org to an `unauthorized` error on their first command.

These assertions fail on the pre-fix tree and pass after it.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

_REPO_ROOT = Path(__file__).resolve().parents[1]

# Every file that walks a reader through starting the container.
_QUICKSTART_DOCS = (
"README.md",
"docs/install/docker.md",
"docs/install/README.md",
)

# Claims that cannot hold while the published image is private.
_UNREACHABLE_CLAIMS = (
"no local build needed",
"does not build the repository locally",
"Pull and launch the pre-built container",
)


@pytest.mark.parametrize("rel_path", _QUICKSTART_DOCS)
def test_quickstart_docs_say_the_image_is_private(rel_path: str) -> None:
"""Each quick start has to acknowledge the private package."""
text = (_REPO_ROOT / rel_path).read_text(encoding="utf-8")
assert re.search(r"private", text, re.IGNORECASE), (
f"{rel_path} documents a container quick start but never says the "
"ghcr.io/apodexai/frontieragent package is private, so a reader "
"outside the org hits `unauthorized` on the first command"
)


@pytest.mark.parametrize("rel_path", _QUICKSTART_DOCS)
def test_quickstart_docs_drop_unreachable_claims(rel_path: str) -> None:
"""The pre-fix wording promised a pull that cannot succeed anonymously."""
lowered = (_REPO_ROOT / rel_path).read_text(encoding="utf-8").lower()
for claim in _UNREACHABLE_CLAIMS:
assert claim not in lowered, (
f"{rel_path} still claims {claim!r}, which is false while the "
"published image is private"
)


def _run_commands(text: str) -> list[str]:
"""Every `docker compose ... run ...` invocation, joined across line wraps.

Two things make a naive per-line scan miss these: the compose file flags
sit between `compose` and `run`, and long invocations wrap onto the next
line with a trailing backslash.
"""
joined = text.replace("\\\n", " ")
return [
line.strip()
for line in joined.splitlines()
if re.match(r"\s*docker compose\b.*\brun\b", line)
]


def test_docker_quickstart_offers_a_path_that_needs_no_registry() -> None:
"""The local-build escape hatch has to be spelled out.

`compose.yaml` sets `pull_policy: always`, so the documented build path is
only usable if the commands keep the `compose.dev.yaml` override — its
`pull_policy: build` is what keeps the local image instead of retrying the
private registry.
"""
text = (_REPO_ROOT / "docs/install/docker.md").read_text(encoding="utf-8")
assert "compose.dev.yaml" in text, (
"docs/install/docker.md offers no local-build path, but the published "
"image cannot be pulled anonymously"
)

build_blocks = re.findall(r"```bash\n(.*?)```", text, re.DOTALL)
offenders = [
command
for block in build_blocks
if "compose.dev.yaml build" in block
for command in _run_commands(block)
if "compose.dev.yaml" not in command and "docker login" not in block
]
assert not offenders, (
"a block that builds locally then runs without compose.dev.yaml, so "
"compose.yaml's pull_policy: always re-fetches the private image: " + "; ".join(offenders)
)


def test_documented_local_tag_opts_out_of_the_registry() -> None:
"""A locally built tag has to say `--pull never` to be usable.

`compose.yaml` sets `pull_policy: always`, so `FRONTIER_AGENT_IMAGE` pointed at a
tag that exists only on this machine still makes Compose resolve it against a
registry and fail.
"""
text = (_REPO_ROOT / "docs/install/docker.md").read_text(encoding="utf-8")
# The local-tag example is prefixed with FRONTIER_AGENT_IMAGE=..., which `_run_commands`
# only matches when `docker compose` starts the line.
joined = text.replace("\\\n", " ")
local_runs = [
line.strip()
for line in joined.splitlines()
if re.match(r"\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)?docker compose\b.*\brun\b", line)
and "frontier-agent:local" in line
]
assert local_runs, (
"docs/install/docker.md documents a locally built tag but never runs it "
"through Compose, so there is no local-only example to check"
)
offenders = [command for command in local_runs if "--pull never" not in command]
assert not offenders, (
"a locally built tag without --pull never is re-fetched from the registry "
"by compose.yaml's pull_policy: always: " + "; ".join(offenders)
)


def test_readme_quickstart_offers_a_path_that_needs_no_registry() -> None:
"""The README snippet is the most-read entry point for the container path."""
text = (_REPO_ROOT / "README.md").read_text(encoding="utf-8")
section = text.split("## Containers and local models", 1)[1].split("\n## ", 1)[0]
runs = _run_commands(section)
assert runs, "README no longer shows a container run command"
assert all("compose.dev.yaml" in command for command in runs), (
"README runs the container without the compose.dev.yaml override, so "
"the private image is pulled: " + "; ".join(runs)
)
Loading