diff --git a/README.md b/README.md index e2982f6..2097d40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/install/README.md b/docs/install/README.md index 5974c0a..3b790eb 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -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) | diff --git a/docs/install/docker.md b/docs/install/docker.md index 75c220d..e3f8640 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -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 @@ -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//`. @@ -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: @@ -56,6 +85,18 @@ 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 @@ -63,6 +104,16 @@ cannot use it. The environment variables and mounts are not optional — they ar 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 \ @@ -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" ``` @@ -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 @@ -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. diff --git a/tests/test_container_image_docs.py b/tests/test_container_image_docs.py new file mode 100644 index 0000000..ce2e122 --- /dev/null +++ b/tests/test_container_image_docs.py @@ -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) + )