SandboxClaim
A SandboxClaim requests one codegen sandbox: a network-isolated pod
exposing the full codegen tool surface (shell included) over MCP. The operator
turns a claim into a Deployment, Service and NetworkPolicy, all named
<claim>-sandbox and all owned by the claim, so deleting the claim removes
them.
It works like a PersistentVolumeClaim: the claim asks, and a
SandboxClass defines what it gets.
One claim is one sandbox. A session may hold several claims, distinguished by
handle, up to the ceilings on the owning
AgentRuntime.spec.sandbox.
The operator creates the claims agents use. The runtime asks the operator’s
Sandbox API for a sandbox, and the agent’s
ServiceAccount has no permissions on sandboxclaims at all.
API version
Section titled “API version”apiVersion: omnia.altairalabs.ai/v1alpha1kind: SandboxClaimShort name: sbclaim (e.g. kubectl get sbclaim).
Spec fields
Section titled “Spec fields”| Field | Type | Required |
|---|---|---|
agentRuntimeRef.name | string | Yes, unless enrolment (exactly one of the two) |
classRef.name | string | Yes |
classRef.kind | SandboxClass | ClusterSandboxClass | No (default SandboxClass) |
sessionID | string | Yes |
handle | string (max 127) | No |
targetPath | string (max 512) | No |
mounts[].role | DNS label (max 63): scratch, session, or a configured mount role | No (empty = scratch; at most one entry) |
mounts[].id | string (max 253) | With a configured role; never with role: session |
mounts[].path | string (max 1024) | With a configured role; never with scratch or session (written by the operator) |
mounts[].mustExist | bool | No (written by the operator) |
mount | {role, id, path, mustExist} | No (deprecated form of mounts; not with mounts) |
requestedBy.subject / requestedBy.agent | string (max 253) | No |
declaredBy | string (max 256) | No (written by the operator) |
maxInstances | int32 (min 1) | No (written by the operator) |
enrolment.connection | string (max 253) | For an enrolment |
enrolment.subject | string (max 256) | For a perUser Connection’s enrolment; empty for a shared one |
enrolment.maxDuration | duration, 1m to 20m | No (default 15m) |
apiVersion: omnia.altairalabs.ai/v1alpha1kind: SandboxClaimmetadata: name: my-agent-sbx-7k2qp namespace: my-agents labels: omnia.altairalabs.ai/sandbox-agent-runtime: my-agent omnia.altairalabs.ai/sandbox-session: session-7spec: agentRuntimeRef: name: my-agent classRef: name: codegen-default kind: ClusterSandboxClass sessionID: session-7 handle: codegen/defaultHow agents get a claim
Section titled “How agents get a claim”The runtime calls the operator’s Sandbox API (POST /v1/sandboxes/acquire)
with the AgentRuntime, the session id and the handle it wants. The operator
checks that the caller is that AgentRuntime’s own ServiceAccount, then finds
the claim for (namespace, runtime, session, handle) or creates it. The request
carries no class. It waits up
to 2m45s (under the runtime’s own 3-minute deadline) for the claim to be Bound and returns its endpoint.
A wait that ends unbound, at that deadline or because the runtime hung up,
withdraws the claim it was waiting for when the claim is still Queued (or
has no phase) and mounts nothing: the operator deletes it, on condition that
it has not changed since the wait last read it. A waiter that outlived its
caller would otherwise take the next free slot, oldest first, for a tool call
that has already failed. An admitted (Pending) claim is kept, because its pod
may be one image pull from ready, and so is a claim that mounts anything,
because the runtime acquires such a handle with mustExist. A caller that
asks again joins the back of the queue.
The caller is identified by its ServiceAccount token (a TokenReview), not by
its pod. Runtimes that share one ServiceAccount, as when a Workspace sets
runtime.serviceAccountName, cannot be told apart by the Sandbox API, so each
can ask for and renew the others’ sandboxes. See
Workspace for the trade-off.
A claim the operator creates has these properties:
- An opaque name. It is created with
generateName: <runtime>-sbx-, the runtime part shortened so that<claim>-sandboxfits 63 characters. Nothing parses the name; look claims up by label or handle. - Lookup labels.
omnia.altairalabs.ai/sandbox-agent-runtimeandomnia.altairalabs.ai/sandbox-sessioncarry the runtime and session id. - The sandbox’s class, target and instance cap. The operator reads the
sandbox named by the handle’s
first segment from the
spec.sandboxesof the AgentRuntime’stoolRegistryRef, and copies itsclass,targetPathandmaxInstances(defaulted, so1when unset or whentargetPathis set) onto the claim. A handle naming a sandbox the registry does not declare, or a runtime with no ToolRegistry, is refused (UnknownSandbox). - An owner. The claim has an ownerReference to its AgentRuntime
(
blockOwnerDeletion, not a controller reference). Deleting the AgentRuntime garbage-collects its claims and their sandboxes.
A request for a new handle is refused (MaxPerSession) when the session
already holds spec.sandbox.maxPerSession live claims, whatever their class.
It is refused (MaxInstances) when the session already holds the sandbox’s
maxInstances live claims of that sandbox.
Each operator replica serves the API. Two replicas that create the same
handle at once both check the API server afterwards, and every claim but
the oldest is deleted.
Each replica checks both limits against its own view of the session’s
claims, so two replicas creating different handles at once can both pass
them. The controller, which runs on the leader alone, is what holds the
session to them: it admits the session’s claims oldest first, and leaves the
rest Queued with reason SessionLimitReached (maxPerSession) or
InstanceLimitReached (the claim’s spec.maxInstances). The request waiting
on such a claim deletes it, if it created it, and is refused with
MaxPerSession or MaxInstances, as if its own check had caught it. A claim
already admitted is never refused for these limits. A claim with no
spec.maxInstances, written by an older operator or by hand, is not held to
an instance limit.
agentRuntimeRef
Section titled “agentRuntimeRef”The runtime this sandbox belongs to, in the claim’s own namespace. It is what the NetworkPolicy admits ingress from, and whose ceilings govern how many claims may be bound at once.
A claim naming a runtime whose spec.sandbox.enabled is unset is Failed:
this is a misconfiguration, not resource contention, so it does not queue.
classRef
Section titled “classRef”Which kind of sandbox to build. kind defaults to SandboxClass, looked up in
the claim’s own namespace — the safe default, since a claim cannot then pick
up a platform class it never named. Naming ClusterSandboxClass explicitly is
what reaches a cluster-scoped one.
classRef.kind selects the lookup. A SandboxClass is read only from the
claim’s namespace and a ClusterSandboxClass cluster-wide, with no fallback
between the kinds.
sessionID
Section titled “sessionID”The session this sandbox is for. Constrained to 63 characters and a Kubernetes
label-value pattern, because it becomes a pod label and part of the Deployment’s
immutable selector — the API server rejects a bad value at admission, where the
message reaches whoever typed it, rather than at pod creation where it would not.
Immutable, like agentRuntimeRef and classRef: a claim moved to another
runtime or session would hand it a sandbox it never asked for.
handle
Section titled “handle”The sandbox’s identity within its runtime and session: <sandbox>/<instance>,
two DNS-1123 labels, where the sandbox is one of the ToolRegistry’s
spec.sandboxes. The runtime asks for <sandbox>/default. Immutable: it cannot be
added, removed or changed after the claim is created.
targetPath
Section titled “targetPath”The project this sandbox’s work is promoted into, relative to the workspace
content volume, for example projects/checkout-service. The operator copies
it from the ToolRegistry sandbox’s targetPath when it creates the
claim. When unset, the sandbox is scratch space and a sync-back request for it
is refused.
The destination lives on the claim, not the sync request. The agent decides
when its work is done and asks the operator to promote it; the operator
decides where the work lands, and runs the promotion itself. A targetPath
cannot be added, removed or changed after the claim is created, and only the
operator may create a claim that names one. The work is promoted only once the
operator has recorded ProvenanceVerified on the claim.
mounts
Section titled “mounts”Which workspace directory the sandbox’s /workspace is. Empty, or one entry
with role: scratch, is the claim’s own scratch directory. Any role other
than scratch and session is a configured mount role: with
an id it mounts the directory path names in place, so the agent edits that
directory itself rather than a copy. With the chart’s default project role,
that is the arena project of that id. The class must declare the role in its
mounts. The list holds at most one entry.
path and mustExist are written by the operator when it authorises the
mount. path is the directory, relative to the workspace’s content, that the
role and id resolved to; it is recorded so that what the claim binds stays
put when the role’s configuration changes. mustExist is the role’s setting at
that moment: true fails the bind with MountNotFound when the directory is
not there, false has the operator create it as the claim binds. Both are
empty for scratch and session, whose directories the operator derives from
the claim itself.
role: session, with no id, mounts the directory that every claim of the
same session and handle shares, so a replacement sandbox finds the files the
last one left. The operator sets it for a ToolRegistry sandbox with
workspace: session, and
places the directory from the claim’s own agentRuntimeRef, sessionID and
handle. The class needs only a scratch mount.
spec.mounts is immutable: it cannot be added, removed or changed after the
claim is created. Delete the claim and re-claim instead.
spec.mount is the older single-binding form of the same thing. It is
deprecated but still honoured, with the same immutability, on claims created
before mounts existed; the operator writes only mounts, and a claim may not
set both.
A configured or session mount, a Connection and a targetPath are honoured only
on a claim the operator wrote. Agents cannot write claims, but anyone with RBAC on
sandboxclaims can, and could otherwise mount any configured directory, or any session’s
files, by writing spec.mounts, or have a sandbox’s work promoted into any project by writing
spec.targetPath. So the chart installs a ValidatingAdmissionPolicy,
<release>-sandbox-claim-<hash>, that refuses to create a claim that mounts
anything but scratch, binds any Connection, names a targetPath or is an
enrolment, and refuses
to change the spec of any claim, for every identity but the operator’s
ServiceAccount. The API server authenticates that identity, so it cannot be
claimed the way an annotation or a label can. Labels, annotations and
finalizers stay editable. The CRD also makes agentRuntimeRef, classRef,
sessionID, handle, targetPath, mounts and connections immutable for
everyone, the operator included.
Trusted, precisely: whoever can act as the operator’s ServiceAccount (no other
chart workload runs as it: arena-controller and PromptKit LSP have their own),
whoever can edit cluster-scoped admission policy, and whoever
can write SandboxClaim status, because the operator reads status.phase,
status.addresses and the ProvenanceVerified condition back as its own.
The operator trusts the policy only while it is in force as the chart wrote
it: the policy and its binding must exist, the binding must only deny and must
not narrow what the policy matches, the policy must fail closed, exempt exactly
the operator, and hold exactly the chart’s rules, and neither may have been
edited since it was created. A claim must have been created more than the grace
period (operator.sandboxAPI.claimPolicyGrace, 60s by default) after the later
of the two, because an API server enforces a new policy only once it has loaded
it. Inside that window the Sandbox API waits before creating a claim. The
operator checks all of this once, before the claim first binds, and records the
ProvenanceVerified condition. A verified claim, or one that has already
bound, keeps running if the policy is later deleted, recreated or weakened, and
the Sandbox API still hands it back and replaces it. A claim that fails the
check is failed MountNotGranted, ConnectionsNotGranted or
TargetPathNotGranted.
A claim that was already bound when the policy was first installed keeps
running, and is recorded ProvenanceVerified with reason BoundBeforePolicy.
Its targetPath was never proved, so its work is not synced back until a new
session replaces it.
Anyone with create on SandboxClaims can still create a scratch claim, with
no mount, Connection or targetPath, naming any runtime, session and handle.
No chart role grants that verb: the workspace roles can only read claims.
A cluster without ValidatingAdmissionPolicy (Kubernetes older than 1.30), or an install without the chart’s policy, gets scratch sandboxes only, and none syncs back. If the policy was edited in place, delete it and upgrade the chart to recreate it. The operator writes a claim bound to a configured role in two places: when the Sandbox API creates one for a declared mount authorised for a verified user, and when it replaces a failed one. You cannot write one by hand.
spec: classRef: name: promptarena kind: ClusterSandboxClass mounts: - role: project id: checkout-service path: arena/workspaces/checkout-service/3f2a9c1d7e4b8a06 mustExist: trueMount roles
Section titled “Mount roles”scratch and session are built in: the operator derives their directories
from the claim’s own identity. Every other role a claim, a class or a
declaration names is configuration.
- The operator’s roles come from its
--sandbox-mount-rolesflag, which the chart renders fromoperator.sandboxAPI.mountRoles. The chart’s default is one role,project. The operator refuses to start when the flag is invalid. - A Workspace’s roles, in
spec.sandboxMountRoles, replace the operator’s role of the same name for that workspace, and may add roles of their own.
Each role has these fields:
| Field | Meaning |
|---|---|
name | The role a declaration and a class’s mounts name. A DNS label, not scratch or session |
path | The directory a mount of this role binds, relative to the workspace’s content. Exactly one whole segment is {id}, the id the declaration names, and at most one is {user}, the verified declaring user’s pseudonym, e.g. arena/workspaces/{id}/{user}. It may not be absolute, contain .., or reach or contain sandboxes/, sessions/, arena/sources/, arena/repos/, arena/snapshots/, arena/deployed/, arena/template-sources/, arena/template-indexes/, skills/ or manifests/, the directories sandboxes and other components own, so a bare {id} or arena/{id} is refused. No two roles’ paths may overlap, a directory reached through a symlink is refused, never mounted (checked again on every reconcile: a claim whose directory becomes a symlink after it started fails InvalidMount and its sandbox is stopped), and a claim whose directory lies inside, or around, one a live claim already mounts is refused (InvalidMount). Two claims on the same directory are fine |
mustExist | Default true: a declaration whose directory is not there is refused (MountNotFound). false: the operator creates the directory as the claim binds |
minRole | Default editor. The least workspace role (viewer, editor or owner) a verified user needs to declare the mount |
The chart’s default role:
operator: sandboxAPI: mountRoles: - name: project path: "arena/workspaces/{id}/{user}" mustExist: true minRole: editorDeclared mounts
Section titled “Declared mounts”The runtime can say what a NEW sandbox mounts, in the acquire request’s
declaration.mounts ({role, id}, at most one). The operator applies a
declaration only when that acquire creates the claim; if the handle’s claim
already exists and mounts anything else, the acquire is refused
(DeclarationMismatch) and the claim is left as it is.
scratch needs no authorisation. Any other role is authorised for the
user, never the agent, and the user is named only by a delegation token on
the X-Omnia-Delegation header — the request body cannot name one. The token
must be the operator’s, addressed to omnia/sandbox-api, and minted for this
session (sid), this runtime (act.sub) and this namespace’s Workspace
(wid). A configured role then needs a user the operator verified
(origin: oidc) whose token was minted in the Workspace that owns the
sandbox’s namespace, and whose role there (wrole) is at least the role’s
minRole; workspace content storage (NoWorkspaceStorage otherwise); and,
for a mustExist role, the directory on the volume (MountNotFound
otherwise). A role neither the operator nor the Workspace configures is
refused (UnknownMountRole), and a Workspace role whose path is invalid
refuses every declaration of it (InvalidMountRole). See
Sandbox mount authorization.
The runtime acquires a handle bound to any mount but scratch with
mustExist, which refuses (SandboxEnded) rather than creating a claim when
the handle has none, so a sandbox that was released or reaped is never
silently replaced by empty scratch. On that refusal the runtime creates the
claim again with the mount it had: it declares a declared mount again, for the
same user with a fresh delegation token, or asks plainly for the session
workspace. It refuses a new claim whose mount role differs from the old one.
That memory is the runtime’s, and a runtime that restarts loses it. So the
operator keeps its own: before it deletes a claim that mounts anything
(released, reaped idle, cancelled, or a stale Failed claim) it writes an
ended record, a ConfigMap sandbox-ended-<hash> labelled
omnia.altairalabs.ai/sandbox-ended=true and owned by the AgentRuntime, that
names the handle and the mount role. An acquire that declares nothing and
would create anything but that mount for the handle is refused
SandboxEnded; the client declares the mount again (reopens the session),
which the operator authorises afresh and which consumes the record. A
session-workspace handle is recreated plainly, since that is what ended. The
record lives as long as the session’s record: the session workspace sweeper
deletes it once session-api does not hold the session, and it goes with the
AgentRuntime.
The operator carries a non-scratch mount only from a claim it wrote. When it
replaces a failed claim, re-creates one that vanished mid-wait, or answers a
declaration with a claim that already exists, it carries or returns the mount
only from a claim it wrote. Otherwise it refuses with 403 MountNotGranted,
naming the claim.
A declaration may also name the session it replaces. After this acquire
succeeds, the operator deletes that session’s claim for the same handle, but
only when the claim meets all of these:
- it binds a configured role, with the same role,
idandpathas this session’s claim; - its
declaredByis this request’s verified user; - it carries
ProvenanceVerifiedwith reasonWrittenByOperator.
Otherwise the claim is left for the idle reaper, and the acquire still
succeeds. The dashboard’s builder names the replaced session when it starts a
new conversation, so an abandoned conversation stops holding a
maxConcurrent slot.
connections
Section titled “connections”The Connections bound to this sandbox’s roles. The operator resolves them when it creates the claim: the AgentRuntime’s binding for each role, else the ToolRegistry sandbox’s default. The agent never chooses them. Immutable: they cannot be added, removed or changed after the claim is created.
Only the operator may write a claim with any binding, under the same admission
policy as a configured mount. A claim with bindings the operator did not write
fails with reason ConnectionsNotGranted. Before it binds, the controller
runs the checks again, with required
roles included, and fails the claim with the check’s reason. A Bound claim is
not re-checked.
declaredBy
Section titled “declaredBy”The verified, pseudonymised user whose declaration the operator authorised this claim’s mount for: the subject of the delegation token the Sandbox API verified. The operator writes it when it creates the claim, and it is immutable. Empty when no verified token backed the request. The operator reads it only to release a replaced session’s claim, and only on a claim it is proven to have written.
maxInstances
Section titled “maxInstances”How many instances of this claim’s sandbox (the first segment of its
handle) its session may hold: the ToolRegistry sandbox’s
maxInstances, which the operator
copies when it creates the claim. The controller holds the session to it, so
the limit holds when two operator replicas create instances at once: the
newer claim stays Queued with reason InstanceLimitReached. Immutable. A
claim without it is not held to an instance limit.
enrolment
Section titled “enrolment”Makes the claim an interactive sign-in sandbox for one browserSession
Connection
and one user, rather than an agent’s sandbox. An enrolment claim names no
agentRuntimeRef, binds exactly one Connection in connections (the one
enrolment.connection names, to the class’s session role) and no workspace
(mounts, mount and targetPath are refused). The field is immutable, and
only the operator may create such a claim: anyone else is refused by the
admission policy, and a claim that got past it fails EnrolmentNotGranted.
The operator creates one through its enrolment endpoint, taking the subject
from the vuid claim of the dashboard-signed identity token rather than from
the request, so a person can only enrol themselves. sessionID is the
constant enrolment, and the name is enrol- and a hash of the Connection and
subject, so a (Connection, subject) has one live enrolment at a time.
Nothing that serves a runtime applies to it: no ceiling, idle reaping,
sync-back or session-api. The operator runs it as a bare Pod, not a
Deployment, of a class with view and enrolment:
restartPolicy: Never, andactiveDeadlineSecondsof the enrolment’s whole window (below), a backstop behind the operator’s own deadline. The termination grace is 70 seconds, so the bridge can finish storing a session the person has already submitted.- A single-use write grant for this claim instance (it carries the claim’s
UID), Connection and subject, lasting to the deadline, in the Secret
<claim>-sandbox-enrolment. Only an init container mounts the Secret; it copies the grant onto a memory volume the sandbox container reads, and the bridge deletes it once read. The Secret is deleted once the pod runs. - A NetworkPolicy selecting that pod alone. Ingress: the dashboard’s pods
and, where the chart deploys it, management-api’s (which calls the bridge’s
Done and reload), on the class’s view port only. Egress: the claim’s egress proxy, privacy-api and
the class’s
egress.allowedServices.
Every deadline is measured from one anchor, the claim’s creation:
| When | What happens |
|---|---|
| creation + 5m | the pod must be running, or the claim fails EnrolmentUnschedulable and the pod is deleted. This includes the wait for the egress proxy (WaitingForEgressProxy), so a proxy that never becomes available fails the claim the same way |
pod running + maxDuration | the bridge ends an unfinished enrolment itself |
creation + 5m + maxDuration + 2m | the deadline: the claim fails EnrolmentExpired and the pod is stopped; the grant expires |
| deadline + 10m | the claim is deleted whatever its phase |
The pod, and the grant, are created only once the claim’s egress proxy
Deployment has an available replica, because the bridge loads the sign-in page
through the proxy within seconds of starting. Until then the claim is
Pending with reason WaitingForEgressProxy. The wait spends the five-minute
start allowance, never the person’s maxDuration, which starts when the pod
does. The bridge also tries a start page again, for up to a minute, when it
failed to load for a network reason.
The pod runs within five minutes, so the bridge’s maxDuration always ends
inside the deadline, with two minutes for an upload in flight. The grant
lives to the deadline, at most 27 minutes, inside privacy-api’s 30.
status.expiresAt says which end applies now: the start deadline while the
pod starts, then its start plus maxDuration.
It moves Pending → Bound (the view is up; status.viewAddress) →
Completed when the bridge stored the session and exited, or Failed with
EnrolmentUnschedulable, EnrolmentExpired or EnrolmentFailed (the
bridge, or the grant copy, exited with an error, or the pod disappeared, in
any phase). A finished enrolment is deleted two minutes after it ends, and its
pod and the pod’s log with it, so an EnrolmentFailed message carries what
the failed container said: container <name> exited with code <n> (<reason>): <message>. The bridge and the grant copy (session-fetch) each write their
own exit error as the container’s termination message, starting
browser-bridge failed: or session-fetch failed: , for example
browser-bridge failed: opening the start URL failed after 12 attempt(s): ….
The operator copies only a message with its container’s prefix: at most
1 KiB, on one line (lines joined by |), with anything that looks like a
token, bearer value or cookie redacted as well. Neither binary ever puts its
grant or a cookie in an error. Both containers also run with
terminationMessagePolicy: FallbackToLogsOnError, so a container that
crashed without writing a message (a panic, an OOM kill) leaves the tail of
its log in the pod’s status, where kubectl describe shows it while the pod
exists. That tail can hold browser output and is never copied: the claim
says container <name> exited with code <n> (<reason>); it left no message of its own — see the pod's logs.
The claim’s name repeats for the same Connection and user, so a new claim first
deletes any pod, grant or policy an earlier claim of that name left behind,
never adopting them, and waits (ClearingPreviousEnrolment). An operator without a
grant signer, a configured viewer or its own image fails every enrolment
EnrolmentNotConfigured.
requestedBy
Section titled “requestedBy”Who the runtime believed it was acting for when it created the claim
(subject, the pseudonymised end user, and agent). It is recorded because a
forced promotion happens later, in the operator, after the conversation has
ended. It is not a credential: the operator copies it from the runtime’s
unverified acquire request, and nothing gates on it. On an enrolment it is the
audit pseudonym of the person who asked for it, and the enrolment endpoint
answers GET and DELETE for that person (or an owner) alone.
Status fields
Section titled “Status fields”| Field | Meaning |
|---|---|
phase | Pending, Queued, Bound, Completed (an enrolment only) or Failed |
viewAddress | an enrolment’s live view, host:port, while it is Bound |
expiresAt | when an enrolment ends unsaved: the start deadline while its pod starts, then its start plus maxDuration. Empty on every other claim |
addresses | once bound, one host:port per class port, keyed by the port’s name. A ToolRegistry handler joins its own path to the address of the port it names |
sandboxName | the name of the Deployment, Service and NetworkPolicy |
lastUsedAt | when the claim was last touched; the reaper reads it |
releaseAt | when the operator deletes the claim because its conversation ended; empty while the conversation is live. See Release at conversation end |
lastSyncedVersion | the project version this claim’s work was last promoted to |
lastSyncedAt | when that promotion happened |
syncError | why the most recent promotion attempt failed, if it did |
volumeSubPath | where the claim’s /workspace lives on the workspace volume, fixed when the claim is first reconciled |
conditions | the detail behind the phase; ProvenanceVerified once the operator proved it wrote a claim that mounts a configured role, binds a Connection or names a targetPath; InsecureEgressTestHosts=True (a bad state) when its egress proxy runs the test-only fixture switch (see Connection) |
Phases
Section titled “Phases”Pending— admitted by the ceiling; the sandbox is being created, or its pod has not reported a ready replica yet.status.addressesis not published until it has:Boundpromises the addresses are dialable, so a claim that stamped it the moment the objects existed would hand the agent an address that races the image pull. A claim whose workspace content PVC does not exist yet is alsoPending, with reasonWaitingForStorage: the operator creates the PVC for a Workspace with storage enabled and builds the sandbox once it appears. A claim whose sandbox pod the scheduler cannot place (no node has room for its resource requests, say) isPendingwith reasonUnschedulable, and the message is the scheduler’s own. It keeps waiting: the pod may be placed once a node is added or another sandbox goes. A tool call that times out waiting for it quotes that message.Queued— a limit is full, and theReadyreason says which:CeilingReachedfor the runtime’smaxConcurrent,SessionLimitReachedfor itsmaxPerSession,InstanceLimitReachedfor the claim’smaxInstances. The Sandbox API waits on the first and refuses the other two (see How agents get a claim). When a claim of the same runtime is deleted or fails, the controller at once re-checks the oldest waiters, as many as there are free slots. Every waiter is also re-checked every 10 seconds.Bound—status.addressesare dialable. For an enrolment, the view is up atstatus.viewAddress. The address is the sandbox’s Service, which kube-proxy starts routing a moment after the pod is ready: for up to a few hundred milliseconds afterBound, a connection to it can be refused. The runtime waits up to 30 seconds for a freshly bound claim’s port to accept a connection before it sends the first tool call, so no call is sent into that gap and none is sent twice.Completed— an enrolment stored its session. Terminal; the claim is deleted two minutes later.Failed— terminal for the claim. No such class, the runtime forbids sandboxes, the namespace will never have a workspace content PVC (NoWorkspaceStorage: no Workspace owns it, its storage is disabled, or the install provisions no PVC, as with a direct NFS mount), the claim asks a class with nofilesystem.workspacefor a mount or sync-back target (WorkspaceNotSupported), a bound Connection role’s session arguments do not render (InvalidClass), the mount was refused (see Mount refusals), the Connection bindings were not written by the operator or do not check, the API server rejected the sandbox objects, or the claim was abandoned (see Reaping). When an acquire finds the claimFailed, the operator deletes it and creates one replacement with the same handle, mount andrequestedBy, if the operator wrote the failed claim, so a claim bound to a configured role is never replaced by a scratch one. The replacement takes the sandbox’s currentclass,targetPathand Connection bindings. A second failure is returned to the runtime (SandboxFailed) with the claim’sReadymessage. A claim you created by hand, you delete and recreate yourself.
Mount refusals
Section titled “Mount refusals”A refused mount fails the claim with one of these reasons on its Ready
condition. The controller creates no pod for it.
| Reason | Cause |
|---|---|
MountNotGranted | the mount (spec.mounts, or spec.mount) names a configured role or a session workspace, but nothing proves the operator wrote the claim: the admission policy is missing or not in force, or the claim predates it |
MountNotPermitted | the class does not declare the mount’s role in mounts |
MountNotFound | the mount’s mustExist is true and no directory exists at its path on the workspace content volume |
SessionWorkspaceUnavailable | the mount is a session workspace, and the operator runs without --workspace-content-path, so it has nowhere to keep the session’s files |
InvalidMount | the mount does not resolve to a path on the volume, or status.volumeSubPath records a path this claim could not have been given |
Reaping
Section titled “Reaping”Every claim that has a phase is judged against how long it has gone untouched
(lastUsedAt). While the agent calls sandbox tools, the runtime asks the
operator to renew lastUsedAt on the claim each call used
(POST /v1/sandboxes/{name}/touch). The operator writes it at most once per
half the idle timeout, and the controller stamps it on every phase change.
What happens to a stale claim depends on its phase:
Bound— stale after the runtime’sidleTimeout. The operator first forces a sync-back of the sandbox’s work to its target project (best-effort; a failure never blocks the reap), then deletes the claim, taking its sandbox with it.Pending— stale after the larger ofidleTimeoutand 10 minutes. It is markedFailedwith reasonSandboxNeverReady: a pod that never becomes ready would otherwise hold a ceiling slot indefinitely.Queued— stale after the larger ofidleTimeoutand 10 minutes. It is markedFailedwith reasonQueueWaitExceeded: waiters are admitted oldest-first, so an abandoned one would otherwise take the next free slot for a conversation that has ended.Failed— stale after the larger ofidleTimeoutand 10 minutes, and then deleted, so failed claims do not accumulate without bound.
The 10-minute floor keeps a short idleTimeout from deleting claims whose pods
are still pulling their image.
A claim with no lastUsedAt is never reaped. Unset means “not measured”, and
treating it as infinitely idle would delete a sandbox an agent is mid-edit
on.
Release at conversation end
Section titled “Release at conversation end”A conversation ends when its WebSocket connection closes and its last turn
has finished (or when a parked realtime session expires). The facade then
tells the runtime (the EndConversation RPC). The runtime reports the end to
the Sandbox API (POST /v1/sandboxes/release with {agentRuntime, sessionId}) if the conversation asked it for a sandbox. The operator then sets
status.releaseAt on each of the session’s live claims to now plus the
AgentRuntime’s
spec.sandbox.releaseGrace (2
minutes by default), or, for a claim that mounts a project or the session
workspace, spec.sandbox.mountReleaseGrace (5 minutes by default). When
releaseAt passes, the controller deletes the claim. It first forces a sync-back of a Bound claim, as the idle reaper does,
then reads the claim again and deletes it only if it is still due. A Failed
claim is left to the reaper.
Conversations over the REST and A2A facades have no connection to close and
send no end, so their claims are never released this way and go at
idleTimeout.
Releasing a claim that mounts a project or the session workspace loses no
files: they live on the workspace volume. When the session needs the sandbox
again, the runtime creates the claim again with the same mount (see
mustExist above), and the new sandbox mounts the same files after a cold
start. What is lost is the pod’s own state: caches, and anything written
outside the mount.
A claim that has never had a sandbox when its conversation ends (no phase,
Queued, or Pending without ever having bound) is not marked: the operator
deletes it at once. A claim that bound and dropped back to Pending is
released after its grace like a Bound one, its work synced back first. Left
alone, it would take the next free slot for a conversation that has ended.
The session’s next acquire creates it again.
Any use of the session’s sandboxes within the grace clears releaseAt and
keeps the claim:
- a message of the session opening, which the runtime reports with
cancel: true. A runtime pod sends it on the first message it sees for a session, so a conversation resumed on another pod withdraws a release the first pod scheduled; - an acquire of the claim, for a tool call or an eager or declared prepare;
- a renewal after a sandbox tool call (
POST /v1/sandboxes/{name}/touch), whatever the renewal throttle says.
The deadline lives on the claim, so a runtime that restarts during the grace still has the conversation released. The idle reaper still applies to every claim.
SandboxClass and ClusterSandboxClass
Section titled “SandboxClass and ClusterSandboxClass”One schema, two kinds — the Issuer/ClusterIssuer split. SandboxClass
(short name sbc) is namespaced so a tenant can define its own;
ClusterSandboxClass (short name csbc) is the platform’s.
| Field | Type | Required |
|---|---|---|
image | string | Yes |
imagePullPolicy | Always | IfNotPresent | Never | No (default IfNotPresent) |
args | []string (at most 64) | No. The image’s arguments |
env | []{name, value} (at most 64) | No. Literal values only; the proxy variables are refused |
ports | []{name, port} (1 to 8) | Yes. A ToolRegistry handler addresses one by name |
readiness | {port, httpGet.path} | No (default: a TCP dial of the first port). Probed every second from pod start; ten consecutive failures take a running sandbox out of its Service |
filesystem.workspace.mountPath | string | No. Absent gives the sandbox no workspace |
filesystem.readOnlyRoot | bool | No (default false) |
filesystem.scratch | []{name, mountPath, sizeLimit, medium} (at most 8) | No. sizeLimit is required and positive; medium is Disk (default) or Memory |
resources | ResourceRequirements | Yes, unless trusted — see below |
trusted | bool | No (default false) |
runtimeClassName | string | No (default: the cluster’s) |
nodeSelector | map | No |
tolerations | []Toleration | No |
egress.allowedServices | []{name, namespace, ports} | No |
egress.dns | bool | No (default false) |
mounts | []{role, access} | No (default: a scratch mount only). role is scratch or a configured mount role, a DNS label other than session; access is ReadWrite (default) or ReadOnly |
connections | []{role, required, session} | No. The roles a sandbox of this class offers for Connections, at most 16. A role is an opaque name; required: true refuses a sandbox with the role unbound; session says how a bound browserSession reaches the image (see connections[].session) |
view.port | string | No. The port, by name, that serves the sandbox’s live view. It must be one of ports |
enrolment.args | []string (1 to 32) | No. Makes the class usable for an enrolment; needs view. See Enrolment classes |
Every sandbox pod, whatever its class, gets the restricted Pod Security
Standard, seccomp RuntimeDefault, no service-account token, and a
NetworkPolicy that denies egress except to what egress names. The fields
below are what a class adds to that.
The pod shape
Section titled “The pod shape”A class is a backend: it says how to run its image, and nothing about the
image’s tools or the protocol on each port. Omnia’s code knows nothing about
any image, so the class declares all of it: args, env, ports,
readiness and filesystem.
portsare what a ToolRegistry handler addresses by name. A bound claim reports onehost:portper port instatus.addresses, and the handler’s client adds its own scheme and path.portscannot be changed once the class exists, and neither can whether it has afilesystem.workspace: live claims depend on both. Create a new class instead. A handler naming a port its sandbox’s class does not declare is reported on its ToolRegistry asSandboxesValid=False.filesystem.workspacemounts the claim’s directory of the workspace volume (its own scratch, its session’s, or a configured role’s, such as a project) atmountPath. A class without it gets no workspace volume: a claim that setsmountsortargetPathfails withWorkspaceNotSupported, so a ToolRegistry sandbox whose class has no workspace must not settargetPathorworkspace: session, and sync-back refuses its claims (409, “no workspace”).filesystem.scratchare emptyDir volumes, each bounded by itssizeLimit.medium: Memorykeeps a volume’s contents off the node’s disk and counts it against the container’s memory limit.envholds literal values only: a ClusterSandboxClass serves every namespace and cannot reference a Secret in any of them. Credentials reach a sandbox through Connections. Every variable a class sets is reserved from its Connections, and so is every directory underHOMEthat the class’s own variables point into.
Enrolment classes
Section titled “Enrolment classes”A class runs enrolments when it declares view, enrolment, a
connection role with a session (the one an enrolment binds; it fetches
nothing, so session: {} will do), and no filesystem.workspace. Its own
class, not the browser class, because ports are immutable. A claim naming a
class that lacks enrolment, view or a connection role with a session
fails ClassNotInteractive; a class with a filesystem.workspace fails
WorkspaceNotSupported. The chart ships
one, browser-enrol (sandbox.browserClass.enrolment), on the browser
class’s image.
enrolment.args are appended to args for each enrolment. The operator adds
no argument of its own. Each is a Go text/template over:
| Field | Value |
|---|---|
.Connection | the Connection’s name |
.StartURL | its browserSession.signInURL, or first signed-out URL |
.StateDomains | the registrable domain (eTLD+1) of each of its hosts, comma-separated |
.MaxDuration | the claim’s maxDuration, as a Go duration |
.StateEndpoint | privacy-api’s enrolment route, by cluster IP |
.TokenFile | where the single-use write grant is delivered |
An argument that renders empty is dropped; one that does not render fails the
claim InvalidClass. The chart’s class passes them to the browser bridge’s
interactive flags (-interactive -connection -start-url -state-domains -max-duration -state-endpoint -state-token-file).
connections[].session
Section titled “connections[].session”A role that takes a browserSession Connection says how the stored session
reaches the image. A role without it refuses one (ConnectionKindNotAccepted).
stateFileis wheresession-fetch, an init container, writes the session before the sandbox starts. It must lie under one of the class’s scratch volumes. Absent fetches nothing.argsare appended to the class’sargswhen a session is bound. Each is a Go template over.Connection,.SignedOutPrefixes(comma-separated) and.StateFile(empty when nothing is fetched); an argument that renders empty is dropped.
resources.limits must set both cpu and memory, enforced by CEL on the
CRD: a class setting neither, or only one, is refused at admission. The pod runs
agent-directed code, and code that loops or forks without a limit takes the node
down. Requests stay optional.
trusted: true makes resources.limits optional, and changes nothing
else. It declares that the class runs first-party code rather than the
agent’s, so the namespace’s LimitRange can stand in for explicit bounds — a
class the agent’s tools can reach needs the explicit limits instead. Setting
it needs no separate RBAC: whoever may create a SandboxClass may set it,
the same boundary that governs choosing the image.
runtimeClassName names a
RuntimeClass,
such as gVisor or Kata where the cluster runs one, putting a boundary between
agent-directed code and the host kernel. Omnia requires neither and does not
check the name: a class naming a RuntimeClass the cluster does not have is
admitted, and the claim fails when its pod never becomes ready. See
Stronger isolation
for when to use one.
An absent egress block means the NetworkPolicy carries no egress rules,
which Kubernetes reads as deny everything, DNS included. egress.allowedServices[].name
is a Service object name in the claim’s namespace (or .namespace if set).
The operator resolves it with a direct read of that Service, not a label
match, and scopes the resulting rule to that Service’s own pod selector and
ports (translated to the container’s targetPort). egress.dns defaults to
false because the cluster resolver forwards what it cannot answer, making
DNS a channel out. Set it only when an allowedServices entry needs to be
reached by name rather than ClusterIP. See Sandbox security → What a sandbox
can reach for what
this does and does not prove.
apiVersion: omnia.altairalabs.ai/v1alpha1kind: ClusterSandboxClassmetadata: name: codegen-defaultspec: image: ghcr.io/altairalabs/codegen-sandbox:latest args: ["-addr=:8080", "-workspace=/workspace"] env: - {name: HOME, value: /sandbox-cache} - {name: GOCACHE, value: /sandbox-cache/go-build} ports: - {name: mcp, port: 8080} filesystem: workspace: {mountPath: /workspace} scratch: - {name: cache, mountPath: /sandbox-cache, sizeLimit: 4Gi} # Mandatory. Without limits this class is refused at admission. resources: requests: {cpu: 50m, memory: 128Mi} limits: {cpu: "1", memory: 512Mi} # Where the cluster runs a sandboxed runtime. Omit for the cluster default. runtimeClassName: kata-containers egress: allowedServices: - name: artifact-mirror ports: [443] dns: trueSee also
Section titled “See also”- Connection — the external services a sandbox binds to its class’s roles
AgentRuntime.spec.sandbox— the ceilings and idle timeout every claim is checked against- Sandbox security — what a sandbox can and cannot reach, and why
- Codegen sandbox isolation — the codegen workspace and how its work is promoted