Repository navigation
feat: warmpool support for OpenShell sandboxes #1447
Description
Problem Statement
OpenShell sandboxes have a 5–15s cold start time due to sequential pod scheduling, workspace PVC seeding, supervisor initialization, and gateway registration. This makes OpenShell unsuitable for latency-sensitive workloads where users expect near-instant sandbox availability. Pre-warming sandboxes before a user request arrives would eliminate the dominant cold start costs, but the current architecture tightly couples sandbox identity to pod creation time, making external warmpool implementations impossible without gateway changes.
Technical Context
OpenShell already uses agents.x-k8s.io/v1alpha1 Sandbox as its Kubernetes backend — the higher-level SandboxWarmPool/SandboxTemplate/SandboxClaim resources from the same API group are a natural external interface, but OpenShell has no awareness of them today.
Two structural properties of the current design prevent external warmpool integration:
-
Static identity binding —
OPENSHELL_SANDBOX_IDis injected as a pod env var at creation time (driver.rs:1413-1415). It cannot be changed in a running pod. A warm pod must have itssandbox_idassigned at boot, which means the gateway must know about it before the pod starts. -
Gateway store gate — on boot, the supervisor calls
ConnectSupervisorwith itssandbox_id. The gateway rejects any stream whose ID is not in its persistent store (supervisor_session.rs:572). Without a pre-existing store record, the supervisor is permanently stuck inPROVISIONING.
Both blockers mean an external controller creating SandboxWarmPool/SandboxTemplate objects cannot produce a usable warm sandbox without calling OpenShell's CreateSandbox API — which immediately schedules pod creation, defeating the purpose.
The good news: the per-user dynamic work (policy and credentials) is already hot-reloadable. The supervisor polls for config updates (lib.rs poll loop); provider credentials are live-updated. Only the pre-allocation and claim mechanism is missing.
Affected Components
| Component | Key Files | Role |
|---|---|---|
| Gateway compute layer | crates/openshell-server/src/compute/mod.rs |
Manages sandbox lifecycle, phases, supervisor sessions |
| Kubernetes driver | crates/openshell-driver-kubernetes/src/driver.rs |
Creates agents.x-k8s.io Sandbox objects with static env |
| Supervisor session handler | crates/openshell-server/src/supervisor_session.rs |
Validates and registers supervisor connections |
| Sandbox supervisor | crates/openshell-sandbox/src/lib.rs |
Startup sequence, policy poll loop, ConnectSupervisor |
| Persistence layer | crates/openshell-server/src/persistence/ |
Source of truth for sandbox records |
Technical Investigation
Cold start breakdown
| Phase | Estimate | Pre-warmable? |
|---|---|---|
| Pod scheduling | 1–3s | Yes |
| Image pull (no cache) | 0–60s | Yes (cached images) |
Init container: copy-self |
0.5–2s | Yes |
| Init container: workspace PVC seed | 1–10s | Yes — biggest win |
| Supervisor boot: netns + proxy + SSH | 1–4s | Yes |
ConnectSupervisor handshake |
0.2–1s | Yes |
| Policy hot-reload (per user) | ~0.5s | No — user-specific |
| Credential injection | ~0s | No — already live |
What would need to change
Gateway: introduce a warm_idle sandbox state — pre-allocated records not visible to users via ListSandboxes. A pool reconciler maintains N warm slots per template. On CreateSandbox, gateway claims the oldest ready warm slot instead of creating a new pod.
Kubernetes driver: pre-creation path that allocates a sandbox_id, inserts the store record, and creates the pod — without a user request triggering it.
Supervisor: no changes needed. It already supports hot-reload of policy and live credential updates.
New RPC (optional): ClaimSandbox(warm_sandbox_id, user_metadata, policy) — or reuse UpdateConfig on an existing READY sandbox.
Relationship to agents.x-k8s.io SandboxWarmPool
SandboxWarmPool + SandboxTemplate + SandboxClaim could serve as the external Kubernetes API surface for this feature (the pool controller lives outside OpenShell, drives the pre-allocation via CreateSandbox with a warm flag). Alternatively, OpenShell implements the pool natively. This is a key design decision for human review.
Proposed Approach
Introduce a WarmPool configuration object in the gateway store (alongside the existing TOML config from RFC 0003). Each pool entry specifies a target replica count, a sandbox template reference (image, resource profile, base policy), and an update strategy (Recreate or OnReplenish). This allows operators to resize pools at runtime without a gateway restart.
The gateway runs a pool reconciler loop that maintains the target number of warm_idle sandbox records and their corresponding pods. Warm slots are pre-created with a gateway-assigned sandbox_id and a base policy; the supervisor boots and registers normally via ConnectSupervisor, but the slot is not visible to users via ListSandboxes until claimed.
On CreateSandbox, the gateway first attempts to claim a matching warm slot — matching on template reference (image, resource profile). If a slot is available, it hot-reloads the user-specific policy onto the already-running supervisor before flipping visibility to the requesting user. If no warm slot is available, it falls back to a cold create. The pool reconciler replenishes the claimed slot asynchronously.
Warm slots count against a configurable pool-level resource budget, separate from per-user quota. Unclaimed slots expire after a configurable TTL to prevent policy drift — on expiry the slot is deleted and the reconciler creates a fresh replacement.
The agents.x-k8s.io SandboxWarmPool/SandboxClaim CRDs are a natural external API surface for this feature on Kubernetes deployments, but whether OpenShell implements native pool management or exposes hooks for an external controller remains an open design decision.
Scope Assessment
- Complexity: High
- Confidence: Medium — the hot-reload path is proven; the pre-allocation and claim mechanism needs design
- Estimated files to change: 8–12
- Issue type:
feat
Configuration Surface — Open Questions
Warmpool is only operationally useful if it is configurable without redeploying the gateway. The following questions need answers before implementation:
-
Where does pool configuration live? The gateway already has a TOML config file (RFC 0003). Is pool size / strategy a static gateway-level setting in TOML, a dynamic API resource (CRD or gateway object), or a parameter on
CreateSandbox? Static TOML is simpler but requires a gateway restart to resize the pool. -
What is the configuration granularity? Global per-gateway, per-namespace, per-sandbox-template, or per-provider? A global pool cannot serve heterogeneous workloads (different images, policies, resource profiles) without over-provisioning.
-
What update strategy is needed? The
agents.x-k8s.ioSandboxWarmPoolspec definesRecreate(stale pods deleted immediately) andOnReplenish(replaced only when adopted or manually deleted). Does OpenShell need both, or is one sufficient for the initial implementation? -
How are warm slots counted against quota? Warm sandboxes consume real cluster resources. Do they count against a namespace or user quota before they are claimed? Who "owns" an unclaimed warm slot for billing/chargeback purposes?
-
What is the TTL for a warm slot? A sandbox pre-warmed against a policy version N becomes stale when the default policy is updated. Should unclaimed slots expire after a configurable TTL, be evicted on policy change, or be lazily refreshed at claim time via hot-reload?
-
How is target pool size controlled at runtime? Static config requires a gateway restart. A dynamic API (e.g., a
WarmPoolobject in the gateway store) would allow operators to resize the pool without downtime — but adds API surface and reconciliation logic.
Risks & Open Questions
- Identity isolation between warm slots — a warm sandbox has run init containers and may have stale workspace state. Does claiming a warm slot give a user a clean environment, or can PVC contents leak between users?
- Policy at boot vs. at claim — the supervisor loads policy on boot from
GetSandboxConfig. A warm pod has a policy before it knows its future user. The claim step must hot-reload policy atomically before the user can connect. Is the window between READY and policy-reload safe? - External controller vs. native pool — should warmpool be an OpenShell-native feature or driven externally via
SandboxWarmPool/SandboxClaimCRDs? Native is simpler operationally; external is more composable with the broaderagents.x-k8s.ioecosystem. - Non-Kubernetes drivers — the VM driver and Docker driver have no pool concept. Does warmpool apply only to K8s deployments?
Test Considerations
- E2e test: measure time from
CreateSandboxcall to first SSH byte with warm vs. cold path - Unit test: gateway pool reconciler — claim selection, slot replenishment, pool exhaustion fallback to cold create
- Integration test: policy hot-reload correctness — verify user A's policy is not visible after warm slot is claimed by user B
- Existing pattern:
mise run e2ecovers the sandbox lifecycle; warmpool tests should extend that harness
Created by spike investigation. Use build-from-issue to plan and implement.
Activity
ryanzhang-oss commentedon May 21, 2026
second to this, the better way to integrate with sandbox is through the SandboxClaim instead of a cold one.
ryanzhang-oss commentedon May 26, 2026
I took a closer look at this and it seems that the change is relatively simple, here is the AI generated plan which I vetted
Issue 1447: SandboxClaim Migration — Implementation Guide
Invariant (do not break)
Gateway is the sole allocator and owner of
sandbox-id. Supervisor connect is rejected unless that id is already persisted in the gateway store.This migration changes only the Kubernetes provisioning object used by the driver. It does not change gateway, server protocol, or supervisor behavior.
Relevant unchanged code paths:
OPENSHELL_SANDBOX_ID): crates/openshell-driver-kubernetes/src/driver.rs#L1340Implementation
Step 1: Add a provisioning mode
File:
crates/openshell-driver-kubernetes/src/config.rsandmain.rs.Add:
sandbox_provisioning_mode:sandbox(default) orsandboxclaimsandboxclaim_template_name: required when mode issandboxclaimsandboxclaim_warmpool: optional, defaultdefaultNo proto or server changes.
Step 2: Mode-aware GVK in the driver
File:
crates/openshell-driver-kubernetes/src/driver.rs.Today
api()andwatch_api()hardcodeagents.x-k8s.io/v1alpha1, Sandbox. Replace both with one helper that returns the correct GVK based on mode:sandbox->agents.x-k8s.io/v1alpha1, Sandboxsandboxclaim->extensions.agents.x-k8s.io/v1alpha1, SandboxClaimCentralize this so the rest of the driver does not branch.
Step 3: Branch
create_sandboxby modeToday
create_sandboxalways callssandbox_to_k8s_spec(...).In claim mode, build a claim object instead:
Key implementation rules:
build_env_list(...)to produce env entries — do not duplicate logic.spec.envshape.sandboxclaim_template_nameis empty, fail fast withPrecondition.Step 4: OPENSHELL_SANDBOX_ID propagation contract
This is the part the original issue actually depends on.
In Sandbox mode, the id flows: gateway -> driver
create_sandbox-> pod env viaapply_required_env.In SandboxClaim mode, the upstream controller materializes the pod, not the driver. So the id must ride into the pod through claim fields that the controller is guaranteed to propagate:
spec.enventry forOPENSHELL_SANDBOX_ID. This is the upstream-supported per-claim env override.spec.additionalPodMetadata.labelswith the id, so the pod is identifiable in cluster tooling even if env stripping ever occurs.metadata.labelscarry the id for driver-side correlation.Reviewer acceptance checks:
OPENSHELL_SANDBOX_IDexactly equal to gateway store id.supervisor_session.rs.Step 5: Status, list, get
sandbox_from_object(...)currently assumes core Sandbox status keys. Make it tolerant:openshell.ai/sandbox-idfirst (works for both kinds).status.sandbox.namewhen present to populate instance correlation.DriverSandboxprotobuf; keep external surface stable.Step 6: Watch and event correlation
Today
map_kube_event_to_platform(...)handles involved kindsSandboxandPod.Add:
SandboxClaiminvolved kind.claim name -> sandbox-idfrom claim labels.bound sandbox name / pod name -> sandbox-idfrom claim status.Without this, platform events get dropped in claim mode.
Step 7: Delete
delete_sandboxandsandbox_existsonly need the mode-aware GVK from Step 2. No further changes.Tests to add
Only the risky seams:
spec.sandboxTemplateRef.name.spec.envcontainsOPENSHELL_SANDBOX_IDmatching the gateway id.sandbox_from_objectreads id from label in both kinds.SandboxClaiminvolved kind.Caveats
sandboxclaims. Validate against a cluster with the extensions CRDs installed before enabling claim mode.spec.envandspec.additionalPodMetadata.rohancmr commentedon Jun 12, 2026
+1 for this feature.
From a Kubernetes platform perspective, this is important for multi-tenant sandbox environments where the platform owns tenant onboarding, namespace controls, quotas, NetworkPolicy, RuntimeClass/node placement, and approved sandbox profiles.
We would like OpenShell's Kubernetes driver to support a claim-based allocation mode using the Agent Sandbox extension CRDs:
SandboxTemplateSandboxWarmPoolSandboxClaimThe desired flow is that OpenShell Gateway remains the allocator and owner of the sandbox identity, but instead of always creating a cold
Sandbox, it can create aSandboxClaimagainst an operator-approvedSandboxTemplateand optionalSandboxWarmPool.This would let platform teams pre-warm secure sandbox capacity for approved profiles, including gVisor/Kata runtime classes, tainted node pools, GPU pools, and tenant-specific profiles, without deploying one OpenShell Gateway per runtime profile.
Important requirements from our side:
sandbox_idallocation and supervisor identity validation.SandboxClaimand resulting pod..envfiles, provider credentials, MCP/tool tokens, process state, background jobs, network connections, model/tool sessions, logs, caches, and the live supervisor connection.Sandboxcreation path.This would make OpenShell much easier to use as the sandbox execution plane behind a Kubernetes platform that manages tenant isolation and placement.
craig-kindo commentedon Jul 7, 2026
Moving the design context from my vouch request (#2132) here at @drew's request.
I've built and validated a warm-pool integration against a GKE test cluster, and I'd like to propose it as the implementation direction for this issue. The short version: let the Agent Sandbox extension CRDs own all pool state, and teach the supervisor to start unbound and wait for its identity to arrive as a file. No gateway pool reconciler, no new gateway↔supervisor protocol, no Kubernetes client in the supervisor.
The design, in two independent pieces
Part 1 — claim provisioning mode in the Kubernetes driver. An optional driver mode where
CreateSandboxproduces aSandboxClaim(referencing an operator-managedSandboxTemplate/SandboxWarmPool) instead of a coreSandbox. This is close to the migration plan @ryanzhang-oss posted above, and it's useful on its own: a claim with no warm capacity available just has the Agent Sandbox controller create a freshSandboxto satisfy it, so cold fallback comes for free. The gateway still allocatessandbox_idand persists the store record before creating the claim, so theConnectSupervisorgate insupervisor_session.rsis untouched.Part 2 — a wait-for-bind startup phase in the supervisor. This is the piece that makes true pre-warming work, and it's where I'd like to correct one thing in the plan above. The issue correctly identifies static identity binding (
OPENSHELL_SANDBOX_IDas a boot-time env var) as blocker # 1. The plan above routes the id throughSandboxClaim.spec.env— but upstream rejects custom env on the warm path (custom environment variables are not supported when using a warm pool). I hit this in-cluster; claim env only works for claims that fall through to cold creation. So env can't be the identity carrier for warm adoption, full stop.Instead:
additionalPodMetadata. The Agent Sandbox controller propagates those onto the adopted pod's annotations.downwardAPIvolume projecting those annotations as files. The supervisor watches the projected file as a sentinel; when the id appears, it proceeds through the existing, unchanged bootstrap —ConnectSupervisorwith the discovered id, token, policy fetch, session — exactly as if it had booted with the env var.The claim's binding state lives in exactly one place (the k8s API), and the pod observes it without a k8s client or any new RBAC, because the kubelet does the projection for us.
The projection isn't free relative to giving the supervisor its own Kubernetes watch: in a pilot benchmark of annotation-change → in-pod detection, the downwardAPI file lagged a direct pod watch by ~75ms p50 / ~120ms p95. I consider that an acceptable price for keeping a Kubernetes client, RBAC, and API-server coupling out of the sandbox pod entirely.
How this answers the open questions in the issue
warm_idlestate + pool reconciler" — not needed.SandboxWarmPoolalready is the reconciler; unclaimed pods never appear in the gateway store at all, so there's nothing to hide fromListSandboxes.SandboxWarmPool/SandboxTemplateobjects, operator-owned, resizable withkubectland no gateway restart. OpenShell config only names which template/pool the claim references.shutdownPolicyforeground delete), nothing returns to the pool. Replenishment creates fresh pods.One open question I'll flag but haven't investigated: the unbound phase is a natural place for pre-claim preparation beyond just parking the process — e.g. warm-pool-level conventions could let NetworkPolicies (or similar environment shaping) be set up for whatever class of claimants a given pool serves, before any identity exists.
Validation
I validated this end-to-end on a GKE test cluster (Agent Sandbox core + extensions, operator-managed template and warm pool): a real
SandboxClaimadopts a running warm pod, the supervisor binds via the projected files, reaches READY through the unchanged bootstrap, and serves exec. On that cluster the cold create → READY path runs ~26s p50; the warm claim path measures ~1.9s p50 / ~2.1s p95 fromCreateSandboxto first successfulExecSandbox.The tradeoff vs. a gateway-native pool
A gateway-owned pool (
warm_idlerecords, reconciler loop, claim-at-CreateSandbox) is more portable — it would extend to the Docker/VM drivers and to providers with no Agent Sandbox equivalent. I don't think that's a bad design. But it mirrors pool/claim state into the gateway store and the supervisor session layer on top of the k8s custom resources that already hold it, which is a distributed-state problem I'd rather not create, and it grows the gateway↔supervisor handshake, which I think needs to stay as small as possible to remain maintainable. This proposal keeps the handshake byte-identical and confines the entire feature to the k8s driver plus one optional supervisor startup phase. If a native pool for non-k8s drivers is wanted later, nothing here blocks it — the supervisor's wait-for-bind phase would even be reusable, with a different bind-signal carrier per driver.Size-wise, the supervisor change is a couple hundred lines; the driver claim mode is the larger mechanical diff. The two pieces split naturally into two PRs (claim mode first, wait-for-bind second), and I'm not attached to any of my prototype's flag names or file paths — those were chosen for speed of validation and I'd expect them to change in review.
Happy to share benchmark harnesses/artifacts or the working branches if useful.
docs: add validation run, fix TL;DR, upstream references, mermaid dia…
matthewgrossman commentedon Jul 29, 2026