PAIPAI

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 throws SandboxPolicyMismatchError.
  • create({ network }) is accepted when absent or { access: "none" }. Sandboxes have no outbound network, so any other value throws SandboxPolicyMismatchError.
  • create({ resources }) throws SandboxPolicyMismatchError. Configure per-application sizing in the runtime Terraform module; the provider inherits its template.
  • File paths and command working directories resolve against /workspace and must stay inside it. An absolute path elsewhere, such as /tmp, throws SandboxInputError.

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:

ErrorMeaning
SandboxNotFoundErrorThe claim or its original workspace is gone. Reconnect or recreate.
SandboxQuotaExceededErrorNamespace quota refused allocation. Retry once capacity is released.
SandboxUnavailableErrorKubernetes, the controller or daemon could not serve the operation. Inspect reason and retryable.
SandboxCommandTimeoutErrorThe command exceeded its wall-clock limit.
SandboxOutputLimitErrorCaptured command output exceeded its byte limit.
SandboxFileTooLargeErrorA transfer or directory listing exceeded the byte limit.
SandboxPolicyMismatchErrorThe sandbox or request does not match the fixed runtime or managed ownership labels.
SandboxInputErrorAn 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.

Learn more

On this page