Skip to content

Build

build

Builds L0 (base dev) and L1 (agent CLI) container images via podman.

Owns the L0 (base dev) and L1 (agent CLI) Dockerfile templates, resource staging, image naming, and podman build invocation.

Image layer architecture::

L0  (base)   — Distro + dev tools + init script + dev user
L1  (agent)  — All AI agent CLIs, shell environment, ACP wrappers
               L1 is self-sufficient for standalone use — all user
               config (repo URL, SSH, branch, gate) is runtime.
─── boundary: above owned by terok-executor, below by terok ───
L2  (project)— Optional: user Dockerfile snippet (custom packages)
               Only built when project has docker snippet config.

terok-executor run claude . launches directly on the L1 image — no L2 build needed. terok adds L2 only for project-specific image customisation.

Usage as a library::

from terok_executor import build_base_images

images = build_base_images("fedora:44")
# images.l0 = "terok-l0:fedora-44"
# images.l1 = "terok-l1-cli:fedora-44"

The L0/L1 templates select between Debian/Ubuntu (apt) and Fedora-like (dnf) package managers via a family Jinja2 variable resolved by detect_family from the base image name (or an explicit override).

L1 is roster-driven: each agent's install steps live in its YAML file (install.run_as_root / install.run_as_dev), and the L1 template loops over the resolved selection. Build emits an OCI label ai.terok.agents=<csv>, an in-container manifest /etc/terok/installed.env, and pre-rendered hilfe help fragments — all derived from the same selection.

DEFAULT_BASE_IMAGE = 'fedora:44' module-attribute

Default base OS image when none is specified.

AGENTS_LABEL = 'ai.terok.agents' module-attribute

OCI label naming the roster entries baked into an L1 image.

INSTALLED_ENV_PATH = '/etc/terok/installed.env' module-attribute

In-container env file that scripts source to learn what's installed.

BuildError

Bases: RuntimeError

Raised when base-image construction cannot complete.

The CLI maps this to a user-facing error message; library callers can catch it without being terminated by SystemExit.

ImageSet(l0, l1, l1_sidecar=None) dataclass

L0 + L1 image tags produced by a build.

l0 instance-attribute

L0 base dev image tag (e.g. terok-l0:fedora-44).

l1 instance-attribute

L1 agent CLI image tag (e.g. terok-l1-cli:fedora-44).

l1_sidecar = None class-attribute instance-attribute

L1 sidecar image tag, if built (e.g. terok-l1-sidecar:fedora-44).

ImageBuilder(base_image=DEFAULT_BASE_IMAGE, family=None) dataclass

Build pipeline for terok agent container images.

Holds the (base_image, family) the L0/L1/L2 build stack is anchored on. Build operations are instance-bound; pure family detection, image introspection, and resource staging stay as static methods — they don't depend on the builder's state.

Two scopes of operations:

  • Instance methods — apply self.base_image and self.family to a podman build (build_base, build_sidecar, ensure_default_l1), tag computations (l0_tag, l1_tag, l1_sidecar_tag), and Dockerfile rendering (render_l0, render_l1, render_l1_sidecar).
  • Static methods — pure helpers that operate on arbitrary inputs: detect_family, image_agents, stage_scripts, stage_tmux_config, stage_toad_agents.

base_image = DEFAULT_BASE_IMAGE class-attribute instance-attribute

Base OS image the L0 layer FROMs (e.g. fedora:44).

family = None class-attribute instance-attribute

Package family override ("deb" / "rpm") — auto-detected when None.

l0_tag property

L0 image tag for self.base_image.

l1_sidecar_tag property

L1 sidecar image tag for self.base_image.

build_base(*, agents='all', rebuild=False, full_rebuild=False, build_dir=None, tag_as_default=False)

Build L0 + L1 images for agents; returns the resulting tag pair.

See module-level build_base_images for the parameter contract.

Source code in src/terok_executor/container/build.py
def build_base(
    self,
    *,
    agents: str | tuple[str, ...] = "all",
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
    tag_as_default: bool = False,
) -> ImageSet:
    """Build L0 + L1 images for *agents*; returns the resulting tag pair.

    See module-level ``build_base_images`` for the parameter contract.
    """
    return build_base_images(
        self.base_image,
        family=self.family,
        agents=agents,
        rebuild=rebuild,
        full_rebuild=full_rebuild,
        build_dir=build_dir,
        tag_as_default=tag_as_default,
    )

build_sidecar(*, tool_name='coderabbit', rebuild=False, full_rebuild=False, build_dir=None)

Build the L1 sidecar image variant for tool_name; returns the tag.

Source code in src/terok_executor/container/build.py
def build_sidecar(
    self,
    *,
    tool_name: str = "coderabbit",
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
) -> str:
    """Build the L1 sidecar image variant for *tool_name*; returns the tag."""
    return build_sidecar_image(
        self.base_image,
        family=self.family,
        tool_name=tool_name,
        rebuild=rebuild,
        full_rebuild=full_rebuild,
        build_dir=build_dir,
    )

ensure_default_l1(agents='all')

Return the default-alias L1 tag, building the default L1 if absent.

Source code in src/terok_executor/container/build.py
def ensure_default_l1(self, agents: str | tuple[str, ...] = "all") -> str:
    """Return the default-alias L1 tag, building the default L1 if absent."""
    return ensure_default_l1(self.base_image, family=self.family, agents=agents)

l1_tag(agents=None)

L1 image tag for agents under self.base_image (alias when None).

Source code in src/terok_executor/container/build.py
def l1_tag(self, agents: tuple[str, ...] | None = None) -> str:
    """L1 image tag for *agents* under ``self.base_image`` (alias when ``None``)."""
    return l1_image_tag(self.base_image, agents)

render_l0()

Render the L0 Dockerfile for this base.

Instance-bound because L0 is anchored on self.base_image.

Source code in src/terok_executor/container/build.py
def render_l0(self) -> str:
    """Render the L0 Dockerfile for this base.

    Instance-bound because L0 is anchored on ``self.base_image``.
    """
    return render_l0(self.base_image, family=self._family)

render_l1(l0_tag, *, family, agents='all', cache_bust='0') staticmethod

Render the L1 CLI Dockerfile for agents on top of l0_tag.

Static because the L1 stage depends only on the L0 tag and the package family — it never touches self.base_image. Callers that already have an ImageBuilder can pass builder._family to thread the resolved family through.

Source code in src/terok_executor/container/build.py
@staticmethod
def render_l1(
    l0_tag: str,
    *,
    family: str,
    agents: tuple[str, ...] | str = "all",
    cache_bust: str = "0",
) -> str:
    """Render the L1 CLI Dockerfile for *agents* on top of *l0_tag*.

    Static because the L1 stage depends only on the L0 tag and the
    package family — it never touches ``self.base_image``.  Callers
    that already have an [`ImageBuilder`][terok_executor.container.build.ImageBuilder]
    can pass ``builder._family`` to thread the resolved family through.
    """
    return render_l1(l0_tag, family=family, agents=agents, cache_bust=cache_bust)

render_l1_sidecar(l0_tag, *, family, tool_name='coderabbit', cache_bust='0') staticmethod

Render the L1 sidecar Dockerfile for tool_name on top of l0_tag.

Static for the same reason as render_l1.

Source code in src/terok_executor/container/build.py
@staticmethod
def render_l1_sidecar(
    l0_tag: str,
    *,
    family: str,
    tool_name: str = "coderabbit",
    cache_bust: str = "0",
) -> str:
    """Render the L1 sidecar Dockerfile for *tool_name* on top of *l0_tag*.

    Static for the same reason as [`render_l1`][terok_executor.container.build.ImageBuilder.render_l1].
    """
    return render_l1_sidecar(l0_tag, family=family, tool_name=tool_name, cache_bust=cache_bust)

detect_family(base_image, override=None) staticmethod

Resolve the package family ("deb" / "rpm") for base_image.

Source code in src/terok_executor/container/build.py
@staticmethod
def detect_family(base_image: str, override: str | None = None) -> str:
    """Resolve the package family (``"deb"`` / ``"rpm"``) for *base_image*."""
    return detect_family(base_image, override)

image_agents(image) staticmethod

Return roster agent names from an L1 image's ai.terok.agents label.

Source code in src/terok_executor/container/build.py
@staticmethod
def image_agents(image: str) -> set[str]:
    """Return roster agent names from an L1 image's ``ai.terok.agents`` label."""
    return image_agents(image)

stage_scripts(dest) staticmethod

Stage shell helper scripts (hilfe, terok-*) into dest.

Source code in src/terok_executor/container/build.py
@staticmethod
def stage_scripts(dest: Path) -> None:
    """Stage shell helper scripts (``hilfe``, ``terok-*``) into *dest*."""
    stage_scripts(dest)

stage_tmux_config(dest) staticmethod

Stage the tmux config into dest.

Source code in src/terok_executor/container/build.py
@staticmethod
def stage_tmux_config(dest: Path) -> None:
    """Stage the tmux config into *dest*."""
    stage_tmux_config(dest)

stage_toad_agents(dest) staticmethod

Stage toad agent metadata into dest.

Source code in src/terok_executor/container/build.py
@staticmethod
def stage_toad_agents(dest: Path) -> None:
    """Stage toad agent metadata into *dest*."""
    stage_toad_agents(dest)

detect_family(base_image, override=None)

Resolve the package family (deb or rpm) for base_image.

override — when set, must be "deb" or "rpm" and wins over detection (used to support unknown bases via project config).

Detection matches a small allowlist of known image prefixes (Ubuntu/Debian, Fedora, the official Podman container, NVIDIA CUDA/HPC SDK). NVIDIA images are inspected at the tag level so UBI variants (e.g. …:13.0.0-devel-ubi9) resolve to rpm while Ubuntu variants resolve to deb. Unknown images raise BuildError with a hint to set family: explicitly.

Source code in src/terok_executor/container/build.py
def detect_family(base_image: str, override: str | None = None) -> str:
    """Resolve the package family (``deb`` or ``rpm``) for *base_image*.

    *override* — when set, must be ``"deb"`` or ``"rpm"`` and wins over
    detection (used to support unknown bases via project config).

    Detection matches a small allowlist of known image prefixes
    (Ubuntu/Debian, Fedora, the official Podman container, NVIDIA CUDA/HPC
    SDK).  NVIDIA images are inspected at the tag level so UBI variants
    (e.g. ``…:13.0.0-devel-ubi9``) resolve to ``rpm`` while Ubuntu
    variants resolve to ``deb``.  Unknown images raise [`BuildError`][terok_executor.container.build.BuildError]
    with a hint to set ``family:`` explicitly.
    """
    if override is not None:
        if override not in {"deb", "rpm"}:
            raise BuildError(f"family must be 'deb' or 'rpm', got {override!r}")
        return override
    name, tag = _split_image_ref(_normalize_base_image(base_image))
    name_lc = name.lower()
    for prefix, fam in _KNOWN_FAMILIES:
        if name_lc == prefix or name_lc.startswith(prefix + "/"):
            return fam(tag) if callable(fam) else fam
    raise BuildError(
        f"Cannot infer package family for base image {base_image!r}. "
        "Set `family: deb` or `family: rpm` under image: in project.yml."
    )

build_project_image(*, dockerfile, context_dir, target_tag, extra_tags=(), build_args=None, labels=None, no_cache=False, pull_always=False)

Build an OCI image from a pre-rendered Dockerfile.

The thin podman build invoker that the three opinionated factories in this module (build_base_images, build_sidecar_image, and terok's project/L2 build) share. Callers own Dockerfile rendering, tag naming, label computation, and build-context staging — this function only assembles flags and shells out.

Parameters:

Name Type Description Default
dockerfile Path

Path to the pre-rendered Dockerfile (-f).

required
context_dir Path

Build context directory (final positional argument).

required
target_tag str

Primary image tag (-t).

required
extra_tags tuple[str, ...]

Additional tags applied to the same build (each becomes another -t on the command line — podman builds once and tags the result multiple times).

()
build_args dict[str, str] | None

--build-arg KEY=VALUE pairs.

None
labels dict[str, str] | None

--label KEY=VALUE pairs recorded in the OCI config.

None
no_cache bool

Force full rebuild.

False
pull_always bool

Pull the base image even if a local copy exists.

False

Raises:

Type Description
BuildError

When podman is not on PATH or the build exits non-zero.

Source code in src/terok_executor/container/build.py
def build_project_image(
    *,
    dockerfile: Path,
    context_dir: Path,
    target_tag: str,
    extra_tags: tuple[str, ...] = (),
    build_args: dict[str, str] | None = None,
    labels: dict[str, str] | None = None,
    no_cache: bool = False,
    pull_always: bool = False,
) -> None:
    """Build an OCI image from a pre-rendered Dockerfile.

    The thin ``podman build`` invoker that the three opinionated factories
    in this module ([`build_base_images`][terok_executor.container.build.build_base_images], [`build_sidecar_image`][terok_executor.container.build.build_sidecar_image],
    and terok's project/L2 build) share.  Callers own Dockerfile
    rendering, tag naming, label computation, and build-context staging —
    this function only assembles flags and shells out.

    Args:
        dockerfile: Path to the pre-rendered Dockerfile (``-f``).
        context_dir: Build context directory (final positional argument).
        target_tag: Primary image tag (``-t``).
        extra_tags: Additional tags applied to the same build (each becomes
            another ``-t`` on the command line — podman builds once and
            tags the result multiple times).
        build_args: ``--build-arg KEY=VALUE`` pairs.
        labels: ``--label KEY=VALUE`` pairs recorded in the OCI config.
        no_cache: Force full rebuild.
        pull_always: Pull the base image even if a local copy exists.

    Raises:
        BuildError: When podman is not on PATH or the build exits non-zero.
    """
    cmd = ["podman", "build", "-f", str(dockerfile)]
    for key, value in (build_args or {}).items():
        cmd += ["--build-arg", f"{key}={value}"]
    for key, value in (labels or {}).items():
        cmd += ["--label", f"{key}={value}"]
    cmd += ["-t", target_tag]
    for tag in extra_tags:
        cmd += ["-t", tag]
    if no_cache:
        cmd.append("--no-cache")
    if pull_always:
        cmd.append("--pull=always")
    cmd.append(str(context_dir))

    print("$", shlex.join(cmd))
    try:
        subprocess.run(cmd, check=True)
    except FileNotFoundError as exc:
        raise BuildError("podman not found; please install podman") from exc
    except subprocess.CalledProcessError as exc:
        raise BuildError(f"Image build failed: {exc}") from exc

build_base_images(base_image=DEFAULT_BASE_IMAGE, *, family=None, agents='all', rebuild=False, full_rebuild=False, build_dir=None, tag_as_default=False)

Build L0 + L1 container images and return their tags.

Skips building if images already exist locally (unless rebuild or full_rebuild is set). Uses a temporary directory for the build context by default; pass build_dir to use a specific (empty or non-existent) directory instead.

Parameters:

Name Type Description Default
base_image str

Base OS image (e.g. fedora:44, nvidia/cuda:...).

DEFAULT_BASE_IMAGE
family str | None

Override for the package family ("deb" or "rpm"). None means detect from base_image via detect_family.

None
agents str | tuple[str, ...]

Roster entries to install, as the literal string "all" (every entry) or a tuple of names (transitively expanded by depends_on). Same selection drives the OCI label, the L1 tag suffix, the in-container manifest, and the help fragments.

'all'
rebuild bool

Force rebuild with cache bust (refreshes agent installs).

False
full_rebuild bool

Force rebuild with --no-cache --pull=always.

False
build_dir Path | None

Build context directory (must be empty or absent).

None
tag_as_default bool

When True, additionally tag the L1 with the unsuffixed default-alias l1_image_tag(base_image). Set by ensure_default_l1 when this build represents the user's configured default agent selection. Project / per-agent / partial builds leave it False so the alias keeps pointing at the user's default L1, not at whatever was last built.

False

Returns:

Type Description
ImageSet

ImageSet with the L0 and L1 image tags.

Raises:

Type Description
BuildError

If podman is missing, the family cannot be resolved, or a build step fails.

ValueError

If build_dir is a file or a non-empty directory, or if agents contains unknown roster entries.

Source code in src/terok_executor/container/build.py
def build_base_images(
    base_image: str = DEFAULT_BASE_IMAGE,
    *,
    family: str | None = None,
    agents: str | tuple[str, ...] = "all",
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
    tag_as_default: bool = False,
) -> ImageSet:
    """Build L0 + L1 container images and return their tags.

    Skips building if images already exist locally (unless *rebuild* or
    *full_rebuild* is set).  Uses a temporary directory for the build
    context by default; pass *build_dir* to use a specific (empty or
    non-existent) directory instead.

    Args:
        base_image: Base OS image (e.g. ``fedora:44``, ``nvidia/cuda:...``).
        family: Override for the package family (``"deb"`` or ``"rpm"``).
            ``None`` means detect from *base_image* via [`detect_family`][terok_executor.container.build.detect_family].
        agents: Roster entries to install, as the literal string ``"all"``
            (every entry) or a tuple of names (transitively expanded by
            ``depends_on``).  Same selection drives the OCI label, the L1
            tag suffix, the in-container manifest, and the help fragments.
        rebuild: Force rebuild with cache bust (refreshes agent installs).
        full_rebuild: Force rebuild with ``--no-cache --pull=always``.
        build_dir: Build context directory (must be empty or absent).
        tag_as_default: When ``True``, additionally tag the L1 with the
            unsuffixed default-alias [`l1_image_tag(base_image)`][terok_executor.container.build.l1_image_tag].
            Set by [`ensure_default_l1`][terok_executor.container.build.ensure_default_l1]
            when this build represents the user's *configured* default
            agent selection.  Project / per-agent / partial builds leave
            it ``False`` so the alias keeps pointing at the user's
            default L1, not at whatever was last built.

    Returns:
        [`ImageSet`][terok_executor.container.build.ImageSet] with the L0 and L1 image tags.

    Raises:
        BuildError: If podman is missing, the family cannot be resolved,
            or a build step fails.
        ValueError: If *build_dir* is a file or a non-empty directory,
            or if *agents* contains unknown roster entries.
    """
    from terok_executor.roster import AgentRoster

    _validate_build_dir(build_dir)
    _check_podman()

    base_image = _normalize_base_image(base_image)
    selected = AgentRoster.shared().resolve_selection(agents)

    l0_tag = l0_image_tag(base_image)
    l1_tag = l1_image_tag(base_image, selected)
    l1_alias = l1_image_tag(base_image)
    extra_tags: tuple[str, ...] = (l1_alias,) if tag_as_default else ()

    # Skip if both images exist and no forced rebuild — done before
    # detect_family() so cached images for unknown bases (built earlier
    # with explicit family) can still be reused without supplying it again.
    if not rebuild and not full_rebuild:
        if _image_exists(l0_tag) and _image_exists(l1_tag):
            return ImageSet(l0=l0_tag, l1=l1_tag)

    fam = detect_family(base_image, override=family)

    # Prepare build context in a safe directory
    import tempfile

    own_tmp = build_dir is None
    context = build_dir or Path(tempfile.mkdtemp(prefix="terok-executor-build-"))

    try:
        try:
            prepare_build_context(context)
            stage_help_fragments(context / "help.d", selected)

            # Single timestamp for both render and build-arg consistency
            cache_bust = str(int(time.time()))

            # Render and write Dockerfiles into the build context
            (context / "L0.Dockerfile").write_text(render_l0(base_image, family=fam))
            (context / "L1.cli.Dockerfile").write_text(
                render_l1(l0_tag, family=fam, agents=selected, cache_bust=cache_bust)
            )
        except OSError as exc:
            raise BuildError(
                f"Image build failed preparing base build context for "
                f"{l1_tag!r} at {context}: {exc}"
            ) from exc

        # Build L0 — base dev image (Ubuntu + git + SSH + init script)
        build_project_image(
            dockerfile=context / "L0.Dockerfile",
            context_dir=context,
            target_tag=l0_tag,
            build_args={"BASE_IMAGE": base_image},
            no_cache=full_rebuild,
            pull_always=full_rebuild,
        )

        # The unsuffixed alias is reserved for the user's default L1
        # (whatever they have configured as ``image.agents``).  It must
        # not be retagged on partial / project / per-agent builds —
        # otherwise `terok auth <X>` would silently end up running
        # against an L1 missing X.  Callers that ARE building the
        # user's default selection pass ``tag_as_default=True``.
        build_project_image(
            dockerfile=context / "L1.cli.Dockerfile",
            context_dir=context,
            target_tag=l1_tag,
            extra_tags=extra_tags,
            build_args={"BASE_IMAGE": l0_tag, "AGENT_CACHE_BUST": cache_bust},
            no_cache=full_rebuild,
        )

    finally:
        if own_tmp:
            shutil.rmtree(context, ignore_errors=True)

    return ImageSet(l0=l0_tag, l1=l1_tag)

build_sidecar_image(base_image=DEFAULT_BASE_IMAGE, *, family=None, tool_name='coderabbit', rebuild=False, full_rebuild=False, build_dir=None)

Build the L1 sidecar image for a specific tool. Returns the image tag.

Ensures L0 exists first (builds it if missing), then builds the sidecar image FROM L0. The sidecar contains only the named tool — no agent CLIs, no LLMs.

Parameters:

Name Type Description Default
base_image str

Base OS image (passed through to L0 build).

DEFAULT_BASE_IMAGE
family str | None

Override for the package family ("deb" or "rpm"). None means detect from base_image via detect_family.

None
tool_name str

Tool to install (selects Jinja2 conditional in template).

'coderabbit'
rebuild bool

Force rebuild with cache bust.

False
full_rebuild bool

Force rebuild with --no-cache.

False
build_dir Path | None

Build context directory (must be empty or absent).

None

Returns:

Type Description
str

The sidecar image tag (e.g. terok-l1-sidecar:fedora-44).

Raises:

Type Description
BuildError

If podman is missing, the family cannot be resolved, or a build step fails.

ValueError

If build_dir is a file or a non-empty directory.

Source code in src/terok_executor/container/build.py
def build_sidecar_image(
    base_image: str = DEFAULT_BASE_IMAGE,
    *,
    family: str | None = None,
    tool_name: str = "coderabbit",
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
) -> str:
    """Build the L1 sidecar image for a specific tool. Returns the image tag.

    Ensures L0 exists first (builds it if missing), then builds the
    sidecar image FROM L0.  The sidecar contains only the named tool —
    no agent CLIs, no LLMs.

    Args:
        base_image: Base OS image (passed through to L0 build).
        family: Override for the package family (``"deb"`` or ``"rpm"``).
            ``None`` means detect from *base_image* via [`detect_family`][terok_executor.container.build.detect_family].
        tool_name: Tool to install (selects Jinja2 conditional in template).
        rebuild: Force rebuild with cache bust.
        full_rebuild: Force rebuild with ``--no-cache``.
        build_dir: Build context directory (must be empty or absent).

    Returns:
        The sidecar image tag (e.g. ``terok-l1-sidecar:fedora-44``).

    Raises:
        BuildError: If podman is missing, the family cannot be resolved,
            or a build step fails.
        ValueError: If *build_dir* is a file or a non-empty directory.
    """
    _validate_build_dir(build_dir)
    _check_podman()

    base_image = _normalize_base_image(base_image)
    l0_tag = l0_image_tag(base_image)
    sidecar_tag = l1_sidecar_image_tag(base_image)

    # Same fast-path as build_base_images: defer detect_family until we
    # know we actually need to render Dockerfiles, so cached sidecars
    # for unknown bases can be reused without re-supplying ``family``.
    if not rebuild and not full_rebuild and _image_exists(sidecar_tag) and _image_exists(l0_tag):
        return sidecar_tag

    fam = detect_family(base_image, override=family)

    # Ensure L0 exists (build if needed)
    if not _image_exists(l0_tag) or full_rebuild:
        build_base_images(base_image, family=fam, rebuild=rebuild, full_rebuild=full_rebuild)

    import tempfile

    own_tmp = build_dir is None
    context = build_dir or Path(tempfile.mkdtemp(prefix="terok-executor-sidecar-"))

    try:
        try:
            prepare_build_context(context)
            cache_bust = str(int(time.time()))

            (context / "L1.sidecar.Dockerfile").write_text(
                render_l1_sidecar(l0_tag, family=fam, tool_name=tool_name, cache_bust=cache_bust)
            )
        except OSError as exc:
            raise BuildError(
                f"Image build failed preparing sidecar build context for "
                f"{sidecar_tag!r} at {context}: {exc}"
            ) from exc

        build_project_image(
            dockerfile=context / "L1.sidecar.Dockerfile",
            context_dir=context,
            target_tag=sidecar_tag,
            build_args={"BASE_IMAGE": l0_tag, "TOOL_CACHE_BUST": cache_bust},
            no_cache=full_rebuild,
        )
    finally:
        if own_tmp:
            shutil.rmtree(context, ignore_errors=True)

    return sidecar_tag

prepare_build_context(dest)

Stage auxiliary resources into a build context directory.

After calling this, dest contains the resources that Dockerfile COPY directives reference:

  • scripts/ — container helper scripts (init, env, ACP wrappers)
  • toad-agents/ — ACP agent TOML definitions
  • tmux/ — container tmux config

Dockerfiles themselves are not written here — they are rendered and placed by build_base_images (which calls this function internally).

Source code in src/terok_executor/container/build.py
def prepare_build_context(dest: Path) -> None:
    """Stage auxiliary resources into a build context directory.

    After calling this, *dest* contains the resources that Dockerfile
    ``COPY`` directives reference:

    - ``scripts/``     — container helper scripts (init, env, ACP wrappers)
    - ``toad-agents/`` — ACP agent TOML definitions
    - ``tmux/``        — container tmux config

    Dockerfiles themselves are **not** written here — they are rendered
    and placed by [`build_base_images`][terok_executor.container.build.build_base_images] (which calls this function
    internally).
    """
    dest.mkdir(parents=True, exist_ok=True)
    stage_scripts(dest / "scripts")
    stage_toad_agents(dest / "toad-agents")
    stage_tmux_config(dest / "tmux")

render_l0(base_image=DEFAULT_BASE_IMAGE, *, family=None)

Render the L0 (base dev) Dockerfile.

The base_image is normalised before rendering so that blank or whitespace-only values produce a valid Dockerfile. family ("deb" or "rpm") selects the package-manager branch of the template; None resolves it via detect_family.

The rendered template ships a single vendor unit (sshd-terok.service) gated on a stock ConditionFileNotEmpty= over /etc/ssh/authorized_keys.d/terok. The trust file ships empty, so the unit is skipped at boot under crun (no keys ⇒ no listener); under krun terok bind-mounts the live host pubkey over it at launch, the condition flips to true, sshd starts on TCP 22, and the host reaches the guest via the per-task host port that podman's passt has forwarded into the guest namespace. One image, one build, two runtimes — and "off" is structural, not just inert.

Source code in src/terok_executor/container/build.py
def render_l0(base_image: str = DEFAULT_BASE_IMAGE, *, family: str | None = None) -> str:
    """Render the L0 (base dev) Dockerfile.

    The *base_image* is normalised before rendering so that blank or
    whitespace-only values produce a valid Dockerfile.  *family*
    (``"deb"`` or ``"rpm"``) selects the package-manager branch of the
    template; ``None`` resolves it via [`detect_family`][terok_executor.container.build.detect_family].

    The rendered template ships a single vendor unit
    (``sshd-terok.service``) gated on a stock ``ConditionFileNotEmpty=``
    over ``/etc/ssh/authorized_keys.d/terok``.  The trust file ships
    empty, so the unit is skipped at boot under crun (no keys ⇒ no
    listener); under krun terok bind-mounts the live host pubkey over
    it at launch, the condition flips to true, sshd starts on TCP 22,
    and the host reaches the guest via the per-task host port that
    podman's passt has forwarded into the guest namespace.  One image,
    one build, two runtimes — and "off" is structural, not just inert.
    """
    base_image = _normalize_base_image(base_image)
    fam = detect_family(base_image, override=family)
    return _render_template(
        "l0.dev.Dockerfile.template",
        {"BASE_IMAGE": base_image, "family": fam},
    )

render_l1(l0_image, *, family, agents='all', cache_bust='0')

Render the L1 (agent CLI) Dockerfile for the given agent selection.

l0_image is the tag of the L0 image to build on top of. family ("deb" or "rpm") selects the package-manager branch and is required — there is no L0 reference to detect from at this point, so callers must supply the value resolved at the L0 level (typically via detect_family). Each roster install snippet is itself rendered as a Jinja template with family in scope, so snippets can carry {% if family == "deb" %}…{% else %}…{% endif %} branches for package-manager-specific commands. agents is a tuple of already-resolved roster names (or the literal string "all"); the template loops over them and emits each one's install snippets. cache_bust invalidates the per-agent install layers when changed (typically set to a Unix timestamp).

Source code in src/terok_executor/container/build.py
def render_l1(
    l0_image: str,
    *,
    family: str,
    agents: tuple[str, ...] | str = "all",
    cache_bust: str = "0",
) -> str:
    """Render the L1 (agent CLI) Dockerfile for the given agent selection.

    *l0_image* is the tag of the L0 image to build on top of.  *family*
    (``"deb"`` or ``"rpm"``) selects the package-manager branch and is
    required — there is no L0 reference to detect from at this point, so
    callers must supply the value resolved at the L0 level (typically via
    [`detect_family`][terok_executor.container.build.detect_family]).  Each roster install snippet is itself rendered
    as a Jinja template with ``family`` in scope, so snippets can carry
    ``{% if family == "deb" %}…{% else %}…{% endif %}`` branches for
    package-manager-specific commands.  *agents* is a tuple of
    already-resolved roster names (or the literal string ``"all"``); the
    template loops over them and emits each one's install snippets.
    *cache_bust* invalidates the per-agent install layers when changed
    (typically set to a Unix timestamp).
    """
    from terok_executor.roster import AgentRoster

    roster = AgentRoster.shared()
    selected = roster.resolve_selection(agents)
    installs = roster.installs

    root_snippets = [
        _render_snippet(installs[n].run_as_root, family)
        for n in selected
        if installs[n].run_as_root
    ]
    dev_snippets = [
        _render_snippet(installs[n].run_as_dev, family) for n in selected if installs[n].run_as_dev
    ]

    return _render_template(
        "l1.agent-cli.Dockerfile.template",
        {
            "BASE_IMAGE": l0_image,
            "AGENT_CACHE_BUST": cache_bust,
            "family": family,
            "install_root_snippets": root_snippets,
            "install_dev_snippets": dev_snippets,
            "installed_agents_csv": ",".join(selected),
            "agents_label": AGENTS_LABEL,
            "installed_env_path": INSTALLED_ENV_PATH,
        },
    )

render_l1_sidecar(l0_image, *, family, tool_name='coderabbit', cache_bust='0')

Render the L1 sidecar (tool-only) Dockerfile.

The sidecar image is built FROM L0 (not L1) and installs a single tool binary — no agent CLIs, no LLMs. family (required) selects the package-manager branch; tool_name selects which tool install block to activate via Jinja2 conditional.

Source code in src/terok_executor/container/build.py
def render_l1_sidecar(
    l0_image: str,
    *,
    family: str,
    tool_name: str = "coderabbit",
    cache_bust: str = "0",
) -> str:
    """Render the L1 sidecar (tool-only) Dockerfile.

    The sidecar image is built FROM L0 (not L1) and installs a single
    tool binary — no agent CLIs, no LLMs.  *family* (required) selects
    the package-manager branch; *tool_name* selects which tool install
    block to activate via Jinja2 conditional.
    """
    return _render_template(
        "l1.sidecar.Dockerfile.template",
        {
            "BASE_IMAGE": l0_image,
            "TOOL_CACHE_BUST": cache_bust,
            "tool_name": tool_name,
            "family": family,
        },
    )

stage_scripts(dest)

Stage container helper scripts into dest.

Copies executor's own resources/scripts/ then overlays the socat-based bridge scripts that ship with terok_sandbox (ensure-bridges.sh + ssh-agent-bridge.sh). The bridges live in sandbox because they encode sandbox-level concerns with no executor-specific logic; executor still bundles them into the container image so the Dockerfile's COPY scripts/… lines keep finding them at their established names.

Raises BuildError when terok_sandbox is not importable — sandbox is a hard dependency in pyproject.toml, but a broken install would otherwise surface as a raw ModuleNotFoundError traceback.

Source code in src/terok_executor/container/build.py
def stage_scripts(dest: Path) -> None:
    """Stage container helper scripts into *dest*.

    Copies executor's own ``resources/scripts/`` then overlays the
    socat-based bridge scripts that ship with ``terok_sandbox``
    (``ensure-bridges.sh`` + ``ssh-agent-bridge.sh``).  The bridges
    live in sandbox because they encode sandbox-level concerns with
    no executor-specific logic; executor still bundles them into the
    container image so the Dockerfile's ``COPY scripts/…`` lines keep
    finding them at their established names.

    Raises [`BuildError`][terok_executor.container.build.BuildError]
    when ``terok_sandbox`` is not importable — sandbox is a hard
    dependency in ``pyproject.toml``, but a broken install would
    otherwise surface as a raw ``ModuleNotFoundError`` traceback.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_executor", "resources/scripts", dest)
    try:
        _copy_package_tree("terok_sandbox", "resources/bridges", dest)
    except ModuleNotFoundError as exc:
        raise BuildError(
            "terok_sandbox is not importable — its bridge resources "
            "(resources/bridges/) could not be staged into the build "
            "context.  Reinstall terok-executor's dependencies to fix."
        ) from exc
    _clean_packaging_artifacts(dest)

stage_toad_agents(dest)

Stage Toad ACP agent TOML definitions into dest.

These describe OpenCode-based agents (Blablador, KISSKI, etc.) that are injected into Toad's bundled agent directory at container build time.

Source code in src/terok_executor/container/build.py
def stage_toad_agents(dest: Path) -> None:
    """Stage Toad ACP agent TOML definitions into *dest*.

    These describe OpenCode-based agents (Blablador, KISSKI, etc.) that are
    injected into Toad's bundled agent directory at container build time.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_executor", "resources/toad-agents", dest)
    _clean_packaging_artifacts(dest)

stage_help_fragments(dest, agents)

Render per-section hilfe help fragments into dest.

Writes one file per section (currently agent and dev_tool) containing the labels of the selected agents that have a help: section, with backslash escapes (\033[...]) interpreted into real ANSI sequences so that hilfe only needs to cat them. Empty sections are omitted entirely; hilfe skips missing files.

Source code in src/terok_executor/container/build.py
def stage_help_fragments(dest: Path, agents: tuple[str, ...]) -> None:
    """Render per-section ``hilfe`` help fragments into *dest*.

    Writes one file per section (currently ``agent`` and ``dev_tool``)
    containing the labels of the selected agents that have a ``help:``
    section, with backslash escapes (``\\033[...]``) interpreted into
    real ANSI sequences so that ``hilfe`` only needs to ``cat`` them.
    Empty sections are omitted entirely; ``hilfe`` skips missing files.
    """
    from terok_executor.roster import AgentRoster

    roster = AgentRoster.shared()
    helps = roster.helps

    by_section: dict[str, list[str]] = {}
    for name in agents:
        spec = helps.get(name)
        if spec is None or not spec.label:
            continue
        by_section.setdefault(spec.section, []).append(spec.label)

    if dest.exists():
        shutil.rmtree(dest)
    dest.mkdir(parents=True, exist_ok=True)
    for section, lines in by_section.items():
        decoded = "".join(_decode_label_escapes(line) + "\n" for line in lines)
        (dest / _HELP_SECTION_FILES[section]).write_text(decoded, encoding="utf-8")

stage_tmux_config(dest)

Stage the container tmux configuration into dest.

Copies container-tmux.conf — the green-status-bar config that distinguishes container tmux sessions from host tmux.

Source code in src/terok_executor/container/build.py
def stage_tmux_config(dest: Path) -> None:
    """Stage the container tmux configuration into *dest*.

    Copies ``container-tmux.conf`` — the green-status-bar config that
    distinguishes container tmux sessions from host tmux.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_executor", "resources/tmux", dest)
    _clean_packaging_artifacts(dest)

l0_image_tag(base_image)

Return the L0 base dev image tag for base_image.

Source code in src/terok_executor/container/build.py
def l0_image_tag(base_image: str) -> str:
    """Return the L0 base dev image tag for *base_image*."""
    return f"terok-l0:{_base_tag(base_image)}"

l1_image_tag(base_image, agents=None)

Return the L1 agent CLI image tag for base_image and a selection.

When agents is None, returns the unsuffixed default-alias (e.g. terok-l1-cli:fedora-44). This alias points at whichever L1 was last built with tag_as_default=True — i.e. the L1 that holds the user's configured default agent selection. Project / per-agent / partial builds get only their suffixed tag and never touch the alias, so terok auth <provider> can rely on the alias actually containing every agent the user configured.

When agents is a tuple of names, appends a sorted -a-b-c suffix (- is the only spec-valid separator that _base_tag already uses) so multiple selections coexist in the local image store and stay individually addressable. Agent name fragments are passed through the same _base_tag sanitiser to keep the final tag within the OCI tag charset ([A-Za-z0-9_.-]).

The full tag (after :) is bounded by _MAX_TAG_LEN. When the readable base-a-b-c form would overflow, the agent portion is replaced with a SHA1 digest of the sorted selection — same collision-resistant fallback pattern _base_tag uses internally for overlong image names.

Source code in src/terok_executor/container/build.py
def l1_image_tag(base_image: str, agents: tuple[str, ...] | None = None) -> str:
    """Return the L1 agent CLI image tag for *base_image* and a selection.

    When *agents* is ``None``, returns the unsuffixed **default-alias**
    (e.g. ``terok-l1-cli:fedora-44``).  This alias points at whichever
    L1 was last built with ``tag_as_default=True`` — i.e. the L1 that
    holds the user's configured default agent selection.  Project /
    per-agent / partial builds get only their suffixed tag and never
    touch the alias, so ``terok auth <provider>`` can rely on the alias
    actually containing every agent the user configured.

    When *agents* is a tuple of names, appends a sorted ``-a-b-c``
    suffix (``-`` is the only spec-valid separator that ``_base_tag``
    already uses) so multiple selections coexist in the local image
    store and stay individually addressable.  Agent name fragments are
    passed through the same ``_base_tag`` sanitiser to keep the final
    tag within the OCI tag charset (``[A-Za-z0-9_.-]``).

    The full tag (after ``:``) is bounded by `_MAX_TAG_LEN`.  When
    the readable ``base-a-b-c`` form would overflow, the agent portion
    is replaced with a SHA1 digest of the sorted selection — same
    collision-resistant fallback pattern `_base_tag` uses
    internally for overlong image names.
    """
    base_tag = _base_tag(base_image)
    if agents is None:
        return f"terok-l1-cli:{base_tag}"
    readable_suffix = "-".join(_base_tag(a) for a in sorted(agents)) if agents else "empty"
    if len(base_tag) + 1 + len(readable_suffix) <= _MAX_TAG_LEN:
        return f"terok-l1-cli:{base_tag}-{readable_suffix}"
    suffix = hashlib.sha1(
        ",".join(sorted(agents)).encode("utf-8"), usedforsecurity=False
    ).hexdigest()[:_AGENT_DIGEST_LEN]
    # Pathological case: a base already near _MAX_TAG_LEN leaves no room
    # even for the digest.  Trim base_tag further — same collision-resistant
    # shape as the digest fallback itself.
    max_base = _MAX_TAG_LEN - 1 - _AGENT_DIGEST_LEN
    if len(base_tag) > max_base:
        base_tag = base_tag[:max_base]
    return f"terok-l1-cli:{base_tag}-{suffix}"

l1_sidecar_image_tag(base_image)

Return the L1 sidecar (tool-only) image tag for base_image.

Source code in src/terok_executor/container/build.py
def l1_sidecar_image_tag(base_image: str) -> str:
    """Return the L1 sidecar (tool-only) image tag for *base_image*."""
    return f"terok-l1-sidecar:{_base_tag(base_image)}"

image_agents(image)

Return the set of agent names installed in image.

Reads the ai.terok.agents OCI label baked into the L1 image at build time (see AGENTS_LABEL). Returns an empty set if the image is missing, has no label, or its label is empty — never raises on inspection failure, since callers use the result to make a "image good enough?" decision and an empty set always means "no, rebuild".

Source code in src/terok_executor/container/build.py
def image_agents(image: str) -> set[str]:
    """Return the set of agent names installed in *image*.

    Reads the ``ai.terok.agents`` OCI label baked into the L1 image at
    build time (see [`AGENTS_LABEL`][terok_executor.container.build.AGENTS_LABEL]).
    Returns an empty set if the image is missing, has no label, or its
    label is empty — never raises on inspection failure, since callers
    use the result to make a "image good enough?" decision and an
    empty set always means "no, rebuild".
    """
    try:
        result = subprocess.run(
            [
                "podman",
                "image",
                "inspect",
                image,
                "--format",
                f'{{{{ index .Config.Labels "{AGENTS_LABEL}" }}}}',
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return set()
    if result.returncode != 0:
        return set()
    csv = result.stdout.strip()
    return {a for a in csv.split(",") if a} if csv else set()

ensure_default_l1(base_image=DEFAULT_BASE_IMAGE, *, family=None, agents='all')

Return the default-alias L1 tag, building the user's default L1 if absent.

Used by terok auth (and the equivalent standalone-executor flow) to resolve a host-wide L1 image that contains every agent the user has configured. If the alias already exists locally, it is trusted and returned as-is — the alias is reserved for the user's default selection (see l1_image_tag), so its contents are well-defined. When the alias is missing the function builds it via build_base_images with tag_as_default=True.

agents defaults to the literal string "all" so standalone callers get the whole roster. terok passes the user's configured image.agents value here so the alias means "every agent the user has enabled" rather than the implementation-default roster.

Source code in src/terok_executor/container/build.py
def ensure_default_l1(
    base_image: str = DEFAULT_BASE_IMAGE,
    *,
    family: str | None = None,
    agents: str | tuple[str, ...] = "all",
) -> str:
    """Return the default-alias L1 tag, building the user's default L1 if absent.

    Used by ``terok auth`` (and the equivalent standalone-executor flow)
    to resolve a host-wide L1 image that contains every agent the user
    has configured.  If the alias already exists locally, it is trusted
    and returned as-is — the alias is reserved for the user's default
    selection (see [`l1_image_tag`][terok_executor.container.build.l1_image_tag]),
    so its contents are well-defined.  When the alias is missing the
    function builds it via [`build_base_images`][terok_executor.container.build.build_base_images]
    with ``tag_as_default=True``.

    *agents* defaults to the literal string ``"all"`` so standalone
    callers get the whole roster.  terok passes the user's configured
    ``image.agents`` value here so the alias means *"every agent the
    user has enabled"* rather than the implementation-default roster.
    """
    alias = l1_image_tag(base_image)
    if _image_exists(alias):
        return alias
    build_base_images(
        base_image=base_image,
        family=family,
        agents=agents,
        tag_as_default=True,
    )
    return alias