You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The review of #94 asked for a concept of the runtime layer before more Kubernetes code is written. This is the concept. The full text is in the collapsed section at the end.
Summary
The orchestrator can run boxes on Docker or on Kubernetes. The code outside the runtime does not know which runtime it uses. The central decisions:
Storage assumption. On Kubernetes, each box gets one persistent volume claim from the default storage class, and only the box pod mounts it. The orchestrator never mounts the storage of a box. ReadWriteMany storage or pods on one node are not assumed.
A file service in the box. All file access of the orchestrator (review, attachments, agent set, disk usage) goes through one long-lived process in the box, boxes-files, which is part of the box image. The orchestrator starts it with spawn, as it starts an adapter, and speaks HTTP/2 over its standard input and output. This is the same on both runtimes, so there is one code path and one set of tests. The file service runs as the agent, so the chown calls of the orchestrator go away.
A file operation starts a stopped box, as git does today. A stopped box only has its stored disk usage, which the orchestrator measures before each stop.
The ready state. A box is ready when it runs, the file service is connected, boxes-setup has run, and the agent set is installed. The agent set travels as a tar stream after the start; the /boxes/agent mount goes away. The adapters, the terminal and the review wait for ready.
The runtime keeps only what is different by nature: the container or pod lifecycle, the storage, the network isolation, the exec transport, the image lifecycle and the inventory. Everything else works the same way on both runtimes, for example ps in the box instead of docker top.
The image lifecycle is a runtime matter. Docker pulls, compares and prunes as today. Kubernetes uses imagePullPolicy: Always and does nothing in the refresh loop.
Long-lived streams can end at any time, and each user copes: the adapter respawns as today, the terminal attaches to its tmux session again while the WebSocket stays open, the file service is started again at the next call.
Tests. One conformance test suite for the Runtime interface runs against Docker and against kind in CI. kind v0.24.0 and later enforces NetworkPolicy with its default CNI.
Implementation steps
Each step is a separate pull request. Steps 1 to 8 are useful on Docker alone.
5. The agent set through installAgentSet, and the home setup through boxes-setup. The ready state.
6. The home seed in the entrypoint; remove the helper container.
7. The Runtime interface with the Docker implementation only. No change of behavior.
8. Conformance tests against Docker in CI.
9. The Kubernetes implementation, the manifests, and kind in CI.
@kossmac: this follows the discussion on #94. Your view on the storage assumption (section 4), the pod lifecycle (section 9.4) and the deployment requirements (section 14) is welcome, in particular against the cluster you run Boxes on.
The full concept
Status: draft. Base: main at 3a16674.
1. Summary
Today, the orchestrator runs boxes on Docker only. This concept makes the runtime replaceable, so that boxes can also
run on Kubernetes. The code outside the runtime does not know which runtime it uses.
The central change is the access to the files of a box. Today, the orchestrator reads and writes them on its own
filesystem. On Kubernetes this is not possible, so a file service in the box does this work, on both runtimes. Many
other operations also move into the box or into the box image, so that they work the same way on both runtimes. The
runtime itself keeps only the parts that are different by nature: the container or pod lifecycle, the storage, the
network isolation, the exec transport, the image lifecycle and the inventory.
2. Background
PR #94 added Kubernetes as a second runtime. It was closed after a review, for these reasons:
Review, attachments and agent sets did not work on Kubernetes, because they read the files of a box on the
filesystem of the orchestrator.
The runtime interface was a copy of docker.ts. On Kubernetes, some of its methods were stubs or threw an error.
Code outside the runtime checked cfg.RUNTIME, and logins had a separate runtime of their own.
On Kubernetes, opening the review started a stopped box. With Docker, it did not.
Some operations worked differently on the two runtimes without a need to. For example, Docker read the process list
with docker top, and Kubernetes ran ps in the box.
An inventory of main shows two facts that this concept builds on:
No host binary runs against the files of a box. Git, ps, kill, the terminal and the adapter all run in the box
through docker exec. The only host git is the skill repository pull, and it works on a global directory.
The orchestrator reads and writes the files of a box with Node fs calls. This is in four places: workspaces.ts (create, check, delete), agents.ts (agent set materialization), attachments.ts with app.ts
(upload and download), and review/* (tree, repository discovery, file read and write, REVIEW.md). Disk usage is
a Node walk.
The first fact stays as it is. The second fact must change.
3. Goals and non-goals
Goals:
Boxes run on Docker or on Kubernetes. A deployment selects one runtime.
Each feature works the same way on both runtimes. Where it does not, this concept names the difference.
CI tests both runtimes for each pull request.
Each step of the implementation is useful on Docker alone.
Non-goals:
More than one user. Boxes is a tool for one person. The concept must not prevent more users later: the runtime
keys everything by the box id and a deployment label, and it has no global state other than the configuration. An
owner of a box would be a field in the database, not a runtime concept.
A smooth upgrade. A running box can need a restart after an update of the orchestrator (see 8.3).
Private box images. Boxes does not support them today: the Docker pull sends no credentials. On Kubernetes, the
pull secrets of the cluster apply (see 11).
4. Assumptions
On Kubernetes, the concept assumes only this about storage:
Each box gets one persistent volume claim from the default storage class of the cluster.
One pod at a time mounts it. This pod is the box pod.
The orchestrator gets one volume of its own for boxes.db, the lock file and the skill repository checkouts.
The concept does not assume ReadWriteMany, a volume that two pods share, or pods on one node. The orchestrator never
mounts the storage of a box.
On Docker, the storage stays as it is: directories in the data volume, bind mounted into the box container.
On Kubernetes, the concept assumes this about the network:
The cluster has a CNI that enforces NetworkPolicy, for ingress and for egress. A policy is only an object in the
API, and a cluster whose CNI does not enforce it gives the boxes no isolation, without an error anywhere.
CI uses a kind cluster with such a CNI, and one test confirms that a box cannot reach another box, the orchestrator,
or the control port of the proxy.
5. Principles
The interface describes box operations, not Docker operations. "Make the box reach the proxy and nothing else" is a
box operation. "Connect the proxy container to a network" is a Docker detail.
When an operation can work the same way on both runtimes, it works that way on both, and it is not part of the
runtime. Each difference is a place where a feature can break on one runtime only.
One factory selects the runtime at boot. No other code reads cfg.RUNTIME.
The runtime has no knowledge of threads, reviews or credentials. It knows boxes, their storage, their processes and
the box image.
The orchestrator reaches a box only through the runtime: it starts and stops the box, and it runs processes in it. All
work on the files of a box is done by processes in the box.
What each runtime does in its own way:
Area
Docker
Kubernetes
Box
container box-<id>
pod, deleted at each stop
Storage
directories in the data volume, bind mounts
one claim per box, subPath mounts
Isolation
internal network per box, proxy attached
NetworkPolicy, proxy as a Service
Exec
Docker exec API
exec through the API server
Image
pull, compare, recreate, prune
kubelet pull at each pod start
Inventory
labels on containers and networks
labels on pods, claims and policies
What both runtimes do in the same way, and what is thus not part of the runtime:
Operation
Today
With this concept
File access
Node fs on the data volume
file service in the box (8)
Agent set
written before the start, read-only mount
sent to the file service after the start (9.2)
Disk usage
Node walk on the data volume
file service, before each stop (8.4)
Ownership of files
chown by the orchestrator
the file service writes as the agent
Home seed
root helper container
entrypoint (9.3)
Process list for the background probe and the tunnels
interfaceRuntime{/** Creates the storage of a new box. Fails when it exists. */createStorage(boxId: string): Promise<void>;/** Whether the storage of a box exists. */storageExists(boxId: string): Promise<boolean>;removeStorage(boxId: string): Promise<void>;/** * Creates or repairs the container definition and the isolation of a box. Idempotent. On a running box, it repairs * only the isolation and never changes the container. */ensure(box: BoxSpec): Promise<void>;/** Starts a box. Resolves when an exec in the box works. */start(boxId: string): Promise<void>;stop(boxId: string): Promise<void>;/** Removes the container and the isolation. The storage stays. */remove(boxId: string): Promise<void>;/** `unknown` when the runtime does not answer. It counts as live, so a failed read stops nothing. */state(boxId: string): Promise<'running'|'stopped'|'unknown'>;/** The image a box runs, as an image id or digest. */imageOf(boxId: string): Promise<string|null>;/** Makes the image that BOX_IMAGE names available, and removes old images where the runtime must. */refreshImage(): Promise<void>;/** * All objects of this deployment, storage and login containers included, for the boot reconcile and the orphan * sweep. */inventory(): Promise<RuntimeObject[]>;removeObject(obj: RuntimeObject): Promise<void>;/** A short command as the box user. Output is capped, a timeout applies. */run(boxId: string,cmd: string[],opts?: RunOptions): Promise<RunOutput>;/** A long-lived process with stdio, with or without a TTY. Can end at any time. */spawn(boxId: string,cmd: string[],opts: SpawnOptions): Promise<ProcessStream>;/** Starts a login container: short-lived, from the box image, with internet access and no storage. */startLogin(spec: LoginSpec): Promise<Login>;/** The URL of the front door of the egress proxy, as a box sees it. */proxyUrl(): string;/** Runtime facts for the details page, for example the network and the subnet. */details(boxId: string): Promise<Array<[string,string]>>;/** Throws when the runtime cannot be reached. */health(): Promise<void>;/** The images of the orchestrator, the proxy and a new box, for the image footer. */deploymentImages(): Promise<DeploymentImages>;}
BoxSpec carries the id, the environment and the limits. It does not carry the image, because each runtime uses BOX_IMAGE (see 11). It does not carry the mounts, because they are the same for every box: /workspace, /home/agent and /nix. Each runtime makes them from its own storage.
The core builds the full environment of a box as a plain record: the credential placeholders, the git identity, the
proxy variables from proxyUrl, and the CA certificate with the variables that point to it. Each runtime only converts
the record to its own format. The environment holds no secret, because the egress proxy replaces the placeholders. On
Kubernetes, it is thus a plain env in the pod spec, and no Secret object is necessary.
The runtime keys everything by the box id and keeps no database of its own. The columns container_id, network_name, subnet and image of the boxes table go away with a migration. The Docker runtime finds the values
itself: the container by its name box-<id>, the network by its name, a free subnet at a create from the subnets of
the existing box networks, and the image with imageOf. A create that is not complete is the row status creating,
as today. After docker network prune, a stopped box gets a new subnet at its next start. Nothing depends on a fixed
subnet.
state has no value for a missing container. On Docker, an exited container and a container that is gone are both stopped, and ensure creates a missing one again before the next start. On Kubernetes, a pod in the phase Running
is running, a pod in Pending counts as running because a start is in progress, and no pod or a pod in a terminal
phase is stopped.
spawn serves the adapters, the terminal and the file service. The contract of its stream is in 12.
A login container is not a box, so the box methods do not apply to it. The Login handle has its own spawn, with
the same stream contract, and its own remove. A box id and a login can thus not be mixed up.
The image footer of the box list shows three images with digest, build time and size. For the orchestrator and the
proxy, it shows the image that runs now. For the box, it shows the image that a new box gets (see 11).
Docker: the orchestrator inspects its own container and the container EGRESS_PROXY_CONTAINER.
Kubernetes: the orchestrator reads the imageID of its own pod, which it finds by the pod name from the downward API,
and the imageID of the proxy pod.
8. File access
8.1 The file service
All access of the orchestrator to the files of a box goes through one file service. This is a long-lived process
in the box. The orchestrator starts it with spawn, as it starts an adapter, and talks to it over its standard input
and output. The same file service is used on Docker and on Kubernetes. There is one code path and one set of tests.
The file service is a fixed command in the box image, boxes-files, as the adapters are. The image and the
orchestrator come from one repository, so the name is a contract of the repository. The orchestrator starts the file
service as the first exec after start, because the agent set install (9.2) needs it before the box is ready. After
the connection ended, the next file operation starts it again.
Operations, as a draft:
Operation
Replaces
Used by
list(dir), capped at 2,000 entries
review/tree.tsreadDir
the review tool and its tree view
findRepos(root, limits): the breadth-first walk with the depth, count and prune limits
review/repos.tscandidateDirs
repository discovery
stat(path): type and size
statSync, isDirectory
review, attachments, Content-Length of a download
mkdir(path): one level at a time, each level inside the root
attachments.ts directory setup
attachment upload
createExclusive(path, bytes): refused when the file exists
exclusive create of .boxes/.gitignore
attachment upload
read(path, cap): content, hash, truncated flag
review/fs.tsreadTextFile
file view, drift, REVIEW.md
readStream(path): chunks
createReadStream
raw download, attachment download
write(path, bytes, expectedHash): atomic, refused when the hash does not match
writeFileAtomic
file save, REVIEW.md
create(dir, name, bytes): under the first free name
writeUnderFreeName
attachment upload
remove(path)
removeFile
delete the review
size(): workspace, home and Nix store
diskusage.ts
disk usage
installAgentSet(tar)
agents.ts materialization, entrypoint install
agent set (9.2)
The rules of the operations stay as they are in review/fs.ts, review/tree.ts and review/repos.ts:
Paths: the root and the target are resolved with realpath, a symlink in the last component is refused, and a new
file must have its parent inside the root.
read detects a binary file by a NUL byte, and gives the real size.
write keeps the file mode of the old file.
list leaves out symlinks. findRepos also accepts a .git that is a file.
The orchestrator keeps everything that is not file access: the per-box lock around REVIEW.md, the parsing, the drift
logic, the caches and the HTTP routes.
Wire protocol. The orchestrator and the file service speak HTTP/2 over the standard input and output of the
process. Node's http2 module accepts any byte stream as its connection, on the client side and on the server side,
so neither side needs a port, a network path or a dependency. Each operation is one request: the method and the path
name the operation and the file, the body carries the bytes, and the usual status codes report the result, for
example 404 for a missing file and 409 for a hash that does not match. The hash of a file travels in the ETag
header, and a write sends the expected hash in If-Match.
Each request is its own HTTP/2 stream, so a long download and a short stat run at the same time on one connection.
The flow control of HTTP/2 applies to each stream. A slow reader stops the writer, so a large file stays on disk and
does not go into memory on either side. This holds only when both sides pipe streams. A body that is written in one
call is held in memory first, and Node ends such a stream when it is larger than the session memory limit. The
orchestrator therefore pipes a download into the HTTP reply to the browser, and an upload from the request into the
file service. A test with a 300 MiB file in each direction and a reader at 20 MiB/s kept the memory of both processes
flat.
8.2 Properties
Rights. The file service runs as the agent account. Files it creates belong to the agent, so the chown calls of
the orchestrator go away. The orchestrator no longer reads a tree that the agent controls with its own rights. The
containment check of review/fs.ts stays in the file service, but only to keep the review inside /workspace, not as
a protection of the orchestrator.
Replies are untrusted. The agent has the rights of the file service and can replace it. The orchestrator
therefore treats the file service as it treats an adapter: it applies the caps (the list size, the read cap, the
attachment size) on its own side and ends a request that exceeds them, it validates the shape of each reply before
it uses it, it never turns a reply into a path, a command or a URL of its own, and each request has a timeout.
One process per box. The file service does not start a process for each operation.
Size. The caps of today stay: an attachment (MAX_ATTACHMENT_MB, default 25 MiB) and REVIEW.md (8 MiB) are
held in memory by the orchestrator, as today. A raw download has no cap and is piped end to end.
End of the connection. The next call starts the file service again. A request that is in progress when the
connection ends fails. The caller retries a read once. A write is not retried: when the first attempt arrived, the retry finds a
changed hash and reports a conflict that is not real. The caller reports the error.
8.3 Version
The file service is part of the box image. The image and the orchestrator are built from the same repository, but a
running box keeps its image until it stops, so the orchestrator can be newer. The file service therefore states its
protocol version in a header of each response. On a mismatch, the orchestrator refuses the file operations. Older
versions are not supported.
A restart moves the box to the current image only when BOX_IMAGE resolves to a new image. With a pinned tag, after
a failed pull on Docker, or with IfNotPresent on kind, the restart gives the same image again. The error therefore
names the version it found and the version it needs, and says that the box needs an image with the current file
service. When the last pull on Docker failed, the error says so too. The box gets the status error with this
message, as a box with missing storage does (9.1), because without the file service the agent set is not installed
and the box does not become ready.
8.4 Disk usage
The orchestrator asks the file service for the size before each stop, and stores the value with its time. Nothing in a
stopped box changes, so the value stays correct. A running box is measured again at most every 15 minutes, as today. A
box that ends without a stop by the orchestrator, for example after an out-of-memory kill, keeps its last value.
The measurement before a stop has the timeout of a run. A walk over a large Nix store can take longer on slow
storage. When the walk does not finish in time, the stop goes on, and the box keeps its last value with its old time.
The dashboard shows that time, so the user sees that the value is old.
8.5 Stopped boxes
A file operation starts a stopped box, on both runtimes. Git already works like this. The effect is small:
Feature
Today with a stopped box
With the file service
Review tree, file view, save, base
starts the box (git)
unchanged
Attachment download (thread images)
works
starts the box. A thread that shows them is open, and opening a thread starts the box already.
Attachment upload
works
starts the box. The upload comes from the composer of an open thread.
Raw download, delete annotations, delete the review
works
starts the box
Disk usage
walked at the first read after the stop, and again after each restart of the orchestrator
stored value (8.4)
Delete the box
works
unchanged: storage removal is a runtime operation
With a stopped box, the orchestrator can thus only read the stored disk usage and delete the box.
9. The box lifecycle
9.1 Storage
The runtime creates, checks and removes the storage of a box. It never gives the orchestrator a path.
Docker: the directories workspaces/<id>, homes/<id> and nix/<id> in the data volume, bind mounted.
Kubernetes: one claim per box, mounted with subPath at /workspace, /home/agent and /nix. New subPath
directories must be writable for BOX_UID. The plan is securityContext.fsGroup.
The storage is created only when a box is created. When the storage of an existing box is missing, the box is marked
as an error, and the storage is not created again: a new empty home would erase the transcripts of the agent without a
warning. This is the behavior of requireDirectories today for the workspace and the home.
The Nix store is the exception. It holds only installed packages, and nix lays out an empty store at first use, so a
missing one is created again at each start, as today. On Docker, this is an empty directory that belongs to the agent.
On Kubernetes, a missing claim is a missing storage, and a missing nix directory inside the claim is made by the
kubelet at the mount.
9.2 Start and the ready state
The inputs that a box gets at its start are:
Environment variables, as today: the credential placeholders, the git identity, the URL of the egress proxy and
the CA certificate. They are fixed when the container or the pod is created.
The setup of the home. Today, the entrypoint prepares the home at each start: it writes the CA bundle, adds the
CA to the trust store of Chromium, sets up the browser CLI, and writes the git identity and the credential helpers.
These steps move into one command of the image, boxes-setup, with the same shell code. The orchestrator runs it
with run after the file service is connected. The orchestrator knows only the name, as it knows the adapter names.
The image decides what its setup does. A failed step is an error of the box with its output, where today it is a
warning in the container log.
The agent set. The orchestrator no longer writes agents/<id>, and the box has no /boxes/agent mount. After the
setup, the orchestrator sends the merged set as a tar stream to installAgentSet of the file service. The installer
does what the entrypoint does today: it removes what the last install recorded in ~/.boxes/managed, checks each
manifest line, and installs the new entries. The entrypoint also installs the skills of the image (nix, share-app, diagrams and the skill of the browser CLI), and a skill of the same name in the set wins. These
installs read the manifest, so they move into the installer too, and both happen in one step. As today, a change
to a set reaches a box at its next start, and a running box keeps what it has.
A box is ready when:
the runtime has started it, and an exec works,
the file service is connected,
boxes-setup has run,
the agent set is installed.
The orchestrator drives this order, so nothing writes into the home at the same time as the installer. The entrypoint
does only two things: it seeds the home (9.3) and it holds the container.
The adapters, the terminal and the review wait for "ready", not for "started". On Kubernetes, start must wait for the
pod to run: scheduling, attaching the volume and pulling the image can take longer than the retries of an adapter
spawn.
9.3 The home seed
A new home must get the files of the home of the image. The mount hides the original, so the image keeps a copy of its
home outside /home/agent. The copy is a few kilobytes: the shipped home holds only the files of /etc/skel (from useradd -m), some empty directories and .claude/settings.json.
The entrypoint seeds the home: when the marker file ~/.boxes/seeded does not exist, it copies the files and writes
the marker. A home that the agent emptied thus stays empty. The entrypoint runs as the agent, so the files belong to BOX_UID without a chown. This also solves #99. The root helper container of today goes away.
9.4 Stop and removal
Before a stop, the orchestrator measures the disk usage (8.4). On Docker, a stop stops the container. On Kubernetes, a
stop deletes the pod, and the claim stays.
On Kubernetes, stop deletes the pod with the grace period of today, 10 seconds, and returns only when the pod no
longer exists. This is the contract of Docker: after stop, the box is really stopped. start first deletes a pod of
the box that still exists, for example a terminal pod after an out-of-memory kill or a pod in Terminating after an
interrupted stop, and waits until it is gone. Only then does it create the new pod. The new pod thus never competes
with the old one for the volume. The detach of the volume can still take a moment, and the new pod waits in Pending
for it.
On Kubernetes, a box is a bare pod with restartPolicy: Never, the counterpart of RestartPolicy: no on Docker. The
orchestrator alone creates and deletes the pod, and no controller makes a pod on its own. When the container ends by
itself, for example by an out-of-memory kill, the kubelet does not restart it. The pod stays in a terminal phase with
its reason, the orchestrator sees it as stopped, and the next start deletes the old pod and creates a new one. An
eviction by the kubelet is the same case: the pod stays as Failed, and the next start places the box where there is
room.
A node failure is a known limitation. The control plane cannot know whether the lost node still writes to the volume,
so it does not detach it, and the pod stays in Terminating. The box cannot start on another node until then. The
orchestrator must not force-delete the pod, because that does not release the volume and risks the data. The way out
belongs to the cluster: the operator, or an automation of the cluster, sets the out-of-service taint on the dead node,
and the control plane then deletes the pod and detaches the volume. After that, the box is stopped and starts again
on another node.
When a box is deleted, the orchestrator calls remove and then removeStorage.
9.5 Reconcile and orphan sweep
At boot, the orchestrator compares its database rows with the inventory of the runtime: it adopts live boxes, marks
missing ones as stopped, and marks an interrupted create as an error. The periodic sweep removes each object of the
inventory that has no live row. The inventory includes the storage, so a storage left by a failed delete is removed
too. The guard against a sweep of a wrongly mounted data volume stays.
The inventory also includes the login containers, with their creation time. A login container stays behind when the
orchestrator stops during a login. The sweep removes each login container that is older than 15 minutes, as today.
10. Network isolation
The contract:
A process in a box can open connections to the front door of the egress proxy, and to nothing else. It finds the
front door at the URL that the runtime gives. Nothing reaches a box from the network.
The proxy does not identify a box. It has one policy for all boxes, keyed by the destination host, and it does not
read the source address. So the network alone keeps the boxes apart from each other and from the orchestrator.
Docker: an internal network per box, with the proxy attached under the alias proxy. The subnet pool is an internal
detail of the Docker runtime.
Kubernetes: the box pods, the proxy and the orchestrator are in one pod network, so three NetworkPolicy objects
do the work of the Docker networks. The box policy allows egress only to the proxy pods on port 3128, and to the
cluster DNS when the URL is a name, and it denies all ingress. The orchestrator needs no ingress to a box, because
it uses exec through the API server. The proxy policy allows ingress on port 3128 from the box pods and on port 3129
from the orchestrator pod only. The orchestrator policy is in 14. The proxy is a Service.
The orchestrator reaches the control channel of the proxy at a URL from the configuration (EGRESS_CONTROL_URL,
default http://boxes-egress-proxy:3129), not at a container name. On Docker, the control channel is safe because it
binds to an address on the Compose network, which the box networks cannot reach. On Kubernetes, the box pods and the
proxy are in one pod network, so the bind address does not protect it. The policy must therefore allow port 3128 only.
The isolation of a running box can break. On Docker, a proxy container that Compose creates again has no connection to
the box networks. On Kubernetes, a policy can be deleted. A periodic loop therefore calls ensure for each running box.
On Docker, this connects the proxy to the box network again. On Kubernetes, it applies the policy again. A box where ensure fails shows as a warning in the dashboard, as today.
Each call of ensure runs in the per-box queue of the orchestrator, as a start does. A box can stop between the check
of the loop and the call, and ensure can then create the container again. In the queue, this cannot overlap with a
start of the same box.
The details page shows the network name and the subnet today. These become runtime facts from details.
11. The image lifecycle
The image lifecycle is a runtime matter. Each runtime does it in its own way. The contract:
When a box starts, it runs the image that BOX_IMAGE names at that time. A running box keeps its image.
Operation
Docker
Kubernetes
Make the image available
pull at boot and before a create
the kubelet pulls
Detect a new image behind the tag
pull every BOX_IMAGE_PULL_MINUTES, compare the image id
the kubelet resolves the tag at each pod start (imagePullPolicy: Always)
Move a box to the new image
recreate the container at the next start, never while it runs
nothing to do: each start creates a new pod
Know which image a box runs
container inspect
pod status (imageID)
Show the image of a new box in the image footer
BOX_IMAGE after the last pull
the image of the box pod that started last
Remove old images
BOX_IMAGE_PRUNE
the kubelet garbage collection
The image refresh loop calls refreshImage. On Kubernetes, this does nothing.
Before each start, the orchestrator calls ensure and then start. On Docker, ensure creates the network and the
container again when they are missing, for example after docker container prune. It also creates the container again
when its image is not the current image of BOX_IMAGE. Both happen only when the box does not run. On Kubernetes, ensure only applies the policy, because each start creates a new pod. The core thus has no image comparison and no
case for a missing container.
On Kubernetes, imagePullPolicy is a setting. The default is Always. CI with kind and local development use IfNotPresent, because their images are loaded into the cluster and are in no registry. The pull secrets of the cluster
apply, so the orchestrator needs no registry credentials.
Consequences on Kubernetes: a box start needs the registry, and two boxes started at different times can run different
images. The orchestrator sees this in imageOf.
On Kubernetes, the kubelet resolves the tag, so the orchestrator knows a digest only from a pod that started. The image
footer therefore shows the image that the last box start got. When the tag moves after that start, the footer shows the
old build until the next box starts. This is a known difference from Docker.
12. Long-lived streams
The contract: spawn returns a stream that can end at any time, and the caller must cope with it.
On Docker, an exec stream has no time limit. On Kubernetes, it goes through the API server and the kubelet to the
container runtime, and can end:
The container runtime closes a stream that is idle. containerd does this after 4 hours by default
(stream_idle_timeout). CRI-O has no limit by default. The kubelet setting streamingConnectionIdleTimeout has no
effect; it was deprecated in 2025 (kubernetes PR 131992).
A load balancer in front of the API server can have a shorter limit.
A restart of the API server ends all streams.
An idle timeout does not end a stream with traffic. A running turn sends traffic.
How each user of spawn copes:
Adapter: this works today. An ended exec drops the connection, and the next message spawns the adapter again and
loads the sessions. An idle timeout can only cut a turn that waits longer than 4 hours for a permission. This is
accepted.
Terminal: today, an ended exec closes the WebSocket. With this concept, the orchestrator opens a new exec and
attaches to the same tmux session again, while the WebSocket stays open. tmux keeps the state. Each terminal
connection has its own tmux client session, grouped with the shared session of the box, and that client session
outlives a cut stream but not a shell that the user ended. The reattach therefore uses tmux attach to the client
session by its name, never a new session. After an ended exec, the orchestrator waits one second and attaches. When
the session exists, the user sees the same screen. When tmux reports no such session, the user ended the shell, and
the WebSocket closes with "shell ended", as today. When the box is not running, the WebSocket closes with "box
stopped": opening a terminal starts a stopped box, as today, but a reattach never does. When the spawn fails for
another reason, the orchestrator tries once more after five seconds and then closes the WebSocket with the error.
File service: see 8.2.
Login: a login flow is short and has its own timeout (LOGIN_TIMEOUT_MS). An ended stream fails the flow, and
the user starts it again.
An open terminal tab keeps the idle reaper from stopping a box. This is the same on Docker, and it is not part of this
concept.
13. Logins
A login runs a CLI of a harness, for example claude setup-token, in a short-lived container from the box image. Today, login.ts has a separate LoginRuntime for this. With this concept, it uses startLogin of the runtime, and spawn
and remove of the Login handle.
A login container has the opposite network contract of a box: it reaches the public internet directly, not through
the proxy, and it has no box storage. It does not use the proxy because the proxy refuses a real credential that is
not the placeholder on an intercepted host, and a login exists to obtain one. Its home is temporary, so the credential
material ends with the login container.
Docker: the default bridge network and a tmpfs home, as today. The bridge can also reach the host and the LAN, and
Docker gives no way to filter that for one container. This is a known difference.
Kubernetes: a pod with an emptyDir home in memory, and a NetworkPolicy that allows egress to the internet except
the private ranges that the proxy already blocks as destinations (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16 and the IPv6 counterparts), plus the cluster DNS. The pod and Service ranges of
nearly every cluster are inside these, so no setting for them is necessary. A cluster with public pod addresses is
not covered. The policy denies all ingress.
14. Kubernetes deployment
The Kubernetes runtime and its manifests must also meet these requirements:
Box pods and login pods set automountServiceAccountToken: false and enableServiceLinks: false.
Box pods meet the restricted Pod Security Standard: non-root, no privilege escalation, all capabilities dropped,
read-only root filesystem, seccompProfile: RuntimeDefault. The Docker container has the same hardening today.
The resource limits BOX_MEM_LIMIT and BOX_CPUS apply to the box pod. /tmp is an emptyDir in memory with
the size of today's tmpfs.
BOX_PIDS_LIMIT applies on Docker only. A pod spec has no stable field for a pids limit. The limit is the kubelet
setting podPidsLimit, which applies to every pod on the node, and the manifests document it as a requirement for
the node, with the Docker default of 512 per pod, because a fork bomb in a box must not take the node down. A field
per pod, spec.resources.limits.pids, exists as alpha (KEP-6063, feature gate PerPodPIDLimit). Once it is
stable, the runtime sets it from BOX_PIDS_LIMIT. The implementation opens a ticket that tracks the status of the
KEP.
The box pod is Burstable: its requests are smaller than its limits, because most boxes are idle most of the time,
and the scheduler packs them by their idle use. The requests are the settings BOX_MEM_REQUEST (default 1 GiB) and BOX_CPU_REQUEST (default 0.5 CPU). An idle box with a connected adapter and the file service uses about 400 MiB
and far below a tenth of a CPU. Docker has no requests and ignores the two settings. A box that uses more than its
request can be evicted when the node runs out of memory (9.4), and a node can take more boxes than its memory holds
at their full limits. That is the trade the operator makes with the two settings.
A NetworkPolicy lets only the ingress controller reach the orchestrator API. The API has no authentication of its
own, on both runtimes, and the deployment puts authentication in front of it. On Docker, this is the reverse proxy
of the operator, as today. On Kubernetes, the Ingress carries it, for example an auth annotation of the ingress
controller, or the Ingress is not exposed and the user reaches the API with a port forward. The manifests ship the
Ingress with a placeholder for the authentication, and the documentation says that an Ingress without it is not
safe. The WebSocket upgrade on /ws passes through it.
The orchestrator is one replica with the Recreate strategy, because of the lock on its data directory.
The orchestrator has the RBAC rights for pods, pods/exec, claims and network policies in its namespace, and no
other rights.
15. Tests
The file service is plain Node on a directory. Its unit tests need no runtime.
One conformance test suite for the Runtime interface runs against a real Docker and a real kind cluster. It
checks:
storage: create, exists, remove;
lifecycle: ensure, start, stop, state, remove;
run, and spawn with and without a TTY;
the file service end to end;
that BOX_UID can write the three mounts of a new box;
isolation: the proxy front door can be reached; the internet, the control port, the API server and the
orchestrator cannot;
inventory.
kind v0.24.0 and later enforces NetworkPolicy with its default CNI (kube-network-policies). Calico is not
necessary in CI.
tests/smoke-test.sh gets a Kubernetes variant that runs in CI, as the Docker variant does.
16. Risks and items to test
Item
Risk
Action
fsGroup on subPath mounts
Some volume types ignore fsGroup, and the agent cannot write its mounts.
The conformance test checks it. Document the supported storage.
Registry outage
With imagePullPolicy: Always, a box possibly does not start although the image is on the node.
Test it on kind. Document IfNotPresent as the alternative.
Box image size in CI
Loading the large box image into kind in each run is slow.
Cache it, or run the Kubernetes job only on changes to the runtime or the manifests.
Idle streams
A turn that waits longer than 4 hours for a permission is lost.
Accepted.
17. Implementation steps
Each step is a separate pull request. Steps 1 to 8 are useful on Docker alone.
Process list with ps in the box; remove docker top. The ps asks for pid,ppid,etimes,args, so the age of a
process stays, which the work list of the dashboard shows. The kill and the background probe then share one
command.
Proxy URL from the runtime, control channel from EGRESS_CONTROL_URL.
The terminal attaches again when its exec ends.
The file service in the box image, used for the review reads. Then the writes and the attachments. Then the disk
usage. These can be two or three pull requests.
The agent set through installAgentSet, and the home setup through boxes-setup; remove agents/<id> and the /boxes/agent mount. The ready state.
The home seed in the entrypoint; remove the helper container.
The Runtime interface with the Docker implementation only. Storage, login, subnet pool, proxy attachment,
container repair and the image footer move behind it. The columns container_id, network_name, subnet and image go away with a migration. No change of behavior.
Conformance tests against Docker in CI.
The Kubernetes implementation, the manifests, and kind in CI.
The review of #94 asked for a concept of the runtime layer before more Kubernetes code is written. This is the concept. The full text is in the collapsed section at the end.
Summary
The orchestrator can run boxes on Docker or on Kubernetes. The code outside the runtime does not know which runtime it uses. The central decisions:
ReadWriteManystorage or pods on one node are not assumed.boxes-files, which is part of the box image. The orchestrator starts it withspawn, as it starts an adapter, and speaks HTTP/2 over its standard input and output. This is the same on both runtimes, so there is one code path and one set of tests. The file service runs as the agent, so thechowncalls of the orchestrator go away.boxes-setuphas run, and the agent set is installed. The agent set travels as a tar stream after the start; the/boxes/agentmount goes away. The adapters, the terminal and the review wait for ready.psin the box instead ofdocker top.imagePullPolicy: Alwaysand does nothing in the refresh loop.Runtimeinterface runs against Docker and against kind in CI. kind v0.24.0 and later enforcesNetworkPolicywith its default CNI.Implementation steps
Each step is a separate pull request. Steps 1 to 8 are useful on Docker alone.
psin the box; removedocker top. (Read the process table with ps in the box #110)EGRESS_CONTROL_URL. (Build the box environment in the core, and reach the proxy control channel by URL #113)installAgentSet, and the home setup throughboxes-setup. The ready state.Runtimeinterface with the Docker implementation only. No change of behavior.@kossmac: this follows the discussion on #94. Your view on the storage assumption (section 4), the pod lifecycle (section 9.4) and the deployment requirements (section 14) is welcome, in particular against the cluster you run Boxes on.
The full concept
Status: draft. Base:
mainat3a16674.1. Summary
Today, the orchestrator runs boxes on Docker only. This concept makes the runtime replaceable, so that boxes can also
run on Kubernetes. The code outside the runtime does not know which runtime it uses.
The central change is the access to the files of a box. Today, the orchestrator reads and writes them on its own
filesystem. On Kubernetes this is not possible, so a file service in the box does this work, on both runtimes. Many
other operations also move into the box or into the box image, so that they work the same way on both runtimes. The
runtime itself keeps only the parts that are different by nature: the container or pod lifecycle, the storage, the
network isolation, the exec transport, the image lifecycle and the inventory.
2. Background
PR #94 added Kubernetes as a second runtime. It was closed after a review, for these reasons:
filesystem of the orchestrator.
docker.ts. On Kubernetes, some of its methods were stubs or threw an error.cfg.RUNTIME, and logins had a separate runtime of their own.with
docker top, and Kubernetes ranpsin the box.An inventory of
mainshows two facts that this concept builds on:ps,kill, the terminal and the adapter all run in the boxthrough
docker exec. The only hostgitis the skill repository pull, and it works on a global directory.fscalls. This is in four places:workspaces.ts(create, check, delete),agents.ts(agent set materialization),attachments.tswithapp.ts(upload and download), and
review/*(tree, repository discovery, file read and write,REVIEW.md). Disk usage isa Node walk.
The first fact stays as it is. The second fact must change.
3. Goals and non-goals
Goals:
Non-goals:
keys everything by the box id and a deployment label, and it has no global state other than the configuration. An
owner of a box would be a field in the database, not a runtime concept.
pull secrets of the cluster apply (see 11).
4. Assumptions
On Kubernetes, the concept assumes only this about storage:
boxes.db, the lock file and the skill repository checkouts.The concept does not assume
ReadWriteMany, a volume that two pods share, or pods on one node. The orchestrator nevermounts the storage of a box.
On Docker, the storage stays as it is: directories in the data volume, bind mounted into the box container.
On Kubernetes, the concept assumes this about the network:
NetworkPolicy, for ingress and for egress. A policy is only an object in theAPI, and a cluster whose CNI does not enforce it gives the boxes no isolation, without an error anywhere.
or the control port of the proxy.
5. Principles
box operation. "Connect the proxy container to a network" is a Docker detail.
runtime. Each difference is a place where a feature can break on one runtime only.
cfg.RUNTIME.the box image.
6. Architecture
flowchart TB subgraph orch["orchestrator"] core["boxes, gateway, review,<br>attachments, agent sets, login"] rt["Runtime interface"] docker["Docker runtime"] k8s["Kubernetes runtime"] end subgraph box["box (container or pod)"] files["file service"] adapter["adapters"] term["terminal (tmux)"] storage[("workspace, home, Nix store")] end core -- "lifecycle, exec, spawn" --> rt rt --> docker rt --> k8s core -. "file operations<br>over a spawned stream" .-> files files --> storage adapter --> storage term --> storageThe orchestrator reaches a box only through the runtime: it starts and stops the box, and it runs processes in it. All
work on the files of a box is done by processes in the box.
What each runtime does in its own way:
box-<id>subPathmountsNetworkPolicy, proxy as a ServiceWhat both runtimes do in the same way, and what is thus not part of the runtime:
fson the data volumechownby the orchestratordocker top(host pids)psin the boxkillin the boxtimeoutin the boxtmux kill-sessionin the box7. The runtime interface
Names are a draft:
BoxSpeccarries the id, the environment and the limits. It does not carry the image, because each runtime usesBOX_IMAGE(see 11). It does not carry the mounts, because they are the same for every box:/workspace,/home/agentand/nix. Each runtime makes them from its own storage.The core builds the full environment of a box as a plain record: the credential placeholders, the git identity, the
proxy variables from
proxyUrl, and the CA certificate with the variables that point to it. Each runtime only convertsthe record to its own format. The environment holds no secret, because the egress proxy replaces the placeholders. On
Kubernetes, it is thus a plain
envin the pod spec, and noSecretobject is necessary.The runtime keys everything by the box id and keeps no database of its own. The columns
container_id,network_name,subnetandimageof theboxestable go away with a migration. The Docker runtime finds the valuesitself: the container by its name
box-<id>, the network by its name, a free subnet at a create from the subnets ofthe existing box networks, and the image with
imageOf. A create that is not complete is the row statuscreating,as today. After
docker network prune, a stopped box gets a new subnet at its next start. Nothing depends on a fixedsubnet.
statehas no value for a missing container. On Docker, an exited container and a container that is gone are bothstopped, andensurecreates a missing one again before the next start. On Kubernetes, a pod in the phaseRunningis
running, a pod inPendingcounts asrunningbecause a start is in progress, and no pod or a pod in a terminalphase is
stopped.spawnserves the adapters, the terminal and the file service. The contract of its stream is in 12.A login container is not a box, so the box methods do not apply to it. The
Loginhandle has its ownspawn, withthe same stream contract, and its own
remove. A box id and a login can thus not be mixed up.The image footer of the box list shows three images with digest, build time and size. For the orchestrator and the
proxy, it shows the image that runs now. For the box, it shows the image that a new box gets (see 11).
EGRESS_PROXY_CONTAINER.imageIDof its own pod, which it finds by the pod name from the downward API,and the
imageIDof the proxy pod.8. File access
8.1 The file service
All access of the orchestrator to the files of a box goes through one file service. This is a long-lived process
in the box. The orchestrator starts it with
spawn, as it starts an adapter, and talks to it over its standard inputand output. The same file service is used on Docker and on Kubernetes. There is one code path and one set of tests.
The file service is a fixed command in the box image,
boxes-files, as the adapters are. The image and theorchestrator come from one repository, so the name is a contract of the repository. The orchestrator starts the file
service as the first exec after
start, because the agent set install (9.2) needs it before the box is ready. Afterthe connection ended, the next file operation starts it again.
Operations, as a draft:
list(dir), capped at 2,000 entriesreview/tree.tsreadDirfindRepos(root, limits): the breadth-first walk with the depth, count and prune limitsreview/repos.tscandidateDirsstat(path): type and sizestatSync,isDirectoryContent-Lengthof a downloadmkdir(path): one level at a time, each level inside the rootattachments.tsdirectory setupcreateExclusive(path, bytes): refused when the file exists.boxes/.gitignoreread(path, cap): content, hash, truncated flagreview/fs.tsreadTextFileREVIEW.mdreadStream(path): chunkscreateReadStreamwrite(path, bytes, expectedHash): atomic, refused when the hash does not matchwriteFileAtomicREVIEW.mdcreate(dir, name, bytes): under the first free namewriteUnderFreeNameremove(path)removeFilesize(): workspace, home and Nix storediskusage.tsinstallAgentSet(tar)agents.tsmaterialization, entrypoint installThe rules of the operations stay as they are in
review/fs.ts,review/tree.tsandreview/repos.ts:realpath, a symlink in the last component is refused, and a newfile must have its parent inside the root.
readdetects a binary file by a NUL byte, and gives the real size.writekeeps the file mode of the old file.listleaves out symlinks.findReposalso accepts a.gitthat is a file.The orchestrator keeps everything that is not file access: the per-box lock around
REVIEW.md, the parsing, the driftlogic, the caches and the HTTP routes.
Wire protocol. The orchestrator and the file service speak HTTP/2 over the standard input and output of the
process. Node's
http2module accepts any byte stream as its connection, on the client side and on the server side,so neither side needs a port, a network path or a dependency. Each operation is one request: the method and the path
name the operation and the file, the body carries the bytes, and the usual status codes report the result, for
example 404 for a missing file and 409 for a hash that does not match. The hash of a file travels in the
ETagheader, and a write sends the expected hash in
If-Match.Each request is its own HTTP/2 stream, so a long download and a short
statrun at the same time on one connection.The flow control of HTTP/2 applies to each stream. A slow reader stops the writer, so a large file stays on disk and
does not go into memory on either side. This holds only when both sides pipe streams. A body that is written in one
call is held in memory first, and Node ends such a stream when it is larger than the session memory limit. The
orchestrator therefore pipes a download into the HTTP reply to the browser, and an upload from the request into the
file service. A test with a 300 MiB file in each direction and a reader at 20 MiB/s kept the memory of both processes
flat.
8.2 Properties
chowncalls ofthe orchestrator go away. The orchestrator no longer reads a tree that the agent controls with its own rights. The
containment check of
review/fs.tsstays in the file service, but only to keep the review inside/workspace, not asa protection of the orchestrator.
therefore treats the file service as it treats an adapter: it applies the caps (the list size, the read cap, the
attachment size) on its own side and ends a request that exceeds them, it validates the shape of each reply before
it uses it, it never turns a reply into a path, a command or a URL of its own, and each request has a timeout.
MAX_ATTACHMENT_MB, default 25 MiB) andREVIEW.md(8 MiB) areheld in memory by the orchestrator, as today. A raw download has no cap and is piped end to end.
connection ends fails. The caller retries a read once. A write is not retried: when the first attempt arrived, the retry finds a
changed hash and reports a conflict that is not real. The caller reports the error.
8.3 Version
The file service is part of the box image. The image and the orchestrator are built from the same repository, but a
running box keeps its image until it stops, so the orchestrator can be newer. The file service therefore states its
protocol version in a header of each response. On a mismatch, the orchestrator refuses the file operations. Older
versions are not supported.
A restart moves the box to the current image only when
BOX_IMAGEresolves to a new image. With a pinned tag, aftera failed pull on Docker, or with
IfNotPresenton kind, the restart gives the same image again. The error thereforenames the version it found and the version it needs, and says that the box needs an image with the current file
service. When the last pull on Docker failed, the error says so too. The box gets the status
errorwith thismessage, as a box with missing storage does (9.1), because without the file service the agent set is not installed
and the box does not become ready.
8.4 Disk usage
The orchestrator asks the file service for the size before each stop, and stores the value with its time. Nothing in a
stopped box changes, so the value stays correct. A running box is measured again at most every 15 minutes, as today. A
box that ends without a stop by the orchestrator, for example after an out-of-memory kill, keeps its last value.
The measurement before a stop has the timeout of a
run. A walk over a large Nix store can take longer on slowstorage. When the walk does not finish in time, the stop goes on, and the box keeps its last value with its old time.
The dashboard shows that time, so the user sees that the value is old.
8.5 Stopped boxes
A file operation starts a stopped box, on both runtimes. Git already works like this. The effect is small:
With a stopped box, the orchestrator can thus only read the stored disk usage and delete the box.
9. The box lifecycle
9.1 Storage
The runtime creates, checks and removes the storage of a box. It never gives the orchestrator a path.
workspaces/<id>,homes/<id>andnix/<id>in the data volume, bind mounted.subPathat/workspace,/home/agentand/nix. NewsubPathdirectories must be writable for
BOX_UID. The plan issecurityContext.fsGroup.The storage is created only when a box is created. When the storage of an existing box is missing, the box is marked
as an error, and the storage is not created again: a new empty home would erase the transcripts of the agent without a
warning. This is the behavior of
requireDirectoriestoday for the workspace and the home.The Nix store is the exception. It holds only installed packages, and nix lays out an empty store at first use, so a
missing one is created again at each start, as today. On Docker, this is an empty directory that belongs to the agent.
On Kubernetes, a missing claim is a missing storage, and a missing
nixdirectory inside the claim is made by thekubelet at the mount.
9.2 Start and the ready state
The inputs that a box gets at its start are:
the CA certificate. They are fixed when the container or the pod is created.
CA to the trust store of Chromium, sets up the browser CLI, and writes the git identity and the credential helpers.
These steps move into one command of the image,
boxes-setup, with the same shell code. The orchestrator runs itwith
runafter the file service is connected. The orchestrator knows only the name, as it knows the adapter names.The image decides what its setup does. A failed step is an error of the box with its output, where today it is a
warning in the container log.
agents/<id>, and the box has no/boxes/agentmount. After thesetup, the orchestrator sends the merged set as a tar stream to
installAgentSetof the file service. The installerdoes what the entrypoint does today: it removes what the last install recorded in
~/.boxes/managed, checks eachmanifest line, and installs the new entries. The entrypoint also installs the skills of the image (
nix,share-app,diagramsand the skill of the browser CLI), and a skill of the same name in the set wins. Theseinstalls read the manifest, so they move into the installer too, and both happen in one step. As today, a change
to a set reaches a box at its next start, and a running box keeps what it has.
A box is ready when:
boxes-setuphas run,The orchestrator drives this order, so nothing writes into the home at the same time as the installer. The entrypoint
does only two things: it seeds the home (9.3) and it holds the container.
The adapters, the terminal and the review wait for "ready", not for "started". On Kubernetes,
startmust wait for thepod to run: scheduling, attaching the volume and pulling the image can take longer than the retries of an adapter
spawn.
9.3 The home seed
A new home must get the files of the home of the image. The mount hides the original, so the image keeps a copy of its
home outside
/home/agent. The copy is a few kilobytes: the shipped home holds only the files of/etc/skel(fromuseradd -m), some empty directories and.claude/settings.json.The entrypoint seeds the home: when the marker file
~/.boxes/seededdoes not exist, it copies the files and writesthe marker. A home that the agent emptied thus stays empty. The entrypoint runs as the agent, so the files belong to
BOX_UIDwithout achown. This also solves #99. The root helper container of today goes away.9.4 Stop and removal
Before a stop, the orchestrator measures the disk usage (8.4). On Docker, a stop stops the container. On Kubernetes, a
stop deletes the pod, and the claim stays.
On Kubernetes,
stopdeletes the pod with the grace period of today, 10 seconds, and returns only when the pod nolonger exists. This is the contract of Docker: after
stop, the box is really stopped.startfirst deletes a pod ofthe box that still exists, for example a terminal pod after an out-of-memory kill or a pod in
Terminatingafter aninterrupted stop, and waits until it is gone. Only then does it create the new pod. The new pod thus never competes
with the old one for the volume. The detach of the volume can still take a moment, and the new pod waits in
Pendingfor it.
On Kubernetes, a box is a bare pod with
restartPolicy: Never, the counterpart ofRestartPolicy: noon Docker. Theorchestrator alone creates and deletes the pod, and no controller makes a pod on its own. When the container ends by
itself, for example by an out-of-memory kill, the kubelet does not restart it. The pod stays in a terminal phase with
its reason, the orchestrator sees it as stopped, and the next start deletes the old pod and creates a new one. An
eviction by the kubelet is the same case: the pod stays as
Failed, and the next start places the box where there isroom.
A node failure is a known limitation. The control plane cannot know whether the lost node still writes to the volume,
so it does not detach it, and the pod stays in
Terminating. The box cannot start on another node until then. Theorchestrator must not force-delete the pod, because that does not release the volume and risks the data. The way out
belongs to the cluster: the operator, or an automation of the cluster, sets the out-of-service taint on the dead node,
and the control plane then deletes the pod and detaches the volume. After that, the box is
stoppedand starts againon another node.
When a box is deleted, the orchestrator calls
removeand thenremoveStorage.9.5 Reconcile and orphan sweep
At boot, the orchestrator compares its database rows with the
inventoryof the runtime: it adopts live boxes, marksmissing ones as stopped, and marks an interrupted create as an error. The periodic sweep removes each object of the
inventory that has no live row. The inventory includes the storage, so a storage left by a failed delete is removed
too. The guard against a sweep of a wrongly mounted data volume stays.
The inventory also includes the login containers, with their creation time. A login container stays behind when the
orchestrator stops during a login. The sweep removes each login container that is older than 15 minutes, as today.
10. Network isolation
The contract:
The proxy does not identify a box. It has one policy for all boxes, keyed by the destination host, and it does not
read the source address. So the network alone keeps the boxes apart from each other and from the orchestrator.
proxy. The subnet pool is an internaldetail of the Docker runtime.
NetworkPolicyobjectsdo the work of the Docker networks. The box policy allows egress only to the proxy pods on port 3128, and to the
cluster DNS when the URL is a name, and it denies all ingress. The orchestrator needs no ingress to a box, because
it uses exec through the API server. The proxy policy allows ingress on port 3128 from the box pods and on port 3129
from the orchestrator pod only. The orchestrator policy is in 14. The proxy is a Service.
The orchestrator reaches the control channel of the proxy at a URL from the configuration (
EGRESS_CONTROL_URL,default
http://boxes-egress-proxy:3129), not at a container name. On Docker, the control channel is safe because itbinds to an address on the Compose network, which the box networks cannot reach. On Kubernetes, the box pods and the
proxy are in one pod network, so the bind address does not protect it. The policy must therefore allow port 3128 only.
The isolation of a running box can break. On Docker, a proxy container that Compose creates again has no connection to
the box networks. On Kubernetes, a policy can be deleted. A periodic loop therefore calls
ensurefor each running box.On Docker, this connects the proxy to the box network again. On Kubernetes, it applies the policy again. A box where
ensurefails shows as a warning in the dashboard, as today.Each call of
ensureruns in the per-box queue of the orchestrator, as a start does. A box can stop between the checkof the loop and the call, and
ensurecan then create the container again. In the queue, this cannot overlap with astart of the same box.
The details page shows the network name and the subnet today. These become runtime facts from
details.11. The image lifecycle
The image lifecycle is a runtime matter. Each runtime does it in its own way. The contract:
BOX_IMAGE_PULL_MINUTES, compare the image idimagePullPolicy: Always)imageID)BOX_IMAGEafter the last pullBOX_IMAGE_PRUNEThe image refresh loop calls
refreshImage. On Kubernetes, this does nothing.Before each start, the orchestrator calls
ensureand thenstart. On Docker,ensurecreates the network and thecontainer again when they are missing, for example after
docker container prune. It also creates the container againwhen its image is not the current image of
BOX_IMAGE. Both happen only when the box does not run. On Kubernetes,ensureonly applies the policy, because each start creates a new pod. The core thus has no image comparison and nocase for a missing container.
On Kubernetes,
imagePullPolicyis a setting. The default isAlways. CI with kind and local development useIfNotPresent, because their images are loaded into the cluster and are in no registry. The pull secrets of the clusterapply, so the orchestrator needs no registry credentials.
Consequences on Kubernetes: a box start needs the registry, and two boxes started at different times can run different
images. The orchestrator sees this in
imageOf.On Kubernetes, the kubelet resolves the tag, so the orchestrator knows a digest only from a pod that started. The image
footer therefore shows the image that the last box start got. When the tag moves after that start, the footer shows the
old build until the next box starts. This is a known difference from Docker.
12. Long-lived streams
The contract:
spawnreturns a stream that can end at any time, and the caller must cope with it.On Docker, an exec stream has no time limit. On Kubernetes, it goes through the API server and the kubelet to the
container runtime, and can end:
(
stream_idle_timeout). CRI-O has no limit by default. The kubelet settingstreamingConnectionIdleTimeouthas noeffect; it was deprecated in 2025 (kubernetes PR 131992).
An idle timeout does not end a stream with traffic. A running turn sends traffic.
How each user of
spawncopes:loads the sessions. An idle timeout can only cut a turn that waits longer than 4 hours for a permission. This is
accepted.
attaches to the same tmux session again, while the WebSocket stays open. tmux keeps the state. Each terminal
connection has its own tmux client session, grouped with the shared session of the box, and that client session
outlives a cut stream but not a shell that the user ended. The reattach therefore uses
tmux attachto the clientsession by its name, never a new session. After an ended exec, the orchestrator waits one second and attaches. When
the session exists, the user sees the same screen. When tmux reports no such session, the user ended the shell, and
the WebSocket closes with "shell ended", as today. When the box is not running, the WebSocket closes with "box
stopped": opening a terminal starts a stopped box, as today, but a reattach never does. When the spawn fails for
another reason, the orchestrator tries once more after five seconds and then closes the WebSocket with the error.
LOGIN_TIMEOUT_MS). An ended stream fails the flow, andthe user starts it again.
An open terminal tab keeps the idle reaper from stopping a box. This is the same on Docker, and it is not part of this
concept.
13. Logins
A login runs a CLI of a harness, for example
claude setup-token, in a short-lived container from the box image. Today,login.tshas a separateLoginRuntimefor this. With this concept, it usesstartLoginof the runtime, andspawnand
removeof theLoginhandle.A login container has the opposite network contract of a box: it reaches the public internet directly, not through
the proxy, and it has no box storage. It does not use the proxy because the proxy refuses a real credential that is
not the placeholder on an intercepted host, and a login exists to obtain one. Its home is temporary, so the credential
material ends with the login container.
Docker gives no way to filter that for one container. This is a known difference.
emptyDirhome in memory, and aNetworkPolicythat allows egress to the internet exceptthe private ranges that the proxy already blocks as destinations (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,169.254.0.0/16and the IPv6 counterparts), plus the cluster DNS. The pod and Service ranges ofnearly every cluster are inside these, so no setting for them is necessary. A cluster with public pod addresses is
not covered. The policy denies all ingress.
14. Kubernetes deployment
The Kubernetes runtime and its manifests must also meet these requirements:
automountServiceAccountToken: falseandenableServiceLinks: false.restrictedPod Security Standard: non-root, no privilege escalation, all capabilities dropped,read-only root filesystem,
seccompProfile: RuntimeDefault. The Docker container has the same hardening today.BOX_MEM_LIMITandBOX_CPUSapply to the box pod./tmpis anemptyDirin memory withthe size of today's tmpfs.
BOX_PIDS_LIMITapplies on Docker only. A pod spec has no stable field for a pids limit. The limit is the kubeletsetting
podPidsLimit, which applies to every pod on the node, and the manifests document it as a requirement forthe node, with the Docker default of 512 per pod, because a fork bomb in a box must not take the node down. A field
per pod,
spec.resources.limits.pids, exists as alpha (KEP-6063, feature gatePerPodPIDLimit). Once it isstable, the runtime sets it from
BOX_PIDS_LIMIT. The implementation opens a ticket that tracks the status of theKEP.
Burstable: its requests are smaller than its limits, because most boxes are idle most of the time,and the scheduler packs them by their idle use. The requests are the settings
BOX_MEM_REQUEST(default 1 GiB) andBOX_CPU_REQUEST(default 0.5 CPU). An idle box with a connected adapter and the file service uses about 400 MiBand far below a tenth of a CPU. Docker has no requests and ignores the two settings. A box that uses more than its
request can be evicted when the node runs out of memory (9.4), and a node can take more boxes than its memory holds
at their full limits. That is the trade the operator makes with the two settings.
NetworkPolicylets only the ingress controller reach the orchestrator API. The API has no authentication of itsown, on both runtimes, and the deployment puts authentication in front of it. On Docker, this is the reverse proxy
of the operator, as today. On Kubernetes, the Ingress carries it, for example an auth annotation of the ingress
controller, or the Ingress is not exposed and the user reaches the API with a port forward. The manifests ship the
Ingress with a placeholder for the authentication, and the documentation says that an Ingress without it is not
safe. The WebSocket upgrade on
/wspasses through it.Recreatestrategy, because of the lock on its data directory.pods/exec, claims and network policies in its namespace, and noother rights.
15. Tests
Runtimeinterface runs against a real Docker and a real kind cluster. Itchecks:
run, andspawnwith and without a TTY;BOX_UIDcan write the three mounts of a new box;orchestrator cannot;
NetworkPolicywith its default CNI (kube-network-policies). Calico is notnecessary in CI.
tests/smoke-test.shgets a Kubernetes variant that runs in CI, as the Docker variant does.16. Risks and items to test
fsGrouponsubPathmountsfsGroup, and the agent cannot write its mounts.imagePullPolicy: Always, a box possibly does not start although the image is on the node.IfNotPresentas the alternative.17. Implementation steps
Each step is a separate pull request. Steps 1 to 8 are useful on Docker alone.
psin the box; removedocker top. Thepsasks forpid,ppid,etimes,args, so the age of aprocess stays, which the work list of the dashboard shows. The kill and the background probe then share one
command.
EGRESS_CONTROL_URL.usage. These can be two or three pull requests.
installAgentSet, and the home setup throughboxes-setup; removeagents/<id>and the/boxes/agentmount. The ready state.Runtimeinterface with the Docker implementation only. Storage, login, subnet pool, proxy attachment,container repair and the image footer move behind it. The columns
container_id,network_name,subnetandimagego away with a migration. No change of behavior.