PAIPAI

@pai/gke-sandbox-sdk

Standalone TypeScript SDK for self-hosted GKE Agent Sandboxes.

Package: @pai/gke-sandbox-sdk

@pai/gke-sandbox-sdk is the backend SDK for a deployed GKE Agent Sandbox environment. It creates SandboxClaim resources through the Kubernetes API, waits for the resolved gVisor Pod, and runs commands and file operations through that Pod's runtime daemon.

import { createGkeSandboxClient } from "@pai/gke-sandbox-sdk";

const client = createGkeSandboxClient({
  namespace: "pai-sandboxes",
  templates: [
    {
      name: "pai-node-runtime",
      network: { access: "none" },
      containerName: "runtime",
      defaultCwd: "/workspace",
    },
  ],
});

const sandbox = await client.create();

try {
  await sandbox.files.write("hello.js", "console.log('hello');\n");
  const result = await sandbox.commands.run("node hello.js", {
    timeoutMs: 30_000,
    maxOutputBytes: 2 * 1024 * 1024,
    onStdout: console.log,
  });
  const downloaded = await sandbox.files.read("hello.js");
  const files = await sandbox.files.list(".");
  await sandbox.keepAlive(10 * 60 * 1000);
} finally {
  await sandbox.kill();
}

files.write() is the text/binary upload operation and files.read() is the download operation.

Files move as raw bytes over the runtime daemon's REST surface, and a write is committed with a temp-file-and-rename so a concurrent reader sees the whole old file or the whole new one. Transfers default to a 50 MiB limit — a client policy, not a protocol one — so use this for normal workspace files and artifacts rather than multi-gigabyte streaming transfers.

Every path is confined to the template's rootDir, and the client resolves and checks one before calling. It refuses an absolute path outside the root rather than passing it on: the daemon would read such a path as relative to its own root, and a result that did not exist would surface as an error about /bin/sh rather than about the path.

Product layers

PackageResponsibility
@pai/gke-sandbox-infraExisting-cluster runtime module, Helm templates and optional operations dashboard
@pai/gke-sandbox-sdkStandalone lifecycle, command, and file SDK
@pai/sandbox-gkeThin adapter from this SDK to PAI's SandboxProvider

The SDK is useful without PAI. It must run on a trusted backend with an ambient kubeconfig or in-cluster service account; never send Kubernetes credentials to a browser or an untrusted sandbox.

The exported low-level daemon connectors can also inspect an existing runtime without acquiring a lifecycle-managed sandbox handle. Their runtime.files.list({ path, maxBytes }) accepts an optional byte budget for the directory response, stopping oversized responses before JSON parsing and returning a too-large failure. This does not change the high-level sandbox.files.list(path) call. The operations dashboard uses direct inspection so viewing a workspace does not renew its lifetime.

Health

client.probe() reads the default SandboxTemplate from the namespace: one object read, the cheapest call the API server offers, and it settles reachability, the credential, and whether the namespace holds the template a create would use. It throws GkeSandboxUnavailableError otherwise, including when the template is missing, because a create would fail then too. Use it for a status endpoint instead of list(), which is unbounded.

Every GkeSandboxUnavailableError carries a reason, classified from what the apiserver said: auth for a 401 or 403, not-served for a 404 (the namespace does not hold what the call needs), transport when nothing answered at all, and backend otherwise. @pai/sandbox-gke passes it through unchanged, so a consumer's status page can tell a rejected credential from an unreachable cluster from a missing template without reading messages.

Sandbox ids

A sandbox id is <claimName>.<uid8>: the SandboxClaim name plus the first eight characters of the claim's Kubernetes UID. Operators can still grep the claim name, and a claim that was deleted and recreated under the same name reads as a different, missing sandbox: connect() returns null and a held handle throws GkeSandboxNotFoundError. parseGkeSandboxId(id) splits an id, or returns null for ids the SDK did not mint. generateClaimName overrides how claim names are minted; the UID suffix is always appended by the SDK.

Binding keys

create({ bindingKey }) guarantees at most one live sandbox per namespace and key, across processes:

const a = await client.create({ bindingKey: "thread-123" });
const b = await client.create({ bindingKey: "thread-123" });
// a.sandboxId === b.sandboxId

The atomic step is a coordination.k8s.io/v1 Lease in the claim namespace, named pai-sandbox-binding-<hash of the key>, so the key space is the namespace regardless of client configuration. The creator that wins the Lease create reserves a random claim name in it, creates the claim, then records the claim UID and an owner reference so the Lease is garbage-collected with the claim. Every other creator reads the Lease and connects to the claim it names, waiting for readiness if the holder is still provisioning. A Lease whose claim is gone or terminating, or whose reservation lapsed before a claim appeared, is free: it is deleted with a resourceVersion precondition and the race restarts. kill() deletes the Lease explicitly rather than waiting for garbage collection, and only when the Lease still names that claim. The key is stored on the claim annotation pai.dev/sandbox-binding-key.

The existing sandbox's template and settings stand: a second create for a held key returns it whatever ttlMs, metadata, or env it passed. A client whose template or ownership labels do not match the sandbox holding the key rejects with GkeSandboxPolicyMismatchError, since one key cannot name two sandboxes.

The client identity needs create, get, update, and delete on leases in the namespace; the infra chart's optional adapter Role grants them.

Template policy

A client declares every SandboxTemplate it may use, each with its expected network and resource policy. create({ template }) selects one; with a single template that is the default, and with two or more the client names its defaultTemplate explicitly, so reordering a declaration list never changes what a create without a name provisions. An undeclared name is refused before anything reaches Kubernetes. Claims discovered through connect() and list() are checked against the declared set, so a client never operates a sandbox whose policy it did not declare, and every sandbox handle carries its resolved template with the container and working directory commands run in. The Kubernetes SandboxTemplate object itself is mutable by cluster operators; the SDK does not attest its live image, resources, or network policy.

A create inherits the selected template's network, because the template is where that posture is declared and enforced and a SandboxClaim cannot change it. Declaring the template is the opt-in, so an open template needs nothing extra per create:

const openClient = createGkeSandboxClient({
  namespace: "pai-sandboxes",
  templates: [
    {
      name: "pai-node-runtime-open",
      network: { access: "full" },
    },
  ],
});

// Inherits { access: "full" } from the template.
await openClient.create();

// Naming one asserts something about the selected template, so it has to be
// true: this is refused with GkeSandboxPolicyMismatchError.
await openClient.create({ network: { access: "none" } });

The SDK validates the caller's declaration but cannot alter or independently prove the cluster's effective NetworkPolicy. Keep it aligned with the Helm chart's network.mode.

Every configured client label is also checked by connect(), command, file, keepalive, and kill operations. Use a stable trust-domain label when multiple backends share one namespace. Use separate namespaces and service accounts for mutually untrusted tenants because labels do not replace Kubernetes authorization.

Errors

Every failure the client raises is one of these classes, each with an is… type guard (isGkeSandboxNotFoundError and so on) and a stable code.

ErrorCodeMeaning
GkeSandboxNotFoundErrorGKE_SANDBOX_NOT_FOUNDThe claim is gone, or its pod was replaced with a fresh filesystem. Recreate.
GkeSandboxQuotaExceededErrorGKE_SANDBOX_QUOTA_EXCEEDEDThe namespace's ResourceQuota refused the claim or its pod. Retry once other sandboxes are released.
GkeSandboxUnavailableErrorGKE_SANDBOX_UNAVAILABLEKubernetes or the Agent Sandbox controller could not complete the operation. Not fixed by recreating.
GkeSandboxCommandTimeoutErrorGKE_SANDBOX_COMMAND_TIMEOUTA command exceeded its wall-clock limit.
GkeSandboxOutputLimitErrorGKE_SANDBOX_OUTPUT_LIMITCaptured output exceeded its byte budget.
GkeSandboxFileTooLargeErrorGKE_SANDBOX_FILE_TOO_LARGEA transfer exceeded the file byte limit.
GkeSandboxPolicyMismatchErrorGKE_SANDBOX_POLICY_MISMATCHThe request conflicts with the selected template's declared policy. Not a TypeError: the call is well-formed but wrong for this deployment.
GkeSandboxInputErrorGKE_SANDBOX_INPUTA call the SDK forbids: a malformed option, template, or per-request input. A TypeError, named so a boundary can tell it from a bug.

Only the quota error is worth retrying unchanged. @pai/sandbox-gke maps each of these onto the @pai/sandbox contract: the quota error becomes SandboxQuotaExceededError, the policy error SandboxPolicyMismatchError.

E2B parity

Supported now: create, connect, list, kill, command streaming, exit codes, timeouts, cancellation, bounded output, atomic file read/write/list with size limits, metadata, environment variables, control-plane retries, and keepalive deadlines. Warm allocation comes from SandboxWarmPool.

Not yet included: durable workspace restore/sync, background process handles, PTY sessions, preview URLs, and snapshot/pause APIs. The default Helm runtime uses emptyDir, so sandbox deletion removes /workspace; durable GCS-backed workspaces remain a separate persistence phase.

env and metadata are stored in claim annotations. They are size-limited and must not contain secrets.

Commands capture at most 10 MiB by default. File operations default to a 30-second timeout and 50 MiB transfer limit. Kubernetes API attempts default to 10 seconds with two retries for transient failures. These limits are configurable on createGkeSandboxClient() and violations use typed errors.

Custom templates must run the Agent Sandbox runtime daemon as the container's process, declare its REST and gRPC ports as containerPorts, and expose a writable rootDir. Commands run inside whichever container the daemon lives in — a shared volume shares files, not binaries — so the daemon and the tools an agent invokes must be in one image. The infra package builds such an image and its default Node.js template satisfies this contract.

Transport

transport chooses how the daemon is reached. Creating a sandbox is a Kubernetes API call; running a command in one is not.

"pod-ip" dials the pod's address, and "service" dials the sandbox's own headless Service by DNS. Both take the data path off the control plane, which is what the daemon binds 0.0.0.0 for, and both need the sandbox's NetworkPolicy to admit the caller. "service" needs cluster DNS, so it only works in-cluster; "pod-ip" needs only a route to the address, which a VPC-native cluster publishes across its VPC — so it also works from outside, for example Cloud Run with Direct VPC egress.

"port-forward" is the default and tunnels through the API server's pods/portforward subresource, holding one multiplexed tunnel per sandbox. It needs no network setup, which is why it is the default and why the local kind cluster uses it — but it cannot reach a gVisor sandbox. The kubelet dials loopback in the pod's node-visible namespace, and gVisor's network stack is not there. Since Agent Sandbox mandates gVisor, a real cluster needs one of the direct modes; probe() refuses the combination rather than letting every command time out. See agent-sandbox#158.

The three are exclusive: none falls back to another, because a silent fallback would change the network a deployment needs without saying so.

The transport is stateful either way, so call close() when a process is finished with the client. It releases the channels and tunnels and touches no sandbox — sandboxes outlive the client and are reclaimed by their own deadlines.

Runtime version

REQUIRED_SANDBOXD_VERSION is the sandboxd release this client speaks to. On first contact with each sandbox — after the health check the connection was making anyway — the client reads GET /v1/metadata and refuses a daemon reporting a different version, naming both and the way out.

This is the contract check, and it exists because PAI ships the runtime image's specification rather than the image: every deployment builds and hosts its own, so no digest this client could pin would say what is actually running. Asking the daemon is stronger anyway — it sees a hand-edited SandboxTemplate, a stale rebuild and a per-template image override, none of which a pinned digest would notice.

An image reporting no version is accepted, and so is one with no /v1/metadata endpoint. SANDBOX_SANDBOXD_VERSION is a convention the @pai/gke-sandbox-infra Dockerfile establishes rather than something sandboxd provides, so silence is not disagreement. A wrong version is refused.

The practical consequence: upgrading @pai/* across a bump to this constant means rebuilding and re-pinning the runtime image first. That lockstep was always implied; it is now enforced where the mismatch happens.

On this page