Skip to content

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.

apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SandboxClaim

Short name: sbclaim (e.g. kubectl get sbclaim).

FieldTypeRequired
agentRuntimeRef.namestringYes, unless enrolment (exactly one of the two)
classRef.namestringYes
classRef.kindSandboxClass | ClusterSandboxClassNo (default SandboxClass)
sessionIDstringYes
handlestring (max 127)No
targetPathstring (max 512)No
mounts[].roleDNS label (max 63): scratch, session, or a configured mount roleNo (empty = scratch; at most one entry)
mounts[].idstring (max 253)With a configured role; never with role: session
mounts[].pathstring (max 1024)With a configured role; never with scratch or session (written by the operator)
mounts[].mustExistboolNo (written by the operator)
mount{role, id, path, mustExist}No (deprecated form of mounts; not with mounts)
requestedBy.subject / requestedBy.agentstring (max 253)No
declaredBystring (max 256)No (written by the operator)
maxInstancesint32 (min 1)No (written by the operator)
enrolment.connectionstring (max 253)For an enrolment
enrolment.subjectstring (max 256)For a perUser Connection’s enrolment; empty for a shared one
enrolment.maxDurationduration, 1m to 20mNo (default 15m)
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SandboxClaim
metadata:
name: my-agent-sbx-7k2qp
namespace: my-agents
labels:
omnia.altairalabs.ai/sandbox-agent-runtime: my-agent
omnia.altairalabs.ai/sandbox-session: session-7
spec:
agentRuntimeRef:
name: my-agent
classRef:
name: codegen-default
kind: ClusterSandboxClass
sessionID: session-7
handle: codegen/default

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>-sandbox fits 63 characters. Nothing parses the name; look claims up by label or handle.
  • Lookup labels. omnia.altairalabs.ai/sandbox-agent-runtime and omnia.altairalabs.ai/sandbox-session carry 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.sandboxes of the AgentRuntime’s toolRegistryRef, and copies its class, targetPath and maxInstances (defaulted, so 1 when unset or when targetPath is 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.

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.

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.

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.

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.

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.

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: true

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-roles flag, which the chart renders from operator.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:

FieldMeaning
nameThe role a declaration and a class’s mounts name. A DNS label, not scratch or session
pathThe 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
mustExistDefault true: a declaration whose directory is not there is refused (MountNotFound). false: the operator creates the directory as the claim binds
minRoleDefault 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: editor

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, id and path as this session’s claim;
  • its declaredBy is this request’s verified user;
  • it carries ProvenanceVerified with reason WrittenByOperator.

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.

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.

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.

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.

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, and activeDeadlineSeconds of 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:

WhenWhat happens
creation + 5mthe 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 + maxDurationthe bridge ends an unfinished enrolment itself
creation + 5m + maxDuration + 2mthe deadline: the claim fails EnrolmentExpired and the pod is stopped; the grant expires
deadline + 10mthe 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.

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.

FieldMeaning
phasePending, Queued, Bound, Completed (an enrolment only) or Failed
viewAddressan enrolment’s live view, host:port, while it is Bound
expiresAtwhen an enrolment ends unsaved: the start deadline while its pod starts, then its start plus maxDuration. Empty on every other claim
addressesonce 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
sandboxNamethe name of the Deployment, Service and NetworkPolicy
lastUsedAtwhen the claim was last touched; the reaper reads it
releaseAtwhen the operator deletes the claim because its conversation ended; empty while the conversation is live. See Release at conversation end
lastSyncedVersionthe project version this claim’s work was last promoted to
lastSyncedAtwhen that promotion happened
syncErrorwhy the most recent promotion attempt failed, if it did
volumeSubPathwhere the claim’s /workspace lives on the workspace volume, fixed when the claim is first reconciled
conditionsthe 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)
  • Pending — admitted by the ceiling; the sandbox is being created, or its pod has not reported a ready replica yet. status.addresses is not published until it has: Bound promises 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 also Pending, with reason WaitingForStorage: 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) is Pending with reason Unschedulable, 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 the Ready reason says which: CeilingReached for the runtime’s maxConcurrent, SessionLimitReached for its maxPerSession, InstanceLimitReached for the claim’s maxInstances. 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.addresses are dialable. For an enrolment, the view is up at status.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 after Bound, 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 no filesystem.workspace for 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 claim Failed, the operator deletes it and creates one replacement with the same handle, mount and requestedBy, 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 current class, targetPath and Connection bindings. A second failure is returned to the runtime (SandboxFailed) with the claim’s Ready message. A claim you created by hand, you delete and recreate yourself.

A refused mount fails the claim with one of these reasons on its Ready condition. The controller creates no pod for it.

ReasonCause
MountNotGrantedthe 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
MountNotPermittedthe class does not declare the mount’s role in mounts
MountNotFoundthe mount’s mustExist is true and no directory exists at its path on the workspace content volume
SessionWorkspaceUnavailablethe mount is a session workspace, and the operator runs without --workspace-content-path, so it has nowhere to keep the session’s files
InvalidMountthe mount does not resolve to a path on the volume, or status.volumeSubPath records a path this claim could not have been given

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’s idleTimeout. 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 of idleTimeout and 10 minutes. It is marked Failed with reason SandboxNeverReady: a pod that never becomes ready would otherwise hold a ceiling slot indefinitely.
  • Queued — stale after the larger of idleTimeout and 10 minutes. It is marked Failed with reason QueueWaitExceeded: 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 of idleTimeout and 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.

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.

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.

FieldTypeRequired
imagestringYes
imagePullPolicyAlways | IfNotPresent | NeverNo (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.mountPathstringNo. Absent gives the sandbox no workspace
filesystem.readOnlyRootboolNo (default false)
filesystem.scratch[]{name, mountPath, sizeLimit, medium} (at most 8)No. sizeLimit is required and positive; medium is Disk (default) or Memory
resourcesResourceRequirementsYes, unless trusted — see below
trustedboolNo (default false)
runtimeClassNamestringNo (default: the cluster’s)
nodeSelectormapNo
tolerations[]TolerationNo
egress.allowedServices[]{name, namespace, ports}No
egress.dnsboolNo (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.portstringNo. 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.

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.

  • ports are what a ToolRegistry handler addresses by name. A bound claim reports one host:port per port in status.addresses, and the handler’s client adds its own scheme and path. ports cannot be changed once the class exists, and neither can whether it has a filesystem.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 as SandboxesValid=False.
  • filesystem.workspace mounts the claim’s directory of the workspace volume (its own scratch, its session’s, or a configured role’s, such as a project) at mountPath. A class without it gets no workspace volume: a claim that sets mounts or targetPath fails with WorkspaceNotSupported, so a ToolRegistry sandbox whose class has no workspace must not set targetPath or workspace: session, and sync-back refuses its claims (409, “no workspace”).
  • filesystem.scratch are emptyDir volumes, each bounded by its sizeLimit. medium: Memory keeps a volume’s contents off the node’s disk and counts it against the container’s memory limit.
  • env holds 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 under HOME that the class’s own variables point into.

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:

FieldValue
.Connectionthe Connection’s name
.StartURLits browserSession.signInURL, or first signed-out URL
.StateDomainsthe registrable domain (eTLD+1) of each of its hosts, comma-separated
.MaxDurationthe claim’s maxDuration, as a Go duration
.StateEndpointprivacy-api’s enrolment route, by cluster IP
.TokenFilewhere 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).

A role that takes a browserSession Connection says how the stored session reaches the image. A role without it refuses one (ConnectionKindNotAccepted).

  • stateFile is where session-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.
  • args are appended to the class’s args when 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/v1alpha1
kind: ClusterSandboxClass
metadata:
name: codegen-default
spec:
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: true