Skip to content

Build System

Capsem builds VM assets from the profile ledger. Checked-in config/profiles/<profile_id>/profile.toml and its referenced sibling files are product source truth. capsem-admin image build resolves that profile into a generated backend workspace, then invokes the private Python builder backend to validate the backend image spec, render Jinja2 Dockerfiles, and produce per-architecture VM assets. capsem-builder is not a public image-authoring CLI.

flowchart TD
  subgraph Input["Source of Truth"]
    PROFILE["config/profiles/<id>/profile.toml\n+ package, MCP, rule,\nroot, build, tips files"]
    MATERIALIZED["generated backend workspace\nbackend image spec"]
  end

  subgraph Validation["Validation Layer"]
    Profile["capsem-admin profile check\nsource contract"]
    Config["config.py\nTOML loader"]
    Models["models.py\nPydantic models\n(PackageManager, InstallConfig,\ntool/package/network configs, ...)"]
    Validate["validate.py\nLinter (E001-E402, W001-W012)"]
  end

  subgraph Generation["Code Generation"]
    Context["docker.py\n_rootfs_context()\n_kernel_context()"]
    Jinja["Jinja2 Templates\nDockerfile.rootfs.j2\nDockerfile.kernel.j2"]
  end

  subgraph Output["Build Outputs"]
    Docker["Docker Build"]
    Assets["assets/{arch}/\nvmlinuz, initrd.img,\nrootfs.erofs"]
    Ledger["build-ledger.log\nconfig inputs + hashes"]
    BOM["manifest.json\n+ B3SUMS\n+ obom.cdx.json"]
    RuntimeConfig["target/config/\nmaterialized runtime profiles"]
  end

  PROFILE --> Profile
  Profile --> MATERIALIZED
  MATERIALIZED --> Config
  Config --> Models
  Models --> Validate
  Models --> Context
  Context --> Jinja
  Jinja --> Docker
  Docker --> Assets
  Docker --> Ledger
  Assets --> BOM
  BOM --> RuntimeConfig

The data flows through four layers:

  1. Profile ledger (config/profiles/<id>/profile.toml) — runtime and build product truth: assets, package files, MCP config, security rules, plugins, root seed, install script, tips, and OBOM descriptors.
  2. Image materialization (capsem-admin image build) — validates profile references, recopies descriptor files and profile root payloads from source, and writes a generated backend image workspace.
  3. Pydantic models (models.py) — validate the generated backend image spec with enums (PackageManager: apt, uv, pip, npm, curl), frozen models, and cross-field validators.
  4. Context dicts and Jinja2 templates (docker.py, config/docker/) — produce per-architecture Dockerfiles and build contexts.

Four outputs are produced:

  1. Rendered Dockerfiles — Jinja2 templates (Dockerfile.rootfs.j2, Dockerfile.kernel.j2) parameterized per architecture.
  2. VM assetsvmlinuz, initrd.img, and rootfs.erofs.
  3. build-ledger.log — JSONL debug evidence for rendered inputs, context hashes, profile/package inputs, EROFS settings, git revision, and project version.
  4. target/config/ — generated runtime config produced by capsem-admin profile materialize from checked-in config/ plus assets/manifest.json.
FileModelPurposeKey Fields
build.tomlBuildConfigArchitectures, compressioncompression, compression_level, architectures.*
manifest.tomlImageManifestConfigImage identity and changelogname, version, description, changelog
packages/apt.tomlPackageSetConfigApt package setmanager, install_cmd, packages, network
packages/python.tomlPackageSetConfigPython package setmanager, install_cmd, packages
kernel/defconfig.*(raw)Kernel configs per archLinux kernel defconfig files

These files are backend image spec, usually generated under target/ by the profile-derived build rail. They are implementation detail, not product authoring API. Do not add provider authorization, credentials, security policy, UI settings, or MCP runtime truth to the backend image spec. Those belong to the profile, corp config, rule files, and plugins.

Example build.toml:

[build]
compression = "zstd"
compression_level = 15
[build.erofs]
enabled = true
compression = "lz4hc"
compression_level = 12
[build.architectures.arm64]
base_image = "debian:bookworm-slim"
docker_platform = "linux/arm64"
rust_target = "aarch64-unknown-linux-musl"
kernel_branch = "7.0"
kernel_image = "arch/arm64/boot/Image"
defconfig = "kernel/defconfig.arm64"
node_major = 24

Profile package files such as config/profiles/code/apt-packages.txt, python-requirements.txt, and npm-packages.txt are materialized into backend package TOML before the build. Provider allow/block decisions live in profile/corp enforcement rules. Credentials are captured and materialized by the credential broker plugin at runtime and logged only as BLAKE3 references.

Profile validation is exposed through capsem-admin profile check. The Python builder keeps compiler-style diagnostics internally, with error codes, severity levels, and file:line references, but it is not a second public profile validation rail. Errors block the admin/profile build path; warnings are informational. There is no public capsem-builder build, render-only, inspect, validate, MCP, or dry-run rail for product images.

RangeCategoryExamples
E001-E002TOML parsingMissing build.toml, invalid TOML syntax
E003-E005Pydantic validationSchema violations, empty package lists, invalid enum values
E006Domain validationURLs in domain fields, ports, path components
E008Duplicate keysSame key in multiple files within a directory
E009-E010File contentNon-absolute paths, invalid JSON in .json file settings
E100-E103Schema / JSONGenerated JSON fails schema validation
E200-E202Cross-languageRust/Python conformance mismatches
E300-E305ArtifactsMissing defconfig, CA cert, capsem-init, diagnostics
E400-E402DockerDockerfile generation failures
CodeDescription
W001Package sets configured but no registry config
W002Development packages (-dev, -devel) in package lists
W003Potential secrets detected in file content, headers, or env
W004Package set with no network config
W005Conflicting profile/corp enforcement rules
W006Placeholder file content (TODO, FIXME)
W007Overly broad security rule match expressions
W008Duplicate tool credential hints
W009Shell metacharacters in install_cmd
W010PATH missing essential directories (/usr/bin, /bin)
W011Wide-open network/security rule posture
W012Unknown Rust target (not a known musl target)

Diagnostic output format:

error: [E006] config/profiles/code/enforcement.toml: Invalid domain pattern 'https://api.anthropic.com'
warning: [W003] config/profiles/code/mcp.json: Potential secret in MCP server headers

Two architectures are supported. Each is self-contained in build.toml and produces an independent asset directory.

ArchitectureHypervisorDocker PlatformRust TargetKernel Image
arm64Apple VZ (macOS) / KVM (Linux)linux/arm64aarch64-unknown-linux-muslarch/arm64/boot/Image
x86_64KVMlinux/amd64x86_64-unknown-linux-muslarch/x86_64/boot/bzImage

Output layout:

assets/
arm64/
vmlinuz
initrd.img
rootfs.erofs
tool-versions.txt
x86_64/
vmlinuz
initrd.img
rootfs.erofs
tool-versions.txt
manifest.json
B3SUMS
target/
config/
assets/manifest.json
profiles/code/profile.toml
flowchart TD
  Load["Load TOML configs"] --> Validate["Validate (Pydantic + linter)"]
  Validate -->|errors| Abort["Abort with diagnostics"]
  Validate -->|clean| Arches["For each architecture"]
  Arches --> Cross["Cross-compile guest binaries\n(cargo build --target)"]
  Cross --> Render["Render Dockerfile.rootfs.j2"]
  Render --> Context["Assemble build context\n(CA cert, bashrc, diagnostics, binaries)"]
  Context --> Build["Docker build"]
  Build --> Export["Export container filesystem"]
  Export --> Erofs["mkfs.erofs (lz4hc level 12)"]
  Erofs --> Versions["Extract tool versions"]
  Versions --> Checksums["Generate B3SUMS + manifest.json"]
  Checksums --> Materialize["Materialize target/config\nfrom profile + manifest"]

The kernel build follows a parallel path:

flowchart TD
  KLoad["Load build.toml"] --> KResolve["Resolve kernel version\n(kernel.org LTS lookup)"]
  KResolve --> KRender["Render Dockerfile.kernel.j2"]
  KRender --> KBuild["Docker build\n(kernel compile + initrd)"]
  KBuild --> KExtract["Extract vmlinuz + initrd.img"]

Key implementation details:

  • Container runtime auto-detection. Docker CLI.
  • CI cache integration. Docker buildx with GitHub Actions cache (type=gha) when GITHUB_ACTIONS is set.
  • Kernel version resolution. Fetches the latest stable version for the configured LTS branch from kernel.org/releases.json, falls back to a hardcoded version on network failure.
  • Cross-compilation. Guest agent binaries are cross-compiled with cargo build --target {rust_target} using rust-lld as the linker (configured in .cargo/config.toml).
  • Clock skew resilience. All apt-get update calls use -o Acquire::Check-Valid-Until=false to handle container VM clock drift.

On macOS, Docker runs inside a Colima VM with limited resources. The rootfs build runs apt, npm, and profile install steps, requiring substantial memory.

ThresholdRAMNotes
Minimum12 GBTauri install-test cold build SIGTERMs below this (exit 143 mid-cargo)
Recommended16 GBComfortable margin for build-assets + install-test together
CI (GitHub Actions)7 GBStandard runner; install-test container uses pre-baked image so no cold build
Terminal window
# Colima (macOS): configure VM resources
colima stop
colima start --vm-type vz --vz-rosetta --memory 16 --cpu 8
# Linux: Docker runs natively, no memory tuning needed
# sudo apt install docker.io

just doctor and capsem-builder doctor both check these resources automatically and fail if below minimum.

Profile-owned package files and install scripts resolve into backend package sets. The builder supports multiple install strategies:

ManagerTemplate HandlingUse CaseExample
npmBatched into single npm install -g --prefixNode.js CLI toolsGemini CLI, Codex
curlProfile install script or backend curl package setNative binary installersClaude Code
aptPackage set (not per-provider)System packagescoreutils, git, curl
uvPackage set (not per-provider)Python packagesnumpy, pytest
pipPackage set (not per-provider)Python packages (fallback)

At runtime, /root is a tmpfs overlay — anything baked into the rootfs under /root/ during the Docker build is hidden. This matters for CLI installers that put binaries in ~/.local/bin/ or ~/.claude/bin/:

# The installer puts claude at ~/.local/bin/claude, which is /root/.local/bin/
# inside the container. Since /root is tmpfs at runtime, copy to /usr/local/bin.
RUN curl -fsSL https://claude.ai/install.sh | bash && \
for bin in /root/.local/bin/*; do \
[ -f "$bin" ] && install -m 555 "$bin" /usr/local/bin/; \
done

The install -m 555 enforces the guest binary security invariant: all binaries are read-only, non-writable by the guest.

To add a new manager type (e.g., cargo):

  1. Add the enum value to PackageManager in models.py
  2. Collect packages in _rootfs_context() in docker.py — create a new list variable
  3. Pass it to the template context dict
  4. Add a Jinja2 block in Dockerfile.rootfs.j2
  5. Update tests in test_docker.py and the admin materialization tests

The generated Dockerfile.rootfs.j2 follows a specific ordering. Understanding this is important when adding new install steps — the /root cleanup and binary permissions are load-bearing:

flowchart TD
  A["1. apt packages\n(system tools, runtimes)"] --> B["2. Node.js via nvm\n(for npm-based CLIs)"]
  B --> C["3. uv installer\n(Python package manager)"]
  C --> D["4. npm install\n(Gemini CLI, Codex)"]
  D --> E["5. CA certificate\n+ certifi patch"]
  E --> F["6. Guest binaries\n(COPY + chmod 555)"]
  F --> G["7. Shell config + diagnostics\n(bashrc, banner, tests)"]
  G --> H["8. Python packages\n(uv pip install)"]
  H --> I["9. Security hardening\n(strip setuid, rm EXTERNALLY-MANAGED)"]
  I --> J["10. rm -rf /root\n(clean HOME for tmpfs)"]
  J --> K["11. curl installers\n(Claude Code, copy to /usr/local/bin)"]
  K --> L["12. Switch apt to HTTPS"]

  style J fill:#f9f,stroke:#333
  style K fill:#bbf,stroke:#333

Step 10 and 11 ordering matters: curl installers run after the /root cleanup so there’s a clean HOME. Binaries are immediately copied to /usr/local/bin/ since /root becomes tmpfs at boot.

Every build produces manifest.json at the asset root. The manifest records asset hashes and compatibility, including the per-arch CycloneDX obom.cdx.json. The per-arch build-ledger.log records debug evidence for the inputs that produced the assets, but release uploads expose the OBOM as the installed base-image package/component truth. The OBOM does not describe user session mutations, workspace writes, or post-boot state.

SectionSourceContents
Assetsb3sum outputFilename, BLAKE3 hash, size in bytes
Build ledgerbuild pipelineDebug-only rendered Dockerfile/context hashes, profile/package inputs, EROFS settings
OBOMcdxgenPublished installed base-image package/component names and versions

Profile builds feed the release graph through profile-owned records. The root channels.json file lists stable, nightly, and any future channel, each with versioned manifest records and one status enum value: current, supported, deprecated, or revoked. A channel manifest can change package artifacts and per-binary inventory without changing profiles. A profile release can change one profile’s config files, profile images, software inventory, ABOM/OBOM evidence, and manifest profile digest without changing packages, other profiles, or other channels.

The graph hierarchy is:

channels.json
-> assets/<channel>/manifest.json
-> packages
-> binaries
-> profiles
-> profile images, config files, software inventory, ABOM/OBOM evidence

Profiles may declare min_capsem_version when their config or image requires a newer client. They do not reference the selected Capsem package or binary; the manifest owns package metadata and every per-binary SHA-256, BLAKE3, and SBOM component reference.

The audit subcommand parses vulnerability scanner output and fails on CRITICAL or HIGH findings.

CommandDescriptionKey Options
capsem-admin image buildBuild profile-derived kernel/rootfs assets--profile, --config-root, --arch, --template, --output, --clean, --json
capsem-admin profile checkValidate source profile, file references, rules, MCP, and root seed--config-root, --arch, --json
capsem-builder doctorBackend prerequisite checks used by the build rail--profile, --config-root
capsem-builder agentCross-compile guest agent binaries for initrd repack--arch, --output
capsem-builder auditParse vulnerability scan results--scanner (trivy/grype), --input, --json
capsem-builder validate-skillsValidate repository development skills--json

Usage:

Terminal window
# Validate the active profile and profile-owned files
cargo run -p capsem-admin -- profile check config/profiles/code/profile.toml --config-root config
# Build rootfs for arm64 through the profile-derived build rail
cargo run -p capsem-admin -- image build --profile config/profiles/code/profile.toml --config-root config --arch arm64 --template rootfs
# Build kernel for all architectures
cargo run -p capsem-admin -- image build --profile config/profiles/code/profile.toml --config-root config --template kernel

There is no public capsem-builder build, capsem-builder validate, capsem-builder inspect, builder MCP, or --dry-run rendering rail. Product image inputs must enter through profile/corp/settings config and the capsem-admin checks above.

Settings schema generation is separate from image building. Settings are UI/app preferences; profiles own assets, MCP, rules, plugins, and image payloads.

flowchart LR
  TOML["config/settings/settings.toml"] --> Py["generate_defaults_json()"]
  Py --> DJ["config/settings/ui-metadata.generated.json"]
  DJ --> Rust["include_str! in Rust"]
  Py --> Schema["config/settings/schema.generated.json"]
  Schema --> CV["Cross-language\nconformance tests"]
  DJ --> CV

generate_defaults_json() transforms host settings source into the hierarchical JSON tree consumed by the Rust settings UI metadata. This JSON defines each setting’s name, description, type, default value, and UI metadata.

The schema is generated from SettingsRoot.model_json_schema() (Pydantic) and written to config/settings/schema.generated.json. Cross-language conformance tests verify that:

  1. The generated settings UI metadata validates against the JSON schema.
  2. Rust’s compiled-in defaults match the Python-generated output.
  3. Every setting referenced in Rust code exists in the schema.

This ensures the Python build tooling and Rust runtime never drift.