Running the session service in a container
The session service — the process that owns your terminals and worktrees — normally runs directly on your computer, with the same access to your files that you have. It can instead run inside a container, where it sees only the projects you have registered and nothing else.
This is off by default. Turning it on changes nothing about how sessions behave; what changes is what a session can reach when it goes looking.
Turning it on
Settings → Session service → “Run the service in a container”, then restart the application.
You need a container runtime installed — Docker or Podman; see Container runtimes below.
The first start downloads the service’s image, which can take a few minutes on a slow connection. After that it starts in seconds.
Container runtimes
Settings → Session service → Container runtime chooses between them. Docker is the default; nothing changes runtime on your behalf, and only the one you selected is ever run.
| Docker | Podman | |
|---|---|---|
| Minimum version | 20.10 | 4.0 |
| Runs as | the Docker service, as root | you, rootless |
| Needs first-time setup | membership of the docker group | subuid/subgid ranges for your user |
| Writable storage limit | usually enforceable | often not |
Everything this page promises holds on both. They are not a shim around one runtime with the other bolted on: both are driven through the same interface and both pass the same conformance suite, so a claim that survives on Docker and not on Podman is a bug in this application rather than a difference you are expected to work around.
What is different about Podman
It runs as you, not as a service running as root. That is why it needs no group membership, and
why files a session writes into a project already come out owned by you. Docker reaches the same
result by being told your user and group explicitly; Podman by mapping your own user into the
container (--userns=keep-id). The outcome is identical; the mechanism is not, and it is the
mechanism that fails differently.
Its characteristic first-run failure is a missing subuid range. Rootless Podman needs a block of
subordinate user and group ids allocated to you in /etc/subuid and /etc/subgid. Without them
nothing starts, and Podman says so in its own words rather than saying “permission denied”. The
application classifies it as a permission problem anyway and points at podman system migrate,
because the alternative — reporting it as an unrecognised error — leaves you one usermod away
from a working sandbox with no way to know it.
“Not running” means something else. Docker has a daemon to start. Rootless Podman does not: on
Linux “not running” means its user-level service is down, and on macOS and Windows it means the
podman machine virtual machine has not been started. The failure message names the command that
applies to your setup.
The writable storage limit is the one most likely to be unavailable, on either runtime but more often on Podman, since whether a size cap can be applied at all depends on the storage driver underneath. The application asks, and shows the field disabled with the runtime’s own reason rather than accepting a number it knows will be ignored — see Limits below.
Adding a runtime
A third runtime is a contained change, and deliberately so. In order:
- Add a
RuntimeKindvariant and acrates/micold-core/src/sandbox/dialect/<name>.rsbeside the existing two. - Declare its baseline capabilities, its probe commands, and the wording it uses for “not running” and “not permitted” — those phrase lists are per-runtime data, not a branch in the classifier.
- Pass the conformance suite K-1 … K-12 against a fake binary speaking that runtime’s output
format. The suite is parameterised over every
RuntimeKind, so a new variant is enrolled in it by existing. - Document its quirks in this file, in the shape of the section above.
Nothing in argv construction’s callers, the client, or the service should need to change. If a new
runtime forces one of them to, the abstraction is wrong, and that is the signal to revisit
specs/027-sandboxed-daemon-runtime/contracts/container-runtime.md rather than to special-case
around it.
What the container can see
| Reachable | Not reachable |
|---|---|
| Every project you have registered, at its own path | Your home directory |
| The service’s own data directory | Any project you have not registered |
| The credentials you explicitly share (below) | Your SSH keys, browser profile, system configuration |
| The container runtime’s own control socket |
Projects are mounted at the same absolute path they have on your computer. That is deliberate: git records absolute paths inside worktree metadata, and both the application and the service run git, so a project seen at two different paths would make the two disagree about which worktrees exist.
Files a session creates in a project come out owned by you, not by root.
The AI CLIs come from the image
A session runs its AI CLI inside the container. That means the CLI comes from the image, and the one
installed on your computer is not used. The published image, and one built from this checkout, ship
Claude Code, GitHub Copilot and Pi Coding Agent at pinned versions. If you choose an image that
lacks one, that CLI is not offered, and Settings → Session service names it under Image
reference, with the same note that appears under Default AI CLI. The note says the image lacks
the CLI only when the environment a session starts in was applied: environment-include is on and
the startup script succeeded. Otherwise it gives the environment-include reason (the switch is off,
the script path is empty, or the script was not found, failed or timed out) and the setting to
change, because a script that was not applied may be what puts the CLI on the PATH. See
Settings → Default AI CLI. A session that already runs it fails to start and says to choose an image that provides
it — see When a CLI isn’t installed.
The AI’s tools work the same way
Sandboxed Claude Code and Copilot sessions are connected to the application’s tools exactly as they
are outside the container — see Tools for the AI in your sessions. The tool server
runs inside the container beside the sessions and listens on the container’s own loopback address,
and each session’s key file lives in the service’s data directory there. The check for a server
you already named micold reads the configuration the session sees inside the container, not the
files in your own home directory.
Credentials
Nothing of yours is shared unless you say so. Each item is a separate opt-in:
- Git configuration — your commit name and email, read-only.
- SSH agent — the agent’s socket, so a session can push. Never your key files.
- Git credentials — the credential helper’s store.
- AI CLI sign-in — Claude Code’s sign-in token (
~/.claude/.credentials.json), and nothing else from~/.claude. It is the one share a session can write to: Claude Code replaces the token each time it refreshes it.
With none of these on, a session that tries to push to a remote fails for want of credentials, and the application says so rather than leaving you with an unexplained authentication error.
Where sandboxed conversations live
Conversations started in the sandbox are stored in the sandbox’s own home, under the application’s
data directory, and not in ~/.claude on your computer. They survive the application restarting
and the sandbox being recreated, and a sandboxed session resumes from them. They do not show up in
Claude Code sessions you run outside the application.
Moving the service back to your computer leaves them behind. A session started in the sandbox is
then judged against ~/.claude on your computer, which has no record of it, so it is dropped from
the catalogue the next time the application starts. The conversation itself is still in the
sandbox’s home and comes back if you move the service into the container again.
A few limits of the sign-in share:
- macOS keeps the token in the Keychain, not in a file, so there is nothing to share. The
share stays on and mounts nothing; sign in inside a session instead, with
/login. The same is true anywhere the token file is missing, such as before your first sign-in or when you authenticate with an API key. When the running sandbox has no token from the share, Settings → Session service says so under AI CLI sign-in, names the path it shares the token from, and stops listing the sign-in as shared. The running container keeps the mounts it was created with, and restarting the sandbox keeps the same container. To share a token you put at that path later, set Where sessions run to On this computer and back: that removes the container and creates a new one. - Copilot and Pi are not covered. They sign in inside the sandbox, and that sign-in is kept in the sandbox’s home.
- Claude Code starts signed in. With the share on, the application marks Claude Code’s
first-run setup as done in the sandbox’s own home (
hasCompletedOnboardingin its.claude.json; your own~/.claude.jsonis not touched), so the first sandboxed session opens signed in, rather than offering a new sign-in that would replace your computer’s token. If you later turn the share off, a session starts signed out without that setup: sign in with/login. - Sign-ins can collide. If Claude Code on your computer and a sandboxed session refresh the token at the same moment, one of them may be asked to sign in again. Nothing else is lost.
While any of them is on, the settings view shows which — a partially shared sandbox should never look like a fully isolated one.
Limits
Settings → Session service → Limits caps what the sandbox may consume. A session that tries to use every core does not make your machine unusable, and one that forks without bound does not take your desktop with it.
| Setting | Unit | Default | Lowest you can set |
|---|---|---|---|
| Processor limit | cores, fractions allowed | 2 | 0.25 |
| Memory limit | MiB | 4096 | 512 |
| Process limit | count | 512 | 64 |
| Writable storage limit | MiB | unset | 1024 |
Leave a field empty to unset it. Empty is not zero: it hands the decision to the container runtime, which applies whatever it does by default — usually no limit at all. That is why the writable-storage limit ships unset while the other three ship with a number.
The minimums are not arbitrary and the form enforces them. Below them the service does not work rather than working slowly: a quarter of a core is roughly what it takes to keep the control channel answering, 512 MiB is under what the daemon plus one session needs before the kernel starts killing things, and 64 processes is about what a shell running a build already has open. Type something smaller and the field itself refuses it, naming the minimum — the settings are not saved with a number that would produce a sandbox that cannot start.
Not every limit works with every setup, and the writable-storage one usually does not. Whether
it can be enforced depends on how your container runtime stores images. Docker’s overlay drivers —
both overlayfs and the older overlay2 — enforce a size cap only when they sit on a filesystem
with project quotas turned on (xfs with pquota, ext4 with prjquota); without one they accept
the setting and quietly ignore it, so the application treats them as unable to enforce it. btrfs and
zfs do enforce it. podman differs again. The application asks your runtime once, when the sandbox
starts, and remembers the answer against that runtime’s version.
Where a limit cannot be enforced, its field is shown disabled with the runtime’s own reason underneath it. It is not hidden — a value you set earlier stays visible — and it is never quietly accepted, because a number that is ignored is worse than no number at all: you would believe a bound exists.
If a session is stopped because it hit a limit, the application names which limit and which setting governs it, so you know which one to raise. The processor limit is the exception: a container over its processor share is slowed down, never stopped, so it is never named as a cause.
Network
By default the container cannot open outbound connections. Package installs and anything else that reaches the internet will fail inside a session, and — importantly — so will the AI agent’s connection to its provider. If you need either, turn network access on for the sandbox; the application warns you at the point you turn it off, for the same reason.
One thing to know precisely: with outbound connections blocked, DNS lookups still resolve. Names resolve, connections to them do not. The block works by leaving the container’s bridge without a route out to the internet, and the runtime answers DNS from your side of that boundary — so a lookup succeeds and the connection that follows it fails. Expect errors that say “connection refused”, “network unreachable” or a timeout, rather than “host not found”; a program reporting that it resolved an address has not reached it.
That is a small channel — something inside could signal outward by choosing what to look up — and it is stated here rather than left for you to discover. The guarantee is “no outbound connections”, not “no outbound traffic of any kind”. The control channel between this application and the service is unaffected either way; it does not travel over the container’s network.
Working offline
The sandbox does not need a registry. If the machine cannot reach one, bring the image in by hand:
# Somewhere with a connection
docker pull ghcr.io/jaroslawherod/micold-daemon:<version>
docker save ghcr.io/jaroslawherod/micold-daemon:<version> -o micold-daemon.tar
# On the machine without one
docker load -i micold-daemon.tar
Then point the image setting at the reference you imported. Once the image is present, everything works with the network off entirely.
Sessions, restarts and reboots
Closing the application leaves sessions running, exactly as it does without the sandbox. Reopening re-attaches to them.
Stopping and recreating the container does not lose anything: the service’s data lives in your own data directory, mounted in, so it outlives any container.
The sandbox stops itself when nobody has used it
The rule is the same one the direct placement follows, and it is the same code: after 30 continuous minutes with no window connected, the service stops — and here that means the container stops too, rather than being left running with an idle process inside it. See when the service stops itself for what the timer measures and what happens to a session that was running; none of it changes inside a container.
Reopening the application starts the sandbox again. A stopped container is not a failure state and you will not be told about it as if it were one.
It comes back on its own while the application is open
The application does not wait for you to relaunch it. Whenever it finds the service missing while the sandbox is the chosen placement — a start that failed, a container stopped or removed from outside, the machine waking without it — it brings the sandbox up again itself:
- Up to three attempts, the first at once, then after 5 seconds, then after 15. The count starts over once the service has answered, so a sandbox that recovers gets all three again next time.
- Each attempt shows its stage, the same as a first start: checking the runtime, getting the image (with its download progress), starting. While one is under way there is no “could not connect” banner — a sandbox that is coming up is not a lost connection.
- Why the last attempt failed stays on screen as Trying again: … beside the stage of the next.
- When all three have failed, the failure is left standing with its cause and next step from the catalogue below, and Restart is how you try again. Nothing is retried after that without you.
Switching the placement back to On this computer cancels an attempt that is still waiting. None of this ever starts the service outside the container.
Keeping the sandbox running
“Keep the sandbox running” is the one setting that changes this. With it on:
- the container is created with a restart policy, so it comes back after a sign-out and after a reboot, with its sessions live;
- and it is not stopped when idle — the service inside keeps running with nothing connected, which is the point of asking for it.
Those two are one choice because they are one question: should this machine keep a session service running for you when you are not there? Answering yes to the reboot half and no to the idle half would leave you with a sandbox that survives the night but not the afternoon.
The setting is applied when the container is created, so changing it marks the running sandbox as out of date and takes effect the next time the sandbox starts. The application tells you so at the control rather than applying it silently underneath a container that was created with the other answer.
Without the sandbox there is no equivalent: a service running directly on this computer cannot outlive signing out, on any platform. Your sessions are still kept and come back resumable — running the service in a container is what keeps them running.
When it does not start
A failure is first retried on its own, as described in It comes back on its own; what follows is what stands once those attempts are spent.
Every failure the sandbox can report names a cause and a next step. There is no generic “sandbox error”: the runtime’s own output is classified into one of the twelve cases below, and anything that matches none of them is reported as the last row rather than dropped.
Each message is prefixed with the stage it happened in — “The sandbox failed while checking the container runtime”, “…while getting the sandbox image”, “…while creating the sandbox”, “…while starting the sandbox” — so you can tell a missing runtime from a missing image without reading the detail.
The catalogue
| Cause | What the application says | What to do |
|---|---|---|
No runtime on PATH | Docker is not installed. | Install it, or choose another runtime in Settings → Session service. |
| The runtime is installed but its service is down | Docker is installed but not running. | Start it, then retry. For rootless Podman this is the user service or podman machine. |
| The runtime refuses this user | Docker is running, but this user is not permitted to use it. | Docker: add your user to the docker group and log in again. Podman: initialise rootless Podman (podman system migrate) — this is also what a missing subuid/subgid range looks like. |
| The runtime is too old | This runtime is version 19.03; the sandbox needs 20.10 or newer. | Upgrade the runtime. Docker needs 20.10, Podman 4.0. |
| The image is not present and cannot be resolved | The image ghcr.io/… was not found. | Check the reference in Settings → Session service, or import an image archive. |
| The image is present remotely but the fetch failed | ghcr.io/… could not be fetched: … | Rate limit, sign-in, or a proxy. Import the image from a file instead — the sandbox does not need a registry. |
| Something else holds the control port | Port 7373 is already in use, so the sandbox has no control channel. | Stop whatever holds it, or choose another port in Settings → Session service. |
| A project is on a path the runtime will not bind | /mnt/team-share/webapp cannot be shared with the sandbox: … | Move the project to a path the runtime can share, or unregister it. Network shares and FUSE mounts are the usual cause. |
| The runtime rejected a resource limit | The runtime refused the storage limit: … | Clear that limit in Settings → Session service, then retry. See Limits — the writable-storage cap is the one most often unavailable. |
| The container was stopped or removed from outside | The sandbox container micold-sandbox is no longer running. | Usually nothing: the application brings it back up on its own (see It comes back on its own). If that fails three times, restart the sandbox. Sessions resume from where they were — their processes stopped with the container, but their history and layout did not. |
| A step did not finish | Pulling the image did not finish in time. | Retry; if it persists, restart the runtime. |
| Anything else | The runtime reported: <its first line of output> | Retry. The runtime’s full output is in the diagnostics. |
The path in the mount row is the project’s path, not the directory that happens to contain it — if two registered projects nest, the message names the one that was actually rejected.
The application never quietly runs unsandboxed instead. If the sandbox will not start, you are offered the choice explicitly, for that occurrence only: the next launch tries the sandbox again without you having to remember. While you are running without it, the application says so persistently — a sandbox that failed once and was forgotten about is a sandbox that is off for weeks. That notice carries Try the sandbox again, so you do not have to restart the application to get back into the sandbox once you have fixed whatever the message named.
If you add or remove a project while the sandbox is running, it is marked as needing a restart rather than restarting itself. What the container can see is fixed when it is created, and restarting on its own would end sessions you are using to service a settings change.
For maintainers
mise run image builds a :dev image from the working tree. If you rebuild the application but not
the image, the two disagree, and the connection is refused with StaleDevImage naming the tag —
rather than connecting and misbehaving in ways that look like bugs in the new code. Run
mise run image again.
See packaging/sandbox/README.md for building and publishing.