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, pre-rendered hilfe dev-tool help
fragments, and a baked per-agent facts map (agent-protocols.json —
wire protocol and banner label) that the in-container readiness check and
the hilfe agent banner consume — 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.
AGENT_PROTOCOLS_PATH = '/usr/local/share/terok/agent-protocols.json'
module-attribute
¶
In-container map of each installed agent to the roster facts the readiness
check and the hilfe banner need.
Each entry carries the agent's wire protocol (which authenticated
providers it can talk to — null for entries with no fixed protocol) and its
banner label. These are the pieces of roster knowledge the container
environment does not already carry. Usability turns on the protocol alone —
any authenticated provider serving it works — so no provider name is recorded.
Baked at L1 build time and read back by the in-container manifest generator
(terok-agents).
The filename is historical (it once held only protocols); the contents are now
a small per-agent record. Producer (stage_agent_facts)
and consumer ship in the same wheel, baked into and read from the same image,
so the shape is free to evolve in lockstep.
PROVIDER_PROTOCOLS_PATH = '/usr/local/share/terok/provider-protocols.json'
module-attribute
¶
In-container map of each wire protocol to the providers that serve it.
The complement of AGENT_PROTOCOLS_PATH: agents say which protocol
they speak, this says which providers speak it. Lets hilfe list, under the
providers section, the candidate providers a user could authenticate to enable
a dimmed agent — including providers not yet authenticated, which the container
environment does not carry. Baked at L1 build time by
stage_provider_protocols.
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.
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_imageandself.familyto 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
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
ensure_default_l1(agents='all')
¶
Return the default-alias L1 tag, building the default L1 if absent.
l1_tag(agents=None)
¶
L1 image tag for agents under self.base_image (alias when None).
render_l0()
¶
Render the L0 Dockerfile for this base.
Instance-bound because L0 is anchored on self.base_image.
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
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
detect_family(base_image, override=None)
staticmethod
¶
Resolve the package family ("deb" / "rpm") for base_image.
image_agents(image)
staticmethod
¶
stage_scripts(dest)
staticmethod
¶
stage_tmux_config(dest)
staticmethod
¶
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
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 ( |
required |
context_dir
|
Path
|
Build context directory (final positional argument). |
required |
target_tag
|
str
|
Primary image tag ( |
required |
extra_tags
|
tuple[str, ...]
|
Additional tags applied to the same build (each becomes
another |
()
|
build_args
|
dict[str, str] | None
|
|
None
|
labels
|
dict[str, str] | None
|
|
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
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. |
DEFAULT_BASE_IMAGE
|
family
|
str | None
|
Override for the package family ( |
None
|
agents
|
str | tuple[str, ...]
|
Roster entries to install, as the literal string |
'all'
|
rebuild
|
bool
|
Force rebuild with cache bust (refreshes agent installs). |
False
|
full_rebuild
|
bool
|
Force rebuild with |
False
|
build_dir
|
Path | None
|
Build context directory (must be empty or absent). |
None
|
tag_as_default
|
bool
|
When |
False
|
Returns:
| Type | Description |
|---|---|
ImageSet
|
|
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
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 | |
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 ( |
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 |
False
|
build_dir
|
Path | None
|
Build context directory (must be empty or absent). |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The sidecar image tag (e.g. |
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
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 | |
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 definitionstmux/— 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
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
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
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
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
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
stage_help_fragments(dest, agents)
¶
Render the baked hilfe help fragments into dest.
Writes one file per bakeable section — only dev_tool (see the
module-level _HELP_SECTION_FILES map) — holding the labels of the
selected entries that have a help: block, with backslash escapes
(\033[...]) interpreted into real ANSI so that hilfe only needs to
cat them. The agent section is deliberately not baked: hilfe
renders it at runtime so unusable agents can be dimmed (see
stage_agent_facts). Empty sections are omitted;
hilfe skips missing files.
Source code in src/terok_executor/container/build.py
stage_agent_facts(dest_file, agents)
¶
Bake the per-agent facts the in-container readiness layer needs, as JSON.
For every selected entry in the agent help section, records a small
record — {"protocol", "label"} — so that terok-agents can decide, at
runtime, whether each agent has an authenticated provider it can talk to,
and hilfe can render it bright or dimmed:
protocol— the wire protocol the agent speaks (Nonefor entries with no fixed protocol, e.g. thetoadfrontend). An agent is usable when any authenticated provider serves this protocol — not tied to a single default — so the protocol, not a provider name, is the fact that matters. The provider→protocol universe is baked separately bystage_provider_protocols.label— the banner line, escapes already decoded to real ANSI.
These facts are the only roster knowledge the container environment cannot
reconstruct, so they travel into the image alongside the help fragments.
The file is always written — an empty object when the selection has no
agent-section entries — so the Dockerfile COPY always has a source.
Source code in src/terok_executor/container/build.py
stage_provider_protocols(dest_file, agents)
¶
Bake the wire-protocol → candidate-providers universe as JSON.
For every protocol any selected agent speaks, records the sorted list of
providers whose serves covers it — i.e. the providers a user could
authenticate to make those agents usable. This is what lets hilfe turn
a dimmed agent's needs an openai-chat provider into an actionable list
under the providers section, including providers not yet authenticated
(which the container environment, carrying only authenticated providers,
cannot enumerate on its own).
Scoped to the protocols actually in play for this image, so an L1 without a
given agent doesn't advertise providers for a protocol nothing speaks. The
file is always written — an empty object when no selected agent speaks a
protocol — so the Dockerfile COPY always has a source.
Source code in src/terok_executor/container/build.py
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
l0_image_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
l1_sidecar_image_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
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.