Containers
The container is the agent’s whole world. It’s where MCP servers run, where shell commands
execute, where files get read and written. You define it with a Dockerfile you commit to your
repo, and outrig builds it with buildah and runs it with podman.
Default location
outrig image add writes image files
under .agents/outrig/images/<name>/:
.agents/outrig/
├── config.toml
└── images/
└── coding/
├── Dockerfile
└── (any other files referenced by the Dockerfile)
Putting them under .agents/outrig/ keeps outrig-specific build context separate from your
project’s own Dockerfiles (which often live at the repo root or under containers/,
docker/, etc. for unrelated purposes). You can override the default by editing
[images.<name>].dockerfile and .context to point anywhere relative to the repo root – or
absolutely anywhere, with an absolute path.
Dockerfile conventions
outrig expects a few things from your Dockerfile.
Don’t set an ENTRYPOINT
The container’s job is to stay running while the agent works. Every real process – MCP servers,
shell commands the agent runs – is launched via podman exec from outrig on the host, so the
container’s own process only has to stay alive.
outrig arranges that itself: it appends sleep infinity after the image reference on the
podman run, and trailing arguments there are the container’s command. Your CMD is
overridden and does not matter. Setting one is harmless – outrig’s own templates still end
with CMD ["sleep", "infinity"], which keeps the image behaving the same way under a plain
podman run <image> – but nothing depends on it.
ENTRYPOINT is the one that matters, because podman appends trailing arguments to an
exec-form ENTRYPOINT rather than replacing it. An image that sets one therefore runs
<entrypoint> sleep infinity, which is rarely what either side intended. Leave ENTRYPOINT
unset on an image you mean to use as a primary.
The same goes for a sidecar image whose servers are podman exec’d into it – those containers
start exactly the way the primary does. An entrypoint-stdio sidecar is the opposite case:
there the image’s ENTRYPOINT is the server, outrig appends no command of its own, and args
supply its arguments. See MCP Servers for which entries are
which.
The one thing the image has to supply is a sleep that accepts infinity. GNU coreutils has
always understood it, and busybox has since 1.30 – Alpine 3.10, June 2019 – so any current
base is fine. An older busybox exits immediately with sleep: invalid number 'infinity', and
an image with no sleep at all (FROM scratch, some distroless bases) has nothing to run.
When either of these bites, the container exits moments after starting and the failure surfaces
one step later, as a bootstrap error reporting that the container has no running namespaces.
podman logs outrig-<sid> has the real cause.
Don’t set up a user in the Dockerfile
outrig handles user identity entirely at run time – see Workspace. Don’t
useradd a hard-coded UID in your Dockerfile, and don’t set a USER directive. The build is
intentionally generic so the same image works for any host user (yours, a teammate’s, CI’s)
without rebuilding.
The image needs no user tooling for this. outrig writes the matching /etc/passwd and
/etc/group entries into the container itself, from the host, so an image with no useradd,
groupadd, or getent – an unadorned FROM docker.io/library/alpine, or a distroless base –
works unchanged. What it does need is the two files to already exist and a writable /etc; a
base so minimal that it ships neither cannot be bootstrapped, and neither can one without the
sleep the section above describes.
What the image installs has to work for that user. The build runs as root, but outrig execs
nothing as root: every exec gets your UID and HOME=/home/<user> (see
What outrig sets in an exec). Install tools under a shared prefix
such as /usr/local, not into root’s home, which is 0700. A tool that keeps its state under
$HOME by default – rustup, nvm, pyenv, pip install --user – needs that state pointed at the
prefix as well, or at run time it looks under your home and finds nothing there; and whatever it
writes as it works, as cargo does its registry cache, has to be writable by you. The rust
toolchain outrig image add generates does all of this.
Download for the image’s architecture
outrig names no platform when it builds or pulls an image, so the image takes the architecture
of the machine its engine runs on – x86-64 or AArch64 – and a multi-arch base, as every curated
one is, resolves to that architecture. A tool you download as a prebuilt binary has to follow it
too: choose the archive at build time, from uname -m, rather than from a URL that names
amd64. An archive for the other architecture unpacks all the same, so the build succeeds, and
the failure waits for the first run of the binary: exec format error. The go toolchain
outrig image add generates chooses its archive this way,
and fails the build on any other architecture.
Install MCP servers
The image needs to contain the binaries and dependencies for every MCP server you reference in
the matching [images.<name>.mcp] config. There’s no other place to put them – outrig
doesn’t fetch tools at run time.
RUN npm install -g @modelcontextprotocol/server-filesystem
If multiple MCP servers need different language toolchains (one needs Node, one needs Python), install both in the same image. The agent’s whole MCP set runs in one container per session.
Create the directory a VOLUME names
outrig runs every container with --image-volume=ignore, so a VOLUME in the image creates no
volume. The path holds whatever the image’s layers put there, and what the container writes
there lands in its own layer and is removed with it, like any other write. Left to its default,
podman would mount an anonymous volume there, and does not always remove it with the container.
VOLUME does not create the directory, so a path the layers never create is not there at all,
where podman’s default would have mounted an empty one. If the image’s program expects the
directory and does not make it, the image has to: RUN mkdir -p <path> ahead of the VOLUME.
The [images.<name>] config block
A typical config has at least one [images.<name>] block plus a top-level
default-image:
default-image = "coding"
[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile" # relative to repo root
context = ".agents/outrig/images/coding" # relative to repo root
build-args = { NODE_VERSION = "20" } # extra Dockerfile ARGs
[images.coding.mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
An image-config can come from three places, lowest precedence first: outrig’s built-in default, the global config, and the repo config. The built-in only appears when nothing else names an image at all, and any block you declare under one of its reserved names shadows it entirely.
dockerfile and context are relative to the directory of the config file that declared the
block. For this repo config that is the repo root (the directory containing .agents/outrig/).
Declare the same block in ~/.outrig/config.toml and the paths are relative to ~/.outrig/
instead, so a build-from-Dockerfile image can live in your global config and be used from every
repo on the machine. See
Config -> path resolution.
build-args are extra Dockerfile ARGs – whatever your Dockerfile needs parameterized at
build time.
The [images.<name>.mcp] map is covered in MCP Servers.
Capability profiles
By default, outrig preserves podman’s default Linux capability set. That keeps existing
toolchains and MCP servers working while still applying --security-opt=no-new-privileges,
which stays on unless an image-config turns it off (see
Devices and privilege escalation). When a container can
run with less privilege, add a security block:
[images.coding.security]
capability-profile = "no-net-raw"
The supported profiles are:
default: keep podman’s default capability set.no-net-raw: dropNET_RAW, which blocks raw sockets without breaking most development tooling.drop-all: start from--cap-drop=ALL.
You can combine a profile with explicit overrides:
[images.web.security]
capability-profile = "drop-all"
cap-add = ["NET_BIND_SERVICE"]
Explicit cap-add values are rendered last, so a container can start from drop-all and add
back one narrow capability. Capability names may include or omit the CAP_ prefix.
NET_ADMIN (or ALL) in cap-add does not combine with network interception: under
mode = "audit" or "filter" the container is refused, since it could delete the table that
intercepts it. See [network].
Devices and privilege escalation
Three further keys in the same block cover what capabilities cannot express. A device node is
not a capability, a masked path is not a capability, and no_new_privs is a separate process
flag, so each gets its own key:
[images.coding.security]
no-new-privileges = false # default true
devices = ["/dev/fuse"] # default []
unmask = ["/proc/*"] # default []
no-new-privileges = false weakens the container boundary. Under no_new_privs the
kernel ignores the setuid bit and file capabilities on every execve; clearing the flag puts
them back, so a process that finds a setuid-root binary in the image can use it to become
root inside the container. Treat this as a deliberate tradeoff rather than a neutral knob.
Two things bound the damage. The key is opt-in per image-config and defaults to true, so a config that never mentions it keeps today’s protection. And the container is still an unprivileged rootless podman container in a user namespace – clearing the flag grants the container’s own namespace-local root, not root on the host.
devices passes host device nodes through, one --device=<entry> per entry, in declaration
order. An entry is <host-path>[:<container-path>][:<permissions>]; the permissions (r, w,
m) are not enforced by rootless podman. This is the sharpest of the three in the general
case: /dev/kvm or a raw block device hands out real hardware access. It is explicit per path
and per image-config, and outrig does not police which paths you may ask for.
unmask excludes paths from podman’s default masking, one --security-opt=unmask=<path> per
entry. Entries are absolute paths, globs allowed, or the literal ALL – and they reach podman
as written, so an image that asks for /proc/* is never widened into ALL. ALL is fussier
than it looks: podman lifts the read-only paths (making /sys/fs/cgroup writable) only for
that exact uppercase spelling, and only when it comes first, so outrig requires it to be the
list’s sole entry rather than accept a spelling that half-works. This turns a
hardening flag off. Podman hides /proc/acpi, /proc/scsi, /proc/keys, /proc/kcore, and
the rest of its masked set behind read-only tmpfs mounts and /dev/null binds; unmasking
/proc/* puts all of them back. Inside a rootless container in a user namespace these are
read-only to an unprivileged user and mostly uninteresting, but that is a reason the exposure
is usually tolerable, not a reason it is absent.
Nested container runtimes
The motivating case for all three is a nested container runtime – an agent whose job is to build an image or run a throwaway container. The measured recipe, against podman 5.7 on Linux 7.0:
[images.coding.security]
cap-add = ["SYS_ADMIN"]
devices = ["/dev/fuse", "/dev/net/tun"]
unmask = ["/proc/*"]
Each line is load-bearing, and removing any one of them breaks the inner podman run:
unmask = ["/proc/*"]is what the kernel’s “fully visible” procfs rule demands. A process in a non-initial user namespace may mount a freshprocfsonly while the/procalready in its mount namespace is unobstructed, and podman’s masking mounts are an obstruction. They are created by a more privileged namespace and locked, so the container cannot unmount them itself, whatever capabilities it holds. Without this the inner create fails withcrun: mount `proc` to `proc`: Operation not permitted.cap-add = ["SYS_ADMIN"]because the capabilities available in the inner user namespace are bounded by the outer set. Without it:crun: sethostname: Operation not permitted./dev/fusebecause the session container’s rootfs is overlayfs and the kernel refuses overlay-on-overlay, so the nested runtime needsfuse-overlayfs./dev/net/tunbecausepasta, podman 5’s default rootless network backend, opens it to build its tap device. A container passing through only/dev/fusegets as far as pulling the inner image and then fails to configure its network.
no-new-privileges is not in that list, and does not need to be. The usual claim is that a
nested rootless podman calls setuid-root newuidmap to map its subordinate UID range, so
no_new_privs must be cleared. Under --userns=keep-id that never happens. The primary’s user
namespace is created by the host user, so its owner is inside-UID 1000 rather than 0; the
kernel grants capabilities in a user namespace only to a process whose effective UID is that
owner, so execveing newuidmap to euid 0 gains nothing and fails anyway. With no
/etc/subuid entry, podman instead takes its rootless single-mapping path, which creates the
namespace with a plain unshare – making the caller its owner – and never invokes
newuidmap at all. That is the path the recipe above runs on, with
--security-opt=no-new-privileges still applied.
The corollary is a trap worth naming: adding /etc/subuid and /etc/subgid entries to the
image breaks nesting, because podman then takes the newuidmap path and hits the failure
above. Leave them out.
outrig supplies the primitives; assembling them into a working nested runtime is yours to do.
Your Dockerfile still has to install podman, fuse-overlayfs, and passt, select the
overlay storage driver with mount_program = "/usr/bin/fuse-overlayfs" and
ignore_chown_errors = "true", and set default_sysctls = [] in containers.conf.
Using a pre-built image
If you already have an image (from a registry, CI pipeline, or local build), set
image-name instead of dockerfile + context:
[images.scratch]
image-name = "docker.io/library/ubuntu:24.04"
[images.scratch.mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }
Exactly one of these two shapes must be set on each block:
dockerfile+context(with optionalbuild-args) – build path.image-name– use-existing-image path.
Setting both, neither, or image-name alongside build-args is a config-validation error.
outrig build --image scratch pulls the image if not already local:
$ outrig build --image scratch
[outrig] image-config: scratch
[outrig] image: docker.io/library/ubuntu:24.04
[outrig] image ready: docker.io/library/ubuntu:24.04
On subsequent runs when the image is already present:
$ outrig build --image scratch
[outrig] image ready (already pulled: docker.io/library/ubuntu:24.04)
outrig run --image scratch starts the container directly – no buildah invocation.
A stock distribution image works here precisely because outrig supplies the command itself: it
needs no CMD, and ubuntu, debian, fedora, and alpine all set no ENTRYPOINT and bake
in no user. What it does not have is any MCP server, and outrig installs nothing at run time,
so an off-the-shelf base is useful only with servers placed in sidecars – see
MCP Servers -> Sidecar placement. That is exactly how
outrig’s own built-in default is
put together.
Named image-configs
You can declare multiple image-configs for the same repo and switch between them with
--image:
default-image = "coding"
[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context = ".agents/outrig/images/coding"
[images.coding.mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
[images.planning]
dockerfile = ".agents/outrig/images/planning/Dockerfile"
context = ".agents/outrig/images/planning"
[images.planning.mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }
research = { command = ["mcp-research-tools"] }
$ outrig run # uses default-image = "coding"
$ outrig run --image planning # different Dockerfile, different MCPs
This is useful when you want lighter-weight environments for different kinds of work – e.g. a
planning image that has no compiler, no shell, and only research-oriented MCPs; a coding
image with the full toolchain. Agents can also pin their own default image via
agents.<name>.image; see
Providers, Models, and Agents.
Every image-config is built and cached independently. Switching between them is fast after the first build.
Image caching
outrig tags built images as <image-config-name>:<hash>, where the name is the [images.<name>]
block key and the hash is a content-addressed cache key combining the contents of the
Dockerfile, the build-args, the OutRig labels derived from [images.<name>.mcp], and the
build context. When the context is in a git repo, that means every file in it that .gitignore
does not exclude, committed or not, by path, permission bits, and content, and every directory it
does not exclude, empty ones included, by path and permission bits. If .gitignore matches the
context directory, or every file in it, committed or not, then every file counts: the rules say
nothing about which of them the build uses. Outside a git repo, it is a tarball hash of the whole
directory. So [images.outrig-standard] builds to outrig-standard:<hash>, which
podman shows as localhost/outrig-standard.
Repo-local build images carry an org.outrig.mcp label too. On a cache miss, outrig builds a
temporary image, reads any inherited/Dockerfile MCP label, overlays [images.<name>.mcp], and
commits the final cache tag with the merged label. This keeps outrig image inspect <image-config-name>:<hash> aligned with the declared servers that startup will use.
Because the name is the image’s repository, give image-configs repo-specific, lowercase names
(e.g. outrig-standard, not standard) so podman images makes clear which repo an image came
from. Build-image names must be valid container image repository components – lowercase
alphanumeric separated by ., _, or - – and outrig rejects invalid names at config load.
A change to the Dockerfile, any file in the context, build args, or [images.<name>.mcp]
causes a rebuild on the next outrig run or outrig build. Otherwise the cache hit is
immediate. Either command shows the build as it runs, as [buildah] lines on stderr ([podman]
lines for a pull), so a rebuild is visible rather than a pause before image ready. To force a
rebuild without changing files, run outrig build --no-cache. In a git repo, unless
.gitignore excludes the context whole, a file it excludes does not count when the Dockerfile
copies it by its own path, so run outrig build --no-cache after changing one. A symlink counts
by what it points at, ignored or not, since copying the link copies that.
Image-name configs use podman’s local image store directly; there is no <name>:<hash>
tag in that path. --no-cache on an image-name config re-runs podman pull even when the
image is already present locally. (The library Outrig::launch API, which builds from a raw
Dockerfile with no image-config name, falls back to the outrig-cache:<hash> repository.)
Sidecar containers
A session can own more than one container. Sidecars are extra podman containers declared under
the top-level [sidecars.<sc>] (or implied by an inline image key on an MCP entry) that host
MCP servers away from the workspace container; see
MCP Servers -> Sidecar placement for the config surface.
Blocks are declared once and shared by any number of image-configs. A session starts the ones
its image-config’s [mcp] entries name, so a block nothing references costs nothing.
[sidecars.tools]
image = "mcp-tools" # sibling [images.mcp-tools] block first, else raw podman ref
workspace = "ro" # "none" (default) | "ro" | "rw"
start = "auto" # "auto" (default) | "manual"
on-failure = "abort" # "abort" (default) | "warn"
[[sidecars.tools.mounts]]
host-path = "~/.cache/example" # ~ is your home directory
container-path = "/cache"
access = "read-write" # "read-only" (default) | "read-write"
The image key resolves exactly like --image: an [images.<name>] config name first
(Dockerfile-built sidecars get content-hash caching for free), then a raw podman ref, which
must be present locally – outrig never pulls one. A ref that is not local is refused with
image "<ref>" (from [sidecars.<sc>]) is not an [images.<name>] ... and the podman pull <ref>
to run first; an inline image on an MCP entry reads (from the inline image of MCP server "<name>"). An optional [sidecars.<sc>.security] block reuses the
whole security surface of the primary – capability keys, no-new-privileges, devices, and
unmask alike – and each sidecar’s block stands on its own, so opting one out of
no-new-privileges leaves the others hardened. start = "manual" declares a sidecar that does
not start with the session (a later release adds the surfaces that start one mid-session; until
then its servers are skipped with a notice).
Naming and labels. Sidecar containers are named outrig-<sid>-<sc>; an anonymous sidecar
uses its server’s name as <sc>. Every session container – primary included – carries the
podman label org.outrig.session=<session-id>, and sidecars additionally carry
org.outrig.sidecar=<sc>. The session record lists sidecar container names next to
container_name.
(A library caller declares the same containers with SidecarSpec, which mirrors this block
minus start and on-failure – a spec starts when Outrig::add_sidecar is called, and
launch-time sidecars are abort-only. Naming, labels, and lifecycle coupling are identical,
so outrig clean sweeps a library session’s strays the same way.)
Lifecycle coupling is entirely outrig-managed (no pods, no --requires): sidecars start
after the primary and stop before it, and the same four cleanup layers – explicit stop, the
name guard, Drop, and the panic sweep – cover every container. The panic sweep acts only on a
panic that ends outrig, never on one the session carries on from, such as a subagent round or an
MCP connection whose task panicked. In addition, sessions with sidecars run a podman events
watcher on the primary: if the primary dies out from under outrig (manual podman kill, OOM),
the watcher reaps all sidecars and ends the session with an error. It reaps by
org.outrig.instance, a per-container label whose value is unique to the session, rather than
by container name – a sidecar dies at the same moment the primary does and --rm frees its
name on the spot, so a removal that resolved a name a moment later could reach whatever had
taken it.
A stray that survives even that (say, a SIGKILLed outrig) is caught by outrig clean, which
sweeps stopped, record-less containers carrying org.outrig.session; see
Sessions -> outrig clean.
The start guard is the layer that covers cancellation. Between the moment a container name
is reserved and the moment outrig holds a handle for the container, there is no handle for
Drop to run on – so a start that is abandoned partway (a timeout, a cancelled task, an
error) used to leave both the podman client and, potentially, the container itself. The guard
owns that window instead: it is armed before the first podman command and removes the container
unless a handle takes over. The same shape covers an image build’s temporary tag and the
outrig-label-<pid>-<nonce> working container the label-stamping pass creates, neither of
which any sweeper knows the name of. Both are outrig’s own: it chose those names, and no
other build can share them.
It removes by a per-attempt label, not by the name it asked for. --name can fail because
the name is already in use – another session, a stray, a container you made yourself – and
that is an ordinary outcome, not a reason to delete whatever holds the name. So each attempt
stamps a fresh org.outrig.attempt value on the container it asks for and removes by that: a
create that collided made nothing carrying it, and a cleanup still in flight cannot reach a
container you have since started under the same name. Labels are part of the creation request,
so there is no instant in which the container exists without the mark that identifies it.
Every layer uses that selector, not just the start guard. The panic sweep used to hold
container names and remove by them, so a panic arriving while a start had collided would
delete the container that already held the name – one outrig never created. It now replays
each outstanding attempt’s label-scoped removal instead, and it tracks those attempts
separately, so two starts asking for one name are two obligations and neither can cancel the
other’s. No layer that covers a container for the lifetime of the session that made it – stop,
start guard, Drop, panic sweep, sidecar reap – addresses that container by name any more.
outrig clean still does, and necessarily: a stray belongs to a process that is gone, so the
per-attempt label it was started with died with the outrig that chose it.
A cancelled build is asked to stop rather than killed, and it is the only command that is.
buildah creates a working container per stage, and those are not outrig’s to name: buildah
derives the name from the base image, offers no way to label one, and
buildah containers --filter selects only on id, name, and ancestor. So there is nothing to
arm a guard against – a removal scoped to <base>-working-container would be a removal by a
string two concurrent builds from one base both answer to.
The stop is what resolves that. buildah installs a signal handler while a RUN instruction’s
command is executing; a SIGTERM there fails the step, and the build unwinds through its own
stage cleanup, removing each working container by the id only it holds. Attribution becomes
buildah’s, which is the one place perfect knowledge of it exists. outrig waits a bounded grace
and escalates to SIGKILL if the stop is refused, so a wedged build is still terminated.
Three limits, stated rather than implied. Outside a RUN – during a pull, a COPY, the
commit, or the seam between two RUNs – no handler is registered and buildah ends where it
stands. The grace is only worth something while outrig’s own runtime is alive; a process on
its way out escalates immediately. And a stopped child has to be able to write: outrig holds
the read ends of its pipes open until the reap, because a Go program that gets EPIPE on
stdout or stderr dies of SIGPIPE on the spot – which was exactly the abrupt ending the stop
was trying to avoid. What the first two limits leave behind is collected by
outrig clean --build-containers; see
Sessions -> outrig clean.
Abandoning a call also terminates the podman client it was waiting on. The signal is sent
synchronously, as the call is abandoned; the process is reaped shortly after, without the caller
doing anything further. Note what that does not mean: killing a podman exec client does
not stop the command running inside the container, which conmon supervises in the container’s
own namespaces. Removing the container is what stops that, which is why the guard exists rather
than just a kill.
Session [network] policy applies to every container: the network interceptor attaches to each
sidecar the same way it attaches to the primary, before any MCP server connects.
What outrig sets in the run
outrig adds --userns=keep-id, the primary workspace bind-mount, any configured extra
workspace mounts, and the runtime user-mapping bootstrap (see Workspace). It
also supplies the container’s command: sleep infinity, appended after the image reference,
which overrides the image’s CMD (see Don’t set an ENTRYPOINT).
The bootstrap runs from the host: a forked child joins the container’s user namespace, becomes
its root, joins its mount namespace, and appends the missing /etc/passwd and /etc/group
entries before creating /home/<user>. No podman exec is involved. When podman’s keep-id
mapping already planted the entries, nothing is appended, but the planted user entry’s home –
the container’s working directory – is rewritten to /home/<user>. A
view = "primary" sidecar is the one exception to keep-id: it runs --userns=container:<primary>
to join the primary’s user namespace, plus --cap-add=SYS_ADMIN/SYS_PTRACE, the primary’s
/proc/<pid>/ns directory, and the outrig-enter launcher as its --entrypoint (see
MCP Servers). It keeps its own PID namespace, so the
launcher mounts a fresh proc over the primary’s before the exec – /proc/self in the
primary’s procfs would name nothing. The launcher’s argv carries
--uid/--gid with the session user’s ids: it holds those capabilities only until the graft is
in place, then becomes that user for the exec, so the server runs unprivileged even though the
container was created privileged.
--security-opt=no-new-privileges goes on too unless the selected image-config sets
no-new-privileges = false. Capability flags are emitted only when that image-config opts
into a capability profile or explicit cap-drop / cap-add entries, --device=<entry> flags
only when it declares devices, and --security-opt=unmask=<path> flags only when it declares
unmask. Session containers additionally carry the org.outrig.session label (and sidecars
org.outrig.sidecar). Every container gets --image-volume=ignore, so its image’s VOLUMEs
create no volumes; see
Create the directory a VOLUME names. Every container
also gets --http-proxy=false, so podman copies none of the host’s proxy variables into it; see
The container’s environment.
outrig does not configure seccomp profiles, AppArmor policy, SELinux policy, read-only root filesystems, or network egress policy in this container launch path. Network audit/filter mode is a separate session-level interceptor; see Workspace.
What outrig sets in an exec
Most real processes – an exec-hosted MCP server, a command a library caller runs – reach the
container through podman exec, and that invocation is assembled separately from the run. For
those, outrig always passes -i so stdin stays open for a stdio-speaking server,
--user=<uid>:<gid> with the session’s ids, and --env HOME=/home/<user> naming the home
directory the runtime user bootstrap created. Each entry a caller supplies becomes one further
--env K=V, emitted in sorted key order so the same inputs always produce the same command line.
An entrypoint-stdio server is the exception, and gets none of that. It is the image’s own
ENTRYPOINT, started by podman create plus podman start --attach --interactive rather than
exec’d, so there is no --user and no HOME from this path – it runs as whatever user its
image expects, and its environment has to be baked in at create time. Such a container also
skips the runtime user bootstrap unless something else about it needs identity (a workspace, a
mount, or a co-hosted exec server). Do not assume the mapped ids for one. The single exception
to the exception is a view = "primary" sidecar, whose launcher is handed the session’s ids
explicitly and drops to them before the payload runs.
Its stderr normally reaches <session_dir>/logs/<server>.stderr through podman start --attach,
which can drop all of it for a server that exits within milliseconds of starting. So that a server
that fails at once still says why, outrig creates such a container with --log-driver=json-file,
an empty --log-opt=tag=, a --log-opt=path= naming a private file of its own (under
$XDG_RUNTIME_DIR, else the temporary directory), and --log-opt=max-size=1mb. When a start fails
and the stderr file is empty, outrig copies the container’s stderr from that file into it. The log
replaces the host’s default driver for this container – journald on most systemd hosts –
though podman logs still works while it runs, and it goes away with the session. The driver and
the empty tag keep the host’s log_tag from containers.conf off the container, since conmon
refuses a tag with a file log. json-file is podman’s alias of k8s-file, and podman before 6
gives the host’s tag to every driver but that one. The empty tag counts as one already given on
podman 5.5.1 and later, which matters on podman 6, where json-file gets the host’s tag too and a
tag on any driver but journald is an error. Where SELinux is enforcing, or the log’s path holds
a ,, " or :, outrig makes no log and the container logs as the host says.
The working directory is the one exec flag that is conditional. Supply one and outrig emits
--workdir <path>; leave it unset and no flag is emitted at all, so the exec lands wherever the
container already is. That is the image’s WORKDIR only when nothing overrode it – a
workspace-backed session sets -w to the workspace’s container path on the run, so an unset
exec runs in the workspace, on your mounted checkout. Set the directory explicitly when a
relative or destructive command must not land there. The path is the container’s, not the
host’s, and outrig does not check that it exists before running: podman reports a missing
directory itself, naming the path, and probing for it in advance would cost an extra exec on
every call. That failure arrives the way any failing command does – a non-zero exit status with
the message on stderr – rather than as a distinct kind of error.
Setting the directory this way is what lets the argv form stay usable on an image with no shell.
The alternative, wrapping the command in sh -c 'cd ... && ...', needs a shell in the image and
correct quoting from the caller; setting PWD instead changes the variable without moving the
process, so anything calling getcwd never notices. A library caller sets it with
ExecOptions::with_workdir; see the crate docs for Outrig::exec_stdio and
Outrig::exec_capture.
The container’s environment
A container’s environment is its image’s ENV, the container and HOSTNAME podman sets in
any container, the HOME outrig sets, and the env entries the config and outrig run --env
give its MCP servers. Nothing is copied from the host’s own environment unless your
containers.conf sets env_host and asks podman to copy all of it. That includes the
proxy variables podman otherwise copies from the environment it runs in – http_proxy,
https_proxy, ftp_proxy, no_proxy, and their uppercase forms – because a proxy URL can
carry a user:password@ that everything the agent runs could then read. outrig passes
--http-proxy=false to every container it starts.
Behind a proxy, name it on the servers that need it. A ${VAR} reference takes the value from
your environment when the server starts:
[images.coding.mcp.shell]
command = ["bash", "-lc", "exec shell-mcp-command"]
env = { HTTPS_PROXY = "${HTTPS_PROXY}", NO_PROXY = "${NO_PROXY}" }
A repo config may reference a host variable only if the global config lists it, so for the
config above ~/.outrig/config.toml needs the two names under
[repo]:
[repo]
env-allow = ["HTTPS_PROXY", "NO_PROXY"]
outrig run --env 'HTTPS_PROXY=${HTTPS_PROXY}' does the same for every server in one session,
single-quoted so your shell leaves the reference for outrig, and needs no entry: you typed it.
See the value syntax for how each form reaches
podman.
A proxy named this way is the agent’s to read, credentials included: every process the server
starts inherits it. Under a filter network policy, traffic
sent through the proxy reaches the interceptor as a connection to the proxy, so the policy rules
on the proxy’s host and port rather than on what lies behind it – allowing the proxy allows
whatever it will reach. A Dockerfile ENV works for a proxy that needs no password, but it is
baked into the image, so keep credentials out of it.
See also
- outrig image add – the easiest way to scaffold a new image-config.
- MCP Servers – declaring and invoking the tools that run inside the container.
- MCP Trust Model – why MCP tools can be configured liberally inside the container boundary.
- AI-assisted design – use
outrig mcp selfwhen the templates do not fit. - Workspace – what the container sees of your repo.
- Reference -> Config – every supported
[images.<name>]key.