createGkeSandboxProvider()
GKE sandbox lifecycle, commands and files for PAI agents.
Production sandbox backend. Bind with sandboxCapability(). Cluster install: self-hosting.
import { createGkeSandboxProvider } from "@pai/sandbox-gke";
import { sandboxCapability } from "@pai/sandbox";
sandboxCapability({
provider: createGkeSandboxProvider({
namespace: "pai-sandbox-chat",
cluster: {
endpoint: process.env.PAI_GKE_SANDBOX_ENDPOINT!,
caCertificate: process.env.PAI_GKE_SANDBOX_CA_CERTIFICATE!,
},
}),
});Import
import { createGkeSandboxProvider } from "@pai/sandbox-gke";Signature
function createGkeSandboxProvider(options: {
namespace: string;
cluster?: { endpoint: string; caCertificate: string };
transport?: "pod-ip" | "port-forward";
daemonToken?: string;
defaultTtlMs?: number;
createTimeoutMs?: number;
maxFileBytes?: number;
}): SandboxProvider;Parameters
options: GkeSandboxProviderOptions
namespace: string
The application's namespace: application_namespaces["<app>"] from the
runtime module, pai-sandbox-<app> by default. It holds the template,
its warm pool and every claim.
cluster?: { endpoint, caCertificate }
Reach the cluster with the ambient Google credential. Supply the API
server address and its base64-encoded CA, as GKE reports them. Omit it
to use KUBECONFIG, then ~/.kube/config, then the pod's
service-account token.
transport?: "pod-ip" | "port-forward"
"pod-ip" dials each sandbox pod's address, so the caller needs a route
to the pod network and an allow_from CIDR. "port-forward" tunnels
through the API server and only reaches a local kind cluster: it cannot
reach a gVisor sandbox.
Defaults to pod-ip.
daemonToken?: string
The bearer token the runtime daemon requires when the application's
template sets daemon_token_sha256. Sent on every file request and
command. A daemon that requires no token ignores it, so set this before
the template starts requiring one.
defaultTtlMs?: number
Usable lifetime after readiness. Falls back to
PAI_GKE_SANDBOX_TTL_MS, then to five minutes.
createTimeoutMs?: number
How long create() may wait for a ready sandbox, including for a
claim still being deleted under the same binding key. A claim the
controller never reconciles fails after 20 seconds; see the errors
below.
Defaults to 5 * 60 * 1000.
maxFileBytes?: number
Largest upload, download or directory listing. Keep your app's upload limit at or below it: a seed reads stored files up to it, and a larger one fails alone and stays missing.
Defaults to 50 * 1024 * 1024.
Building the provider makes no network call and reads no kubeconfig; both happen on first use.
Returns
A SandboxProvider.
Pass to sandboxCapability({ provider }).
Behavior
The runtime module fixes what a sandbox is, so the provider refuses requests it cannot honour instead of giving less:
create({ template })naming another template throwsSandboxPolicyMismatchError.create({ network })is accepted when absent or{ access: "none" }. Sandboxes have no outbound network, so any other value throwsSandboxPolicyMismatchError.create({ resources })throwsSandboxPolicyMismatchError. Configure per-application sizing in the runtime Terraform module; the provider inherits its template.- File paths and command working directories resolve against
/workspaceand must stay inside it. An absolute path elsewhere, such as/tmp, throwsSandboxInputError.
create({ bindingKey }) names the claim from the key, so every application replica converges on one sandbox per conversation. probe() reads the application's warm pool and creates nothing. Failures use the shared @pai/sandbox error classes.
Direct operations
The provider implements SandboxProvider directly. Use it on a trusted backend;
Kubernetes credentials and daemon endpoints must not be sent to a browser or an
untrusted sandbox. Agents normally access it through sandboxCapability();
trusted application code can use the same contract:
const provider = createGkeSandboxProvider({
namespace: "pai-sandbox-chat",
});
const session = await provider.create({ bindingKey: "thread-123" });
try {
await session.writeFile({ path: "hello.js", data: new TextEncoder().encode("console.log('hello');\n") });
const result = await session.runCommand({
command: "node hello.js",
timeoutMs: 30_000,
maxOutputBytes: 2 * 1024 * 1024,
onStdout: console.log,
});
const downloaded = await session.readFile({ path: "hello.js" });
const files = await session.listDir({ path: "." });
await session.keepAlive({ ttlMs: 10 * 60 * 1000 });
} finally {
await session.kill().finally(() => provider.close?.());
}close() releases this process's channels and tunnels. It does not delete remote
sandboxes, which keep their deadlines. Register it with the host's shutdown
lifecycle when using the provider directly.
Lifecycle and binding
A create writes an Agent Sandbox v1beta1 SandboxClaim naming the application's
warm pool (spec.warmPoolRef) with shutdownPolicy: DeleteForeground. A pool of
zero replicas cold-starts each claim. The first deadline covers provisioning;
the usable ttlMs starts once the sandbox is ready. keepAlive() only extends
that deadline. kill() deletes the claim with a UID precondition, and its
Sandbox and pod follow.
A bound claim is named pai-sbx- plus the first 32 hex characters of the binding
key's SHA-256. Concurrent creators race on that name and adopt the same claim
once ready. They wait while an old claim is being deleted. All of this is bounded
by createTimeoutMs. The existing sandbox's TTL, metadata and environment win
when a second create uses its key. A mismatched binding annotation is refused;
a claim naming another warm pool or missing the provider's managed labels throws
SandboxPolicyMismatchError.
Binding keys are scoped to the namespace. Separate application deployments
need distinct namespaces. The fixed template and warm-pool name is
pai-node-runtime; the namespace selects the application runtime.
Before using a pod, the provider reads the Sandbox named in claim status and verifies two controller owner references: the Sandbox belongs to the claim's UID, and the pod belongs to that Sandbox's UID. A replaced, failed or finished pod loses its workspace: the provider releases the stale claim and reports the sandbox missing so the session manager can recreate it. It never deletes a newer claim that reused the old name.
A sandbox ID combines the claim name and the first eight characters of its UID.
connect({ sandboxId }) returns null for a missing, replaced, expired or
terminating claim. A held session throws SandboxNotFoundError after its
sandbox disappears. list() reads ready claims carrying its fixed
managed labels, optionally filtered by metadata; it does not list pods.
The workspace uses ephemeral emptyDir storage. Publish durable outputs through
the application's file provider before releasing a sandbox.
Commands and files
Commands stream over the daemon's gRPC listener; files move as raw bytes over
its REST listener. Nonzero command exit codes are returned normally. Timeouts and
cancellation terminate the command's process group. Commands default to five
minutes and 10 MiB of captured output; set timeoutMs and maxOutputBytes per
command.
File writes use a temporary file and rename, so concurrent readers see the whole
old file or whole new file. Uploads, downloads and directory listings default to
a 50 MiB limit, controlled by maxFileBytes. File operations time out after 30
seconds. A missing file or directory returns null.
Every file path and command working directory resolves against /workspace.
The provider refuses absolute paths outside that root. This validation does not
confine paths inside a shell command; the pod's isolation is the execution
boundary.
Create-time env and metadata are stored in claim annotations and are
size-limited. Keep secrets out of them; pass secrets per command instead. A
consumer's toolchain and sandboxd must run in one image: a shared volume shares
files, not installed binaries.
Health and errors
probe() reads one SandboxWarmPool to check API reachability, credentials and
installation. It creates nothing and does not prove pod scheduling, image
startup or daemon connectivity. The caller needs create, delete, get,
list and patch on sandboxclaims.extensions.agents.x-k8s.io, and get on
sandboxwarmpools.extensions.agents.x-k8s.io, sandboxes.agents.x-k8s.io and
pods in the namespace; the infra module supplies that Role.
Use error classes exported by @pai/sandbox:
| Error | Meaning |
|---|---|
SandboxNotFoundError | The claim or its original workspace is gone. Reconnect or recreate. |
SandboxQuotaExceededError | Namespace quota refused allocation. Retry once capacity is released. |
SandboxUnavailableError | Kubernetes, the controller or daemon could not serve the operation. Inspect reason and retryable. |
SandboxCommandTimeoutError | The command exceeded its wall-clock limit. |
SandboxOutputLimitError | Captured command output exceeded its byte limit. |
SandboxFileTooLargeError | A transfer or directory listing exceeded the byte limit. |
SandboxPolicyMismatchError | The sandbox or request does not match the fixed runtime or managed ownership labels. |
SandboxInputError | An option, per-request input or path is invalid; this is a TypeError. |
These messages are for operators. When one reaches a PAI tool, the model reads fixed text instead; see Sandbox failures.
SandboxUnavailableError.reason is auth for a rejected credential,
not-served for a missing installation or terminal configuration failure,
transport when nothing answers, and backend for other failures. Kubernetes
API attempts time out after ten seconds, with two retries for transient errors.
A claim condition that cannot become ready because its template, pool, metadata,
environment injection, volumes or configuration is invalid fails promptly.
MultiplePods also fails as not-served, because the controller cannot select
one workspace safely. A claim the controller has not given any status within
20 seconds fails as a retryable backend failure, because no controller is
reconciling claims. A cold start (a new node, an image pull) keeps the whole
createTimeoutMs.
Transport and runtime compatibility
pod-ip dials ports 8080 (REST) and 9090 (gRPC) directly. The caller needs a route
to pod addresses and admission through the template's allow_from CIDRs and
external firewalls. This works across a VPC, including from Cloud Run using
Direct VPC egress. The daemon path does not authenticate Kubernetes credentials;
network access must be restricted to trusted callers. Set the application's
daemon_token_sha256 and the matching daemonToken so the daemon also refuses
callers without the token. The token is per application, not per sandbox, and
travels unencrypted inside the private network.
port-forward holds a Kubernetes API tunnel per sandbox. Use it for local kind
pods, whose caller credential needs pods/portforward. It cannot reach the
gVisor network stack and neither transport falls back to the other.
The provider requires sandboxd v1.0.8. On first contact it checks that the
daemon's /v1/metadata response reports SANDBOX_SANDBOXD_VERSION=v1.0.8.
It also accepts v1.0.4, the version it required before, which speaks the same
protocol, so sandboxes claimed before an image upgrade keep working until their
claims end. Missing, empty, unreadable or other version metadata is refused.
This check does not attest image provenance.
Upgrade the application first, then roll out the runtime image rebuilt from the
same PAI release: earlier providers accept only v1.0.4. The infra package
supplies the Dockerfile; the deployment owns the registry, image digest and
rebuild cadence.