Skip to content

Custom container images

mj can run a session in any container image that meets a small contract. containers/Containerfile.agent-dev is the reference image and satisfies all of it; start there if you’re building your own. CI publishes this image as ghcr.io/brokkai/mjolnir/agent-dev, multi-arch for linux/amd64 and linux/arm64: :latest tracks master, and each release is also published under its own immutable version tag.

mj starts a session’s container detached, running sh -c 'trap "" ...; exec sleep infinity' as its command (the trap makes signals sent to the container’s main process do nothing), and runs every later command — the worker upload, Git, the harness, and the ACP bridge — with exec against that running container. Your image needs a POSIX shell and a sleep binary on PATH. It does not need any particular process supervisor or init system beyond that.

If git or gh is missing, Mjolnir installs both itself the first time a session needs them, using whichever of apt-get, dnf, yum, or apk it finds, and configures gh auth git-credential as the HTTPS credential helper for github.com and gist.github.com. That auto-install needs root or passwordless sudo inside the container, plus one of those package managers.

If your image runs as a non-root user with no sudo, or uses a different package manager, bake git and gh into the image yourself. Either way, gh is what lets HTTPS Git pushes work using the GitHub token Mjolnir syncs into the session (see Container targets).

Mjolnir gives the harness its Git settings through a worker-owned configuration file, named by GIT_CONFIG_GLOBAL. That file includes the session user’s own global configuration first, so image and user settings still apply, and then points the github.com and gist.github.com credential helpers at the worker’s gh wrapper. A file is used because some harnesses drop credential-shaped variable names from the processes they start, which would deliver only part of an equivalent environment setting.

For each harness, Mjolnir first looks for an image-baked bridge binary on PATH: codex-acp, claude-agent-acp, kimi, grok, opencode, mimo, or dsh-acp. If it doesn’t find one, Codex and Claude Code install Mjolnir’s pinned packages into a leased cache using npm ci. This requires Node.js 22+ and npm on the target PATH. Codex also uses this installation when the image’s bridge version differs from Mjolnir’s pin; Claude accepts the image’s installed bridge. Selection and installation finish before the worker starts an ACP session. The worker executes the resolved bridge and provider.

Kimi Code, Grok Build, and OpenCode have no npm bridge: Mjolnir runs their official installer with curl piped to Bash instead, which needs both tools in the image. An image-baked opencode must be OpenCode 2, because each session imports its logins with opencode auth import before it starts.

MiMo Code can fall back to npx -y @mimo-ai/cli@0.1.15 acp, so an unbaked image needs Node.js 22, npm, and npx. Bake @mimo-ai/cli@0.1.15 and set MIMOCODE_DISABLE_AUTOUPDATE=1 for a stable managed runtime.

DSH speaks ACP itself, so dsh-acp is both its bridge and its agent. Without dsh-acp on PATH, a session falls back to npx -y @brokkai/dsh-acp@0.2.0, which needs Node.js 22 or newer, npm, and npx; the npm package bundles the release binaries. To bake it in, run npm install -g @brokkai/dsh-acp@0.2.0 (Node.js 22 or newer), or put the dsh-acp binary from a release archive at https://github.com/BrokkAi/dsh-acp/releases on PATH, as the reference image does. The Linux binaries need glibc 2.34 or newer, so musl images such as Alpine cannot run DSH.

Baking the bridges in, the way the reference image does, avoids that installation cost. Codex images should install the bridge version pinned by Mjolnir and set CODEX_PATH to the installed Codex executable so the bridge runs the selected provider.

Mjolnir-owned worker and bridge commands are started with the environment the worker discovered, not under a login shell of their own. Images must therefore expose required tools on their ordinary process PATH; PATH setup that exists only in a user dotfile is visible only if the image user’s own shell reads it during the worker’s startup probe. The documented !command surface always runs the command through bash -lc, so a custom image must also provide bash on PATH for that surface to work. Those commands intentionally use the session user’s shell environment.

Muse Code requires both muse-acp on PATH and the native muse executable. Set MUSE_CLI to the native binary’s absolute path. Install the pinned @brokkai/muse-acp adapter from npm, or let npx fetch it at launch, and use scripts/install-muse.py with mj-worker/assets/muse/runtime.json to install the matching checksum-verified native binary as managed workers do. Copy the manifest and script into your image build and run the installer with the manifest path and destination bin directory. It needs Python 3.11 or newer and curl.

Sessions work under /workspace. Separately, Mjolnir writes its own session relay binary and a staged, allowlisted copy of the harness profile (credentials, config, skills, and similar) under /var/lib/hel/ inside the container — for example /var/lib/hel/workers/<session-id> and /var/lib/hel/profiles/<session-id>.

New Podman session containers run as uid and gid 0:0 in the default rootless user namespace. Container uid 0 maps to the host account running Mjolnir, and Mjolnir sets HOME=/home/hel when it creates the container to keep Git and home-based tools on the reference image’s existing home path. Harness profiles remain staged under /var/lib/hel/profiles/<session>. Uploaded workers and profiles are owned by the container’s configured user before their permissions are restricted. Existing containers keep their recorded image user and HOME when resumed; Podman exec inherits both from the container configuration. Docker and Apple Container continue to run as the image’s configured user; on those engines, that user needs write access to /workspace and /var/lib/hel.

Images need chown and stat -c '%u:%g' (GNU coreutils and BusyBox both provide these). The reference image creates /workspace and /var/lib/hel before switching to its hel user; rootless Podman’s uid-0 mapping makes these directories writable to new Podman sessions without changing image-layer owners.

Nothing in the container contract requires either, but the reference image bakes both in for consistent availability. This avoids depending on runtime package installation, which may be unavailable with Docker or Apple Container’s configured image user.

Playwright’s Chromium needs a set of X11, GTK, and font shared libraries that most base images omit. The reference image installs them with npx playwright@<version> install-deps chromium, pinned to the same Playwright version tests/e2e/web/package.json uses, and pre-installs the Chromium headless shell into PLAYWRIGHT_BROWSERS_PATH=/ms-playwright so a test run needs no network. Only the shell is baked in, which is what a headless: true config launches; add npx playwright install chromium yourself if you need headed Chromium. If your image skips all of this, run npx playwright install --with-deps chromium inside the session before browser tests, which needs root or passwordless sudo.

For optional local coverage analysis, the reference image carries the llvm-tools-preview rustup component, pinned cargo-llvm-cov, and lcov so genhtml can render lcov output.

The reference image also carries perf (Debian’s linux-perf), cargo-flamegraph, samply, and heaptrack. perf depends on the host kernel and runtime policy as well as the image: it needs kernel.perf_event_paranoid set to 1 or lower on the host, or an environment that already provides the required capability. Mjolnir has no configuration key for adding container capabilities or arbitrary runtime arguments.

mj samples /sys/fs/cgroup (memory.current, memory.max, memory.swap.current, memory.swap.max, cpu.stat, cpu.max) inside the container to drive the CPU and memory numbers in the resource pane. This needs cgroup v2. If those files aren’t readable, the sampling command fails, and Mjolnir silently drops that failed sample instead of raising an error — the session keeps running normally, but the resource pane won’t show current numbers for it. Nothing about running the container or the session itself depends on cgroup v2 being present.

The container-template configuration exposes exactly seven keys: image, platform, cpus, memory, environment, pull_policy, and workspace_storage. Pull policy applies to Podman, Docker, and Apple container targets. All three participate in background image downloads and eligible moving-tag refreshes, including SSH Podman and Docker targets; launch also evaluates the configured policy. Non-default workspace storage is Podman-only; Docker and Apple targets reject it. There’s no configuration key for arbitrary extra container-runtime arguments. Mjolnir derives the rest of the run command itself — the generated container name and the dev.mj.session, dev.mj.managed, and dev.mj.instance ownership labels it uses to find and recover its own containers later — and validates that nothing can override those before starting a container.

[targets.podman]
kind = "podman"
image = "localhost/mjolnir/agent-dev:latest"
# Selects the image platform and the matching mj worker architecture.
platform = "linux/amd64"
cpus = "8"
memory = "32g"
[targets.podman.environment]
# Extra container environment variables, merged in at container start.
RUSTFLAGS = "-D warnings"

Use kind = "docker" under a [targets.docker] table for the same image contract on Docker.