@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
| Package | Responsibility |
|---|---|
@pai/gke-sandbox-infra | Existing-cluster runtime module, Helm templates and optional operations dashboard |
@pai/gke-sandbox-sdk | Standalone lifecycle, command, and file SDK |
@pai/sandbox-gke | Thin 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.sandboxIdThe 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.
| Error | Code | Meaning |
|---|---|---|
GkeSandboxNotFoundError | GKE_SANDBOX_NOT_FOUND | The claim is gone, or its pod was replaced with a fresh filesystem. Recreate. |
GkeSandboxQuotaExceededError | GKE_SANDBOX_QUOTA_EXCEEDED | The namespace's ResourceQuota refused the claim or its pod. Retry once other sandboxes are released. |
GkeSandboxUnavailableError | GKE_SANDBOX_UNAVAILABLE | Kubernetes or the Agent Sandbox controller could not complete the operation. Not fixed by recreating. |
GkeSandboxCommandTimeoutError | GKE_SANDBOX_COMMAND_TIMEOUT | A command exceeded its wall-clock limit. |
GkeSandboxOutputLimitError | GKE_SANDBOX_OUTPUT_LIMIT | Captured output exceeded its byte budget. |
GkeSandboxFileTooLargeError | GKE_SANDBOX_FILE_TOO_LARGE | A transfer exceeded the file byte limit. |
GkeSandboxPolicyMismatchError | GKE_SANDBOX_POLICY_MISMATCH | The request conflicts with the selected template's declared policy. Not a TypeError: the call is well-formed but wrong for this deployment. |
GkeSandboxInputError | GKE_SANDBOX_INPUT | A 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.