ADR-0041: Containerised daemon — single-binary build, distribution image, Kubernetes operation
- Status: Accepted
- Date: 2026-06-14
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
- Depends on: ADR-0040 — the no-embedded-UI binary requires the UI to be independently hostable (
ui.kaged.dev+ the daemon registry). - Relates to: ADR-0004 (Bun runtime;
bun build --compilealready blessed), ADR-0009 (bwrap), ADR-0008 (plugin tiers), the@kaged/nativesdistribution gap (STATUS infra table).
Context
Two goals drive this, and they are not the same goal:
- IP protection during closed-source testing. Core source is not released until v2 (operator's call). Shipping a binary instead of source prevents casual theft by testers. The public plugin contracts (
@kaged/plugin-types, the DSL,@kaged/sso-fixtures, the wire contract) are already public and are explicitly out of the protected set. - Zero-dependency containerised distribution + in-cluster operation. A tester should run the daemon with nothing installed but Docker. The operator should be able to run a daemon in a pod, in the same cluster as a project's running environment, develop the dev version in-pod, and push to staging/prod within that cluster. This foreshadows a Kubernetes plugin that extends daemon hooks/agent capabilities (cluster deploy/rollout/exec) — that plugin is out of scope here, but the image and pod identity must not preclude it.
The two goals reinforce the no-embedded-UI decision from ADR-0040: in the split topology a tester runs the container, visits the daemon URL, is redirected to ui.kaged.dev/connect, registers the daemon, and authenticates — zero source, zero dependencies but Docker.
Constraints that shape the build:
- ESM-only codebase (
"type": "module",verbatimModuleSyntax,.tsextensions).bun build --compilehandles ESM natively. We pin a Bun floor of ≥ 1.3.9. @kaged/nativesis a ~93 MB per-triple NAPI cdylib. Onlylinux-x64-gnuhas been built;linux-arm64-gnuis needed for the homelab (Pi) and likely the cluster. The project rule is natives are never committed to git — built in CI, distributed viaoptionalDependencies.- Project plugins are bwrap-sandboxed subprocesses, frequently spawned as
bun ./dist/index.js; system plugins are operator TypeScript dynamically imported at daemon startup. Both must keep working from a compiled daemon. - PTYs/terminals use a tmux driver; tmux is a runtime dependency in the image.
- bwrap inside a container/pod requires unprivileged user namespaces or elevated capabilities (CAP_SYS_ADMIN / privileged), which many clusters restrict. The sandbox's
CgroupsWrapperalready detects and degrades (systemd-run / direct cgroup v2 / degraded-none). - Multi-arch:
bun build --compile --targetis per-target and the natives.nodeis per-triple, so the image must be built per-arch (amd64 + arm64).
Decision
The daemon is distributed as a multi-arch OCI image containing a minified, source-stripped
bun build --compilebinary plus the matching-arch@kaged/natives.node, with no embedded UI. IP protection is explicitly obfuscation-grade —--compile --minify, no sourcemap — because Bun states bytecode does not obscure source. The only real hardening lever (moving sensitive core behind the Rust/NAPI boundary) is acknowledged and deferred.
1. Build
Per-arch:
bun build packages/daemon/src/main.ts \
--compile --minify \
--target=bun-linux-x64 # and bun-linux-arm64
--outfile kaged-daemon
# NO --sourcemap
--minifymangles identifiers and strips comments/whitespace (the cheap obfuscation win).--sourcemapis forbidden in release builds — it embeds the zstd-compressed original source and auto-resolves it on error.- Pin a Bun floor of ≥ 1.3.9 in the build environment.
2. IP-protection posture (stated honestly)
| Tier | What it is | What it protects | Friction |
|---|---|---|---|
| compile + minify | bundled, mangled JS inside the executable; no .ts on disk |
casual extraction, drive-by source theft | one flag |
| + bytecode | JSC bytecode for faster start | nothing additional — Bun docs: does not obscure source | one flag |
| native boundary | sensitive core logic moved into @kaged/natives (Rust → cdylib) |
genuine reverse-engineering resistance | high |
We adopt compile + minify. --bytecode is omitted — it is unstable in cross-compilation and provides no IP protection (Bun docs: does not obscure source). If cold-start benchmarks justify it in the future, it can be re-added. This is proportionate to "prevent casual theft during testing." A determined party can still recover readable-ish logic; string literals (prompts, route paths, messages) remain visible. If the threat model hardens before v2, the native boundary is the lever — that is a separate decision, not this one.
3. Image contents
A multi-stage build producing a slim runtime image:
- Builder stage:
bun install(resolves the right-triple@kaged/nativesviaoptionalDependencies),bun build --compile --minify --target=bun-linux-<arch>, and obtain the matching-arch.node. - Runtime stage (slim Debian or equivalent — not distroless, because plugins need a shell + bun):
- the compiled
kaged-daemonbinary, - the matching-arch
@kaged/natives.node(copied beside the binary — see §4), - the Bun runtime on
PATH(project plugins and agent code-exec expectbun; this does not expose daemon source, which lives inside the compiled binary), tmux,git,bwrap,bash,ca-certificates,- no UI assets (served from
ui.kaged.dev), - a non-root user;
/data(sqlite + avatars) and/configas volumes.
- the compiled
- Tagged
ghcr.io/kaged-dev/kaged-daemonper the repo image-naming convention.
4. Natives loader adaptation
bun build --compile bundles the JS module graph into a virtual FS (/$bunfs/...). The @kaged/natives loader currently resolves the .node relative to its module location, which inside a compiled binary points at the virtual FS — where the .node is not. The loader must resolve the .node from a runtime-real path: an explicit KAGED_NATIVES_PATH env override, falling back to a path derived from process.execPath (beside the executable). This is robust across Bun versions regardless of whether --compile learns to embed/extract .node assets, and it keeps the "natives never in git" rule intact — the .node is built in CI and copied into the image, never committed.
5. Multi-arch
docker buildx building both linux/amd64 and linux/arm64, each compiling its own binary and bundling its own-triple .node, published as a manifest list. This forces the long-pending linux-arm64-gnu natives build (STATUS #13 / the reported #1 install blocker) — closing that gap is a deliverable of this ADR, not a precondition.
6. Runtime configuration
Env-driven, no source needed:
KAGED_BIND,KAGED_INSECURE,KAGED_NO_SANDBOX,KAGED_HOME, config volume (KAGED_CONFIG), data volume (sqlite URL).KAGED_UI_URL+KAGED_PUBLIC_URL(from ADR-0040) wire the bootstrap redirect to the hosted UI./healthzand/readyzare the K8s liveness/readiness probe targets (thereadyzgate already gatessetReady(true)).
7. Sandboxing inside a container/pod
bwrap in a pod needs userns or privilege that clusters often forbid. The posture:
- Detect and degrade explicitly. Extend the existing cgroups degrade detection to bwrap/userns availability at startup. If bwrap cannot establish isolation, the daemon does not silently run subagents uncaged.
- Pod-as-sandbox for the in-cluster dev case. Running with
cagedegraded (or net-only) inside a pod is acceptable when the pod itself is the blast-radius boundary — which is the in-cluster dev model. Full bwrap isolation requires a privileged/userns-enabled pod and is documented as cluster-dependent opt-in. - ⛔ invariant: the daemon never silently falls to uncaged execution. A degraded sandbox is surfaced in the startup banner, the warnings list (
getMedaemon.warnings), and an audit event; it requires the same explicit posture as--no-sandbox.
8. Kubernetes operation
Shipped as an example, not a hard contract: a Deployment (single replica — the daemon is stateful/long-lived), a Service, a PersistentVolumeClaim for /data, a ConfigMap/Secret for config + provider keys, liveness/readiness probes on /healthz//readyz, and a ServiceAccount (initially unused; reserved for the future Kubernetes plugin's in-cluster API access). The no-embedded-UI decision fits cleanly: the in-cluster daemon serves only API + bootstrap redirect; the operator's browser loads ui.kaged.dev and connects via the registry.
9. CLI surface for the host wrapper
Per ADR-0042 §5, the kaged host wrapper delegates to the containerised daemon for commands the wrapper cannot answer locally. The daemon binary exposes a small CLI surface for this:
kaged-daemon auth launch-url— prints the current connect/launch URL to stdout (consumed by the wrapper'skaged auth launchto open a browser).
Implementation — phased
Phase 0 — Build pipeline + natives resolution
-
bun build --compile --minify --target=bun-linux-<arch>, no sourcemap; Bun floor ≥ 1.3.9 asserted. - Natives loader resolves the
.nodeviaKAGED_NATIVES_PATH→process.execPath-relative fallback. - Smoke test: compiled binary boots, loads natives (all 14 exports), spawns a subprocess, and runs bwrap-availability detection.
- ⛔ invariant: release builds contain no embedded sourcemap (asserting check on the artifact).
Phase 1 — Dockerfile (amd64 first)
- Multi-stage Dockerfile; runtime stage with binary +
.node+ bun + tmux/git/bwrap/bash/ca-certs; non-root user;/data+/configvolumes; expose port; entrypoint runskaged-daemon start. -
docker runwithKAGED_UI_URL/KAGED_PUBLIC_URLreaches the bootstrap redirect end-to-end againstui.kaged.dev.
Phase 2 — Multi-arch + arm64 natives
-
buildxmanifest list forlinux/amd64+linux/arm64. -
linux-arm64-gnunatives build wired into CI (closes STATUS #13). - ⛔ invariant: each arch image carries only its own-triple
.node.
Phase 3 — Sandbox degrade detection
- Startup detection of bwrap/userns availability; explicit degrade with banner +
daemon.warnings+ audit event. - ⛔ invariant: no silent uncaged execution (asserting test: bwrap unavailable ⇒ either explicit-degrade posture set or refuse to spawn caged subagents).
Phase 4 — Kubernetes example
-
examples/deployment/kubernetes/— Deployment/Service/PVC/ConfigMap/Secret/ServiceAccount + probes; operator walkthrough doc.
Phase 5 — Docs + STATUS
- Spec amendments per the checklist; STATUS infra table updated (first real build/release pipeline; arm64 natives; CI/CD line).
Amendment checklist (specs)
-
docs/specs/daemon.md— container runtime knobs;/healthz+/readyzprobe contract; natives path resolution; sandbox-availability detection + degrade posture. - ADR-0009 or the sandbox spec — in-container/userns detection, degrade semantics, the no-silent-uncage invariant.
-
@kaged/nativesloader README/spec —KAGED_NATIVES_PATH+ execPath-relative resolution; reaffirm "never in git, CI-built, image-copied." -
AGENTS.md— record thebun build --compile --minifyrelease target and the natives-copy-not-commit rule for images. - New
examples/deployment/docker/andexamples/deployment/kubernetes/. -
STATUS.md— CI/CD (currently ❌), arm64 natives, image publishing.
Consequences
Easier
- Zero-dependency tester distribution (
docker run+ browser atui.kaged.dev). - The long-pending arm64 natives build gets done (forced by multi-arch).
- UI/daemon release cadence fully decoupled; the in-cluster dev loop becomes possible.
Harder / accepted costs
- IP protection is obfuscation-grade only. Stated plainly so no one mistakes the binary for a moat.
- Image is not small. The compiled binary embeds the Bun runtime (~59 MB), the image also ships Bun for plugins, plus the ~93 MB
.node— a sizeable image. Acceptable for self-hosted/cluster distribution; note it. - Bun present in the image slightly dilutes the "pure binary" intuition, but does not expose daemon source (which is compiled). Required for project plugins and agent code-exec.
- Sandboxing in-cluster is constrained (pod-as-sandbox), with full bwrap requiring privileged/userns.
- CI complexity: per-arch native builds + cross-compiled binaries + manifest lists.
Alternatives considered
Alternative A — ship a plain JS bundle, require Bun installed
Smallest image, no compile step. Rejected: leaks source (defeats goal 1) and is not zero-dependency (defeats goal 2).
Alternative B — rely on --bytecode as the IP-protection mechanism
Rejected: Bun's own docs state bytecode does not obscure source. Keeping it for startup only is fine; treating it as protection would be self-deception.
Alternative C — native-boundary rewrite of sensitive core now
Real protection. Deferred: disproportionate friction for "prevent casual theft during testing"; documented as the lever to pull if the threat model hardens before v2.
Alternative D — distroless / bun-free image
Smaller, tighter. Rejected for the default image because project plugins and agent code-exec expect a shell and bun. A slim "API-only, no-plugins, no-exec" variant could be offered later if there's demand.
Open questions
.nodeembedding vs beside-binary. We choose beside-the-binary + explicit path resolution to be Bun-version-robust. Revisit ifbun build --compilegains reliable.nodeembed/extract.- Bytecode + native interop on arm64. Bun's 1.3.9 notes flag testing compiled artifacts for native-module interop; confirm specifically on the under-tested
linux-arm64-gnutriple. - Sandbox-in-cluster posture. Is privileged/userns acceptable for the dev pod, or is pod-as-sandbox permanent? Default to pod-as-sandbox; document privileged opt-in.
- Dynamic system-plugin import from a compiled binary. System plugins are operator TypeScript imported at runtime against the public
@kaged/plugin-types; confirm dynamicimport()of an external TS module resolves correctly from the compiled daemon. - Image variants. Single fat image vs a future slim API-only variant — defer until demand.
- Bun version floor maintenance. Pinned ≥ 1.3.9 today; decide where the floor is recorded and enforced (build script assertion vs CI matrix).