Skip to content

Latest commit

 

History

History
115 lines (90 loc) · 6.75 KB

File metadata and controls

115 lines (90 loc) · 6.75 KB

Container image layers — how they work and how to add your own

Every task runs its agent inside a container. That container's image is not one fixed blob — it is composed from three layers stacked in order: base → workflow → repo. The base stays small and general; a workflow adds what its skills need; a repo adds its own toolchain. This doc explains the model and gives copy-pasteable recipes for adding a workflow layer and a repo layer.

The three tiers

Each tier is a Dockerfile fragment, and the effective image is those fragments FROM-chained on top of one another:

  • Base — minimal and general: python, git, gh, bash, curl, tmux; the claude CLI the agent execs; the panopticon package; and the entrypoint (uid/gid remap → privilege drop). Built from src/panopticon/docker/Dockerfile and tagged panopticon-base (make build). It is deliberately not opinionated about any single workflow or repo; gh is shared because credentials and GitHub-bound work are workflow-independent.
  • Workflow — a fragment a Workflow subclass contributes via its image_layer() method, adding workflow-specific additions its skills need. It is empty when the base already supplies every required tool, as it is for the GitHub-forge workflows.
  • Repo — a fragment referenced by a repo's image_layer_file, adding repo-specific setup: build toolchain, dependencies, pre-launch configuration (e.g. uv, make sync).

The repo layer sits on top because it is the most specific and most frequently changing.

How composition works

When the session service (the runner) spawns a task, it composes the image before docker run (sessionservice/spawner.py _compose_image, sessionservice/images.py):

  1. It fetches the two layers over REST from the task service — GET /workflows/{name}/image-layer and GET /repos/{id}/image-layer — and drops any that are empty.
  2. If none remain, the task runs on panopticon-base directly (no build).
  3. Otherwise it writes a Dockerfile that starts FROM panopticon-base and appends the fragments, then docker builds it, tagged panopticon-<workflow>-<repo_id>.

Every spawn first compares the base image's content/version fingerprint with the packaged Dockerfile, entrypoint, and Panopticon release, rebuilding the static panopticon-base tag when it is missing or stale. Docker layer-caches that rebuild and the composed image, so unchanged inputs are cheap. Change a layer and the next spawn rebuilds only the affected steps.

Each published Panopticon release also produces a multi-platform copy of that base image at ghcr.io/unsupervisedcom/panopticon-next:<version> and updates latest for a non-prerelease. The published image is built from the same release wheel and carries the same base fingerprint, so it is an immutable distributable form of the locally built panopticon-base. The runner continues to manage its local panopticon-base tag and does not implicitly pull from the registry.

Workflows whose runner_type is "shell" (e.g. setup-repo) run on the host with no container, so they have no image and layers are ignored.

Adding a workflow layer

Override image_layer() on your Workflow subclass to return a Dockerfile fragment string. The default (core/workflow.py) returns "" — no layer. Everything the string contains is baked into the image, so use it for system-level installs your skills depend on. For example, a workflow that renders diagrams could add Graphviz:

def image_layer(self) -> str:
    return "RUN apt-get update && apt-get install --yes --no-install-recommends graphviz"

Notes:

  • This is distinct from tools() and skills(). image_layer() puts a binary in the image; tools() just names an expected tool so the agent reaches for it, and skills() declares agent-driven procedures. A named tool may already be installed in the base image.
  • Spell external-program flags in full (apt-get install --yes, --no-install-recommends) — they are self-documenting and grep-able.
  • Keep it small. The base stays general on purpose; only add what the workflow's own skills need.

Adding a repo layer

A repo layer is operator-authored and referenced by name, so you don't touch code:

  1. Write the fragment file under the layers directory — ~/.config/panopticon/layers/ ($PANOPTICON_CONFIG/layers; core/dirs.py LAYERS_DIR). For example ~/.config/panopticon/layers/myrepo.dockerfile:

    # Layered on top of base → workflow, so shared tools such as gh are already present.
    RUN curl --location --silent https://astral.sh/uv/install.sh | sh

    It's a plain Dockerfile fragment — RUN/ENV/COPY lines, no FROM (the composer supplies that). It builds on top of the workflow layer as the unprivileged panopticon user's environment.

  2. Point the repo at it in the dashboard's repo form: set image_layer_file to the file's name (myrepo.dockerfile), not a path. The form offers a picker over the files in your layers dir, with a custom-path entry that normalizes to a name.

The value is a reference, not inline content — see the image_layer_file field in repos.md. The task service resolves it against its layers dir and serves the content over REST; the runner composes it onto base → workflow. Names that escape the layers root (.., absolute paths) are rejected; nested names (team/myrepo.dockerfile) are allowed. An empty or unset image_layer_file means no repo layer.

Notes

  • The layer file lives on the host that spawns the container. Like env_file (see docs/auth.md), image_layer_file is a bare name resolved against each runner host's own layers dir. With a single host that's your machine; with remote runners (M5), place a same-named file under each runner host's layers dir.
  • Rebuilds & cleanup. Editing a layer rebuilds the affected steps on the next spawn (Docker caches the rest). A packaged base-input or version change also refreshes a stale base automatically. make clean removes panopticon-base and every composed panopticon-* image; make build rebuilds the base immediately with the current development wheel. Changes to package code outside the fingerprinted Dockerfile/entrypoint/version still require make build.
  • Elevated privileges are a capability, not a layer. Docker-in-Docker is opt-in via the repo's capabilities map (docker_in_docker), which makes the runner spawn --privileged and the entrypoint start a nested daemon — it is not something you add through a Dockerfile fragment.
  • Secrets never go in a layer. Image layers are cached and shared; keep API keys and tokens in the repo's env_file (docs/auth.md), injected at launch, not baked into the image.