@chamvi/sandbox-k8s

中文文档
0.8.0发布于 2026/11/09Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 2904

Kubernetes sandbox provider for chamvi: one hardened pod per workspace.

原始使用文档
文档目录12 节
  1. Installation
  2. Usage
  3. RBAC
  4. The pod
  5. Image
  6. Cache
  7. Security
  8. Timeouts and abort
  9. Garbage collection
  10. Options
  11. API
  12. License

@chamvi/sandbox-k8s

@chamvi/sandbox-k8s

One pod per workspace, for chamvi agents on Kubernetes.
Root inside so package managers work, a few capabilities, no service account token, resource limits,
found again by label after a restart or on another replica.

npm version CI License


Table of Contents

Installation

npm install @chamvi/sandbox-k8s @chamvi/sandbox @chamvi/core ai --save

ESM only, Node.js >=26.0.0. @chamvi/core and @chamvi/sandbox are peer dependencies. The API server is reached through @kubernetes/client-node 2 (CoreV1Api and Exec over a WebSocket). It declares @types/node ^26 as a runtime dependency, so a project on older Node.js types gets a nested copy; that is harmless with skipLibCheck.

Usage

import type { KubeConfig } from '@kubernetes/client-node';
import type { ISandboxProvider } from '@chamvi/sandbox';
import { createKubernetesSandbox } from '@chamvi/sandbox-k8s';

export function createSandbox(kubeConfig: KubeConfig): ISandboxProvider {
    // one pod per workspace in this namespace; nothing is contacted before the first create()
    return createKubernetesSandbox({ namespace: 'agents', kubeConfig });
}

Without kubeConfig the provider calls loadFromDefault(): the in-cluster service account when chamvi runs in a pod, else KUBECONFIG or ~/.kube/config. Pass the provider to createWorkspaceManager({ provider }) from @chamvi/sandbox. Creating it contacts nothing; create() creates the pod, waits until it runs (polling every second, at most two minutes, failing fast on ErrImagePull, ImagePullBackOff, InvalidImageName, CreateContainerConfigError and CreateContainerError), then checks out.

RBAC

The identity chamvi runs as needs, in the sandbox namespace only:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: chamvi-sandbox
  namespace: agents
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["create", "get", "list", "delete"]
  - apiGroups: [""]
    resources: ["pods/exec"]
    verbs: ["get", "create"]
  # only with persistence
  - apiGroups: [""]
    resources: ["persistentvolumeclaims"]
    verbs: ["create", "get", "list", "delete"]

Pod Security: by default the pods run as root with a writable root filesystem and the capabilities CHOWN, DAC_OVERRIDE, FOWNER, FSETID, SETUID, SETGID and KILL, which the Pod Security level baseline admits and restricted refuses. Label the sandbox namespace accordingly:

kubectl label namespace agents pod-security.kubernetes.io/enforce=baseline

Where only restricted is allowed, use the hardened mode, createKubernetesSandbox({ namespace, user: { uid: 1000, gid: 1000 }, readOnlyRoot: true }): non-root with runAsNonRoot and fsGroup, a read-only root, no capability (apt then fails; toolchains still install into /cache with mise). A shared cache.claimName must honour fsGroup (NFS-style volume plugins ignore it) or be world-writable, else the hardened user cannot write it.

pods/exec needs both verbs: the client opens the exec WebSocket with a GET, which the API server authorizes as get, while servers with the WebSocket-upgrade authorization change ask for create. Keep the sandbox namespace apart from chamvi's own, so the pods never share a namespace with its secrets.

The pod

Each workspace is a bare pod chamvi-ws-<id> with one container sandbox:

SettingValue
PodrestartPolicy: Never, automountServiceAccountToken: false, enableServiceLinks: false, shareProcessNamespace: true (the pause container is pid 1 and reaps orphaned processes for any image, also one without an init, whose zombies would otherwise fill the kubelet's pid limit), activeDeadlineSeconds = the workspace's lifetime in seconds (lifetimeSeconds), so the pod ends itself at expiresAt even when no collection runs, terminationGracePeriodSeconds: 5
Pod securityrunAsUser/runAsGroup from user (default 0), seccomp RuntimeDefault; a non-root user adds runAsNonRoot and fsGroup
Container securityallowPrivilegeEscalation: false, not privileged, readOnlyRootFilesystem from readOnlyRoot (default false), all capabilities dropped and for root ROOT_CAPABILITIES added back
Commandcommand: ['sh', '-c', SLEEP_UNTIL_SCRIPT, 'sh', '<expiresAt in epoch seconds>'] (sleeps until expiresAt, so a pod scheduled late stops in time), replacing the image's entrypoint (an image whose entrypoint is a command works too)
Resourcesrequests equal limits: cpu, memory, ephemeral-storage from storage
/workspacean emptyDir with sizeLimit from storage; with persistence the workspace's claim (subPath workspace)
/tmpa memory-backed emptyDir of tmpSize (default 1 GiB, TMP_SIZE)
/home/sandboxHOME (WORKSPACE_HOME): an emptyDir on the node's disk, counted in ephemeral-storage; with persistence the workspace's claim (subPath home)
/cachethe ReadWriteMany claim cache.claimName, shared by every workspace, else an emptyDir of its own
EnvironmentSANDBOX_ENV, the cache variables (CACHE_ENV) and env, nothing from chamvi's pod
LabelspodLabels, then app.kubernetes.io/managed-by=chamvi, app.kubernetes.io/component=sandbox, chamvi.dev/run=<runId>, chamvi.dev/workspace=<id>, chamvi.dev/deployment=<deployment>, chamvi.dev/repo-key=<hash> (with a repository) (the managed labels win; podLabels naming one throws; a runId that is no label value is a ValidationError, engine run ids are UUIDs)
Annotationschamvi.dev/created-at and chamvi.dev/expires-at (epoch ms), chamvi.dev/repo, chamvi.dev/ref, chamvi.dev/image

A pod is used rather than a Job: exec needs a pod name known at create time, and a Job's controller, retries and generated names add nothing the pod spec does not already enforce.

limits.pids has no per-pod field in Kubernetes: the kubelet's podPidsLimit applies, so set it on the nodes that run sandboxes.

Image

The default image is ghcr.io/chamvi/sandbox-dev:trixie (DEFAULT_SANDBOX_IMAGE): Debian, git, build tools, ripgrep, jq and mise for any toolchain, built for linux/amd64 and linux/arm64 from images/sandbox-dev; tags trixie (rebuilt on changes and weekly) and trixie-<short sha>. imagePullPolicy (the provider image's) defaults to 'IfNotPresent'; use 'Always' to follow the mutable tag, or pin a digest in production (image: 'ghcr.io/chamvi/sandbox-dev@sha256:...').

A run may pick an image (create_workspace({ image })) only when the manager has an allow-list (allowImage, or CHAMVI_SANDBOX_IMAGES in sandboxModule()); imagePullPolicy applies to every image, the provider's, the manager's and one a run asked for. An image needs sh, git and a GNU timeout that takes -k: create probes them once the pod runs and fails with a SandboxError naming what is missing, before any checkout (and deletes the pod).

Cache

Every pod mounts /cache and gets the cache variables (CACHE_ENV: XDG_CACHE_HOME, npm_config_cache, PIP_CACHE_DIR, GOMODCACHE, GOCACHE, COMPOSER_CACHE_DIR; package downloads only, mise keeps toolchains in HOME). Without cache that is an emptyDir of the pod (counted against its ephemeral-storage), so installs work but start cold every time. With cache: { claimName } every workspace mounts the same ReadWriteMany claim (NFS, CephFS, EFS, Azure Files), so workspaces sharing the claim reuse each other's package downloads (a miss, an eviction or concurrent installs may still fetch again; toolchains stay per workspace, in HOME).

Risk: a shared claim is shared by every run and agent of the namespace. A malicious package one run fetched can sit in another run's cache; npm verifies integrity hashes, pip, Go's build cache and others less. Keep runs that must not influence each other on different claims, or without one.

Security

  • Not a network boundary. Without a NetworkPolicy a workspace pod reaches every service in the cluster by DNS, the chamvi API and its database included, and cloud metadata. Select the pods by app.kubernetes.io/component=sandbox (or your podLabels) and deny all egress except what the work needs (GitHub, the npm registry), the chamvi namespace and 169.254.169.254 explicitly denied. The Helm chart will ship this policy.
  • Root inside, not on the node: the default root user holds only the package-manager capabilities, no privilege escalation, seccomp RuntimeDefault, no host path and no privileged mode; the hardened mode drops root too.
  • No credentials: no service account token is mounted, no service links are injected, and no chamvi environment variable reaches the pod.
  • Prompt injection: what the model runs follows what it reads. Narrow allowRepo on the manager and put bash behind an approval where needed.

Timeouts and abort

Every command runs as timeout -k 5 <seconds> <argv>, which signals its whole process group. A client-side guard stops waiting 15 s after the timeout and reports exit 137 with the output so far. Kubernetes sends an exec's exit status only once every process holding its output closed it, so the command reports its exit on stderr itself: one that exits while a background job it started holds its output (node server.js & echo started) returns about 2 s after its exit with its own exit code, and the job runs on until the pod is deleted. An abort (signal) detaches: the exec WebSocket closes, and the command runs on until its timeout fires or the pod is deleted. Every settled run disposes its workspaces (manager.attach(engine)). A dispose deletes the pod without a grace period.

Stdin is closed only on the v5.channel.k8s.io exec protocol (Kubernetes 1.30+); on v4 it is written but never closed, so a command waiting for stdin EOF hangs until its timeout. writeFile does not depend on it (head -c <bytes>).

Garbage collection

find(runId, { deployment }) lists the run's pods by label and rebuilds the workspaces of the running ones (a pod that failed, finished or is being deleted is gone), so a run finds its workspace after a restart or on another replica. collectGarbage({ deployment, isSettled }) lists the deployment's pods with the managed labels and deletes those past their chamvi.dev/expires-at annotation, those in phase Failed or Succeeded and, with isSettled, those of settled runs (open workspaces of this process are disposed first). The manager calls it from acquire at most every 10 minutes; it is the only reaper.

Persistence (persistence: { storageClassName?, size? }, CHAMVI_SANDBOX_PERSISTENCE=true with sandboxModule()): create makes a ReadWriteOnce claim chamvi-ws-<id> (the pod's labels and annotations, size default limits.storage, storageClassName default the cluster's) before the pod, which mounts it at /workspace and /home/sandbox. With isSuspendable the collection deletes the running pod of a run that waited long enough and keeps the claim; find rebuilds the pod of a live claim without a running one (the image and expiry from the claim, env and limits from the provider and the manager, activeDeadlineSeconds what is left of the lifetime), waits like create and fails with a SandboxError when it does not start. Dispose, expiry and a settled run delete the claim too. Lost on suspension: background jobs, /tmp, writes outside the two mounts and an emptyDir /cache. A node-local storage class pins the rebuilt pod to its node. A pod that cannot be removed does not stop the pass: the others go, and one SandboxError with the count and the first failure is thrown at the end.

Options

createKubernetesSandbox(options):

OptionTypeDefaultDescription
namespacestringrequiredNamespace of the workspace pods
kubeConfigKubeConfigloadFromDefault()Cluster access
imagestringDEFAULT_SANDBOX_IMAGEThe workspace image; pin a digest in production
imagePullPolicy'IfNotPresent' | 'Always''IfNotPresent'Of every workspace image; 'Always' for a mutable tag
envRecord<string, string>noneAdded after SANDBOX_ENV and CACHE_ENV
user{ uid, gid }{ uid: 0, gid: 0 }The pod's user; a non-root user gets no capability, runAsNonRoot and fsGroup
readOnlyRootbooleanfalseA read-only root filesystem
cache{ claimName }noneA ReadWriteMany claim shared at /cache (else an emptyDir per pod)
persistence{ storageClassName?, size? }noneA claim per workspace for /workspace and the home, so a waiting run's pod can be suspended; a custom client then needs the claim methods
tmpSizestring'1Gi'Size of the in-memory /tmp
limitsSandboxLimitsDEFAULT_LIMITScpu, memory, storage, maxLifetimeMs (pids: see above)
podLabelsRecord<string, string>noneExtra labels, e.g. for a NetworkPolicy; may not name a managed label
startTimeoutMsnumber120_000How long create waits for the pod to run
pollMsnumber1_000Status poll interval while it waits
clockIClocksystemClockPolls, expiry and the exec guard
clientIPodClientcreatePodClient()Test seam

Misconfiguration throws core InvalidAgentDefinitionError; API and pod failures are SandboxErrors.

API

ExportKindPurpose
createKubernetesSandboxfunctionThe ISandboxProvider (name: 'k8s')
createPodClientfunctionThe IPodClient over @kubernetes/client-node
CONTAINER_NAMEconstant'sandbox'
POD_NAME_PREFIXconstant'chamvi-ws-'
START_TIMEOUT_MS, POLL_MSconstant120_000, 1_000
FATAL_WAITING_REASONSconstantThe container waiting reasons create fails on at once

Types: KubernetesSandboxOptions, KubernetesPersistence, PodUser, IPodClient, PodExecInput, CreatePodClientInput.

License

Published under Apache License 2.0.

版本与发布记录

@chamvi/sandbox-k8s 共发布 15 个版本,首次发布于 2026/10/25;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.8.0latest2026/11/09今天
0.7.32026/11/063 天前
0.7.22026/11/053 天前
0.7.12026/11/054 天前
0.7.02026/11/045 天前
0.6.12026/11/045 天前

关于 @chamvi/sandbox-k8s 的常见问题

@chamvi/sandbox-k8s 是什么?

Kubernetes sandbox provider for chamvi: one hardened pod per workspace.

如何安装 @chamvi/sandbox-k8s?

在项目目录运行 npm install @chamvi/sandbox-k8s,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@chamvi/core(^0.8.0)、@chamvi/sandbox(^0.8.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@chamvi/sandbox-k8s 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@chamvi/sandbox-k8s 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@chamvi/sandbox-k8s 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=26.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@chamvi/sandbox-k8s 可以商用吗?

@chamvi/sandbox-k8s 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@chamvi/sandbox-k8s 的下载量有多少?

根据 npm 官方下载统计,@chamvi/sandbox-k8s 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,904 次,日均约 97 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@chamvi/sandbox-k8s 还在维护吗?

最新版本 0.8.0 发布于 2026/11/09(今天);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/10/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。