Configuration
The default capOS boot manifest (system.cue at the repo root) is layered
on a shared scaffold in cue/defaults/defaults.cue.
Operators can extend it without forking either file by dropping a
system.local.cue overlay next to system.cue. The overlay is
gitignored, so each developer/host can carry their own extensions
without conflicting with git pull.
This document covers the slice-2 extension surface. The design rationale
lives in
docs/proposals/system-configuration-proposal.md.
How the layering works
mkmanifest --package capos system.cue manifest.bin invokes
cue export .:capos --out json against the repo root. CUE’s package
mode unifies every non-hidden .cue file in that directory that
declares package capos — currently system.cue (committed) and any
system.local.cue (gitignored) the operator drops in. The shared
scaffold is imported by system.cue:
import defaults "capos.local/cue/defaults"
#Manifest (the value system.cue exports) inherits all defaults from
defaults.#DefaultSystem, then applies any operator overrides declared
in system.local.cue. The kernel decoder reads concrete fields at the
document root (schemaVersion, binaries, initConfig,
kernelParams); #Manifest is documentation-only.
Quick start
Copy the committed example and edit:
cp system.local.cue.example system.local.cue
$EDITOR system.local.cue
make run
The Makefile picks up the new file automatically — no flag, no include
line. make re-evaluates the manifest because system.local.cue is a
prerequisite of the manifest rule.
Worked examples
Override the MOTD
package capos
#Manifest: kernelParams: motd: """
hello, capOS dev box.
type 'login' to authenticate.
"""
The defaults package declares motd: string | *"...", so a concrete
overlay value wins under CUE unification (a more concrete value is
strictly more specific than a default).
Add an authorized SSH key for the host operator
The default manifest declares a single host-operator seed account with
the canonical 32-byte principal id local-operator-principal-default.
Bind any number of authorized keys to that principal:
package capos
#Manifest: extraAuthorizedSshKeys: [{
keyId: "host-laptop-ed25519-2026-04"
principalId: "local-operator-principal-default"
algorithm: "ssh-ed25519"
publicKey: "<32-byte ed25519 public key as ASCII hex>"
fingerprintSha256: "<32-byte SHA-256 of the public key as ASCII hex>"
allowedShellProfiles: ["operator"]
source: "manifest"
comment: "host laptop"
}]
Convert an existing ~/.ssh/id_ed25519.pub line to the manifest hex
fields (Ed25519 example):
# extract the base64-encoded SSH wire format and decode the embedded key
ssh-keygen -e -m PKCS8 -f ~/.ssh/id_ed25519.pub | \
openssl pkey -pubin -outform DER 2>/dev/null | \
tail -c 32 | xxd -p -c 64
# fingerprintSha256 — SHA-256 over the same 32-byte raw public key:
ssh-keygen -e -m PKCS8 -f ~/.ssh/id_ed25519.pub | \
openssl pkey -pubin -outform DER 2>/dev/null | \
tail -c 32 | sha256sum | awk '{print $1}'
Use the printed hex as the publicKey and fingerprintSha256 strings.
The proposal explicitly avoids auto-ingesting ~/.ssh/*.pub from the
Makefile. Manual conversion gives the operator control over which keys
are trusted by the boot manifest.
Add a non-operator principal
The single-account-multi-auth invariant fixes the host operator at
kind: "operator"; slice 2 rejects manifests with multiple operator
seeds. Additional principals must use kind: "guest" or
kind: "service":
package capos
#Manifest: extraSeedAccounts: [{
name: "kiosk-guest"
displayName: "Kiosk Guest"
principalId: "kiosk-guest-principal-32-bytes-x" // exactly 32 bytes
kind: "guest"
credentialRefs: []
resourceProfile: "operator-default"
}]
Each seed account’s principalId must be unique and exactly 32 bytes;
each must reference an existing resourceProfile (either
operator-default from the defaults package or one declared in
extraResourceProfiles).
Add a custom resource profile
package capos
#Manifest: extraResourceProfiles: [{
name: "kiosk-guest-profile"
homeQuotaBytes: 0
tempQuotaBytes: 1048576
processLimit: 2
threadLimit: 4
capLimit: 24
memoryCommitLimitBytes: 16777216
frameGrantLimitPages: 64
endpointQueueLimit: 8
inFlightCallLimit: 4
pendingIpcSubmissionLimit: 8
ringScratchLimitBytes: 16384
logQuotaBytesPerWindow: 32768
networkProfile: "none"
cpuBudgetUsPerWindow: 10000
cpuWindowUs: 100000
timerWaiterLimit: 2
launcherProfile: "bootstrap-guest"
}]
Reference the profile name from extraSeedAccounts[].resourceProfile.
Host-user injection (@tag(user))
make run runs cue export with --inject user=$(USER), so the
operator session shows the host login as the displayName. Other Make
targets leave the tag unset, keeping the canonical operator value
that smoke harnesses assert against. The audit-correlatable
principalId is fixed to the canonical 32-byte value regardless of
host user, so audit history is stable across $USER changes.
mkmanifest reads CAPOS_CUE_TAGS from the environment and forwards
each key=value pair as --inject key=value. The Makefile sets it
target-scoped to make run only:
run: CAPOS_CUE_TAGS = user=$(USER)
Set additional tags via CAPOS_CUE_TAGS=user=alice,region=eu-west make run
or by passing --tag key=value to mkmanifest directly. system.cue
only consumes user today; future tags can carry hostname, locale, or
other build-environment-derived values without adding new mechanisms.
Tools-root cache
CAPOS_TOOLS_ROOT defaults to $HOME/.capos-tools. The pinned
toolchain (capnp, cue, mdbook, typst, limine) lives under that path so
multiple capOS clones share a single download. Override with
CAPOS_TOOLS_ROOT=/path/to/cache make ... for non-default placement.
The Makefile and mkmanifest’s expected_cue_path follow the same
default; mismatched CAPOS_CUE / CAPOS_CAPNP env values are still
rejected by mkmanifest and make generated-code-check.
Schema-aware data conversion
mkmanifest cue-to-capnp converts CUE-authored data messages into arbitrary
specified Cap’n Proto struct roots without routing them through the boot
manifest ABI:
make cue-ensure capnp-ensure
CAPOS_CUE="$(make -s cue-path)" \
CAPOS_CAPNP="$(make -s capnp-path)" \
cargo run --manifest-path tools/mkmanifest/Cargo.toml --target "$(rustc -vV | awk '/^host:/ {print $2}')" -- \
cue-to-capnp --import-path schema input.cue schema/example.capnp Example output.bin
The subcommand accepts the same CUE --package, --tag, and
CAPOS_CUE_TAGS inputs as the manifest builder. It also accepts repeated
--import-path <dir> or -I<dir> arguments plus --no-standard-import, which
are passed to capnp convert as process arguments, not through a shell. The
input CUE is first exported to JSON, then the pinned Cap’n Proto tool validates
that JSON against the named schema and root struct.
This is the right path for configuration blobs, demo fixtures, or future
schema-defined records that are not SystemManifest. It still cannot encode
live capOS capability table entries or meaningful Cap’n Proto interface
objects; authority transfer remains an IPC/runtime concern.
Limits and non-goals
- A second
kind: "operator"seed account is rejected by the kernel in slice 2; multi-operator support is tracked indocs/proposals/user-identity-and-policy-proposal.md. - The slice-2 overlay is not a replacement for cloud-instance
configuration; cloud-metadata-driven manifest deltas are designed in
docs/proposals/cloud-metadata-proposal.md. - The overlay does not auto-ingest
~/.ssh/*.pub; conversion is manual by design (security review on which keys count). - Focused-proof manifests are migrating onto the defaults package in
slice 3.
system-spawn.cue,system-shell.cue,system-terminal.cue,system-telnet.cue,system-login.cue,system-login-setup.cue,system-local-users.cue, andsystem-credential.cueare already packaged;system-ssh-*.cueand the remaining variants still use their legacy single-file shape until their focused checks move with them.