Files
PaperClipAI/packages/plugins/sandbox-providers/kubernetes
DottaandPaperclip c6573962f8 fix(workspaces): recover full sandboxes through read-only export (#19465)
## Thinking Path

> - Paperclip preserves agent work across failed runs and server
restarts.
> - A remote workspace must have a durable save before its source can be
released.
> - Portable capture normally writes a Git mirror and a full archive
inside the sandbox.
> - A full sandbox can prevent capture before the provider's streaming
download starts.
> - Retrying that write cannot create the required free space.
> - This change transfers a checked standalone source and seals its
archive on the controller.
> - The source remains retained unless the existing durable save
completes.

## Linked Issues or Issue Description

Related: #18644 reduced temporary capture space. #18172 added the
provider's disk-free directory transfer. This repair connects portable
capture to that transfer when the archive itself cannot be created.

**What happened?**

Workspace capture returns `remote_workspace_sandbox_disk_full` after
provider work has finished. File recovery repeats the same sandbox
write.

**Expected behavior**

Recover a stable standalone workspace without requiring another full
archive on the source disk. Preserve staged changes, untracked work,
confined links and the original source.

**Steps to reproduce**

1. Create a standalone Git repository with a commit, staged changes and
an untracked result.
2. Use the existing real-command transport fixture. Inject ENOSPC into
the sandbox helper write or archive write.
3. Run portable capture. Before this change it rejects the helper
failure.
4. With the change, restore the saved archive after removing the fixture
source. Check the commit, staged content, untracked result and Git
connectivity.

## What Changed

- Attempt one controller-side capture after an exact sandbox disk-full
failure.
- Read a source inventory without installing a helper. Use the existing
provider bulk transfer with confined links and runtime exclusions.
- Compare the transferred inventory with source fingerprints before and
after sealing.
- Reject copied Git pointers, alternates and administrative symlinks.
Parse config as data without includes; allow only portable keys before
controller Git normalization.
- Preserve socket exclusions. Retain the source and remove incomplete
controller staging after failure.
- Update execution semantics and focused regression coverage. No
database migration or new execution authority.

## Verification

- TDD red on base `2f35eb1080`: the new full-sandbox test failed with
`remote_workspace_sandbox_disk_full` before production edits.
- Green: 202 transport and archive tests passed before the additional
Git configuration guards. At head `8541086cb5`, all 116 transport tests
and the PostgreSQL durability acceptance pass. The final `e24e173ec9`
exclusion guard passes those 116 cases plus its new Git-history
regression across the full run and focused rerun. Environment: macOS
arm64, Node 26.4.0; real Node/Git commands and filesystem transfer.
- Review regressions were also red before their fixes: an internal Git
config symlink bypassed the check, and `core.attributesFile` could
select a controller path. Git administrative symlinks and nonportable
config now fail before normalization. The focused safety selection
passes 11 cases.
- Cases cover helper and archive ENOSPC, commit/staged/untracked
continuity after source removal, confined links, sockets, changed
source, corrupt or extra copied files, unsafe Git metadata, retained
diagnostic text and failed download.
- Real PostgreSQL acceptance passes: normal workspace save recovers one
sandbox ENOSPC, commits the encrypted snapshot, clears its writer, loses
the original allocation, then independently reloads the saved result
twice. The first acceptance attempt used an incorrect expected status
(`available`); the documented state is `committed`. No production change
was needed for that correction.
- Review regressions for untracked Python/Node environments and
excessive Git roots failed before their fixes. Dependency exclusions now
match normal capture. Controller parsing starts only after
whole-tree/repository budgets and layout checks; refspec/settings
budgets and a shared two-minute verification/normalization deadline
bound further work. The excessive-root test verifies zero Git
subprocesses.
- Real provider-boundary red/green: Daytona direct tar and
capacity-fallback PTY transfers first dropped `._result.txt`. The new
optional mapping contract preserves all user names and excludes only
current transfer scratch. Kubernetes implements the same contract.
Explicit caller exclusions and archive validation remain in force.
- Standalone Linux qualification on Node 24.22.2/Debian bookworm: fresh
shared/SDK build; Daytona and Kubernetes typecheck and build; Daytona
file-sync/capacity/diagnostics 68/68 pass; Kubernetes file-sync 31/31
pass, including the actual `/proc` case. Qualification was refreshed
again on the final `be6f7feacc` source tree: current shared/SDK rebuilt;
all 815 staged source/config/manifest hashes match the delivered tree
before and after testing. An initial container run as root bypassed a
read-only file test; the same source passes as uid 1000. Installing zstd
enabled eight initially skipped cases.
- A further review regression proved `.git/pyvenv.cfg` could exclude Git
history as a dependency. The inventory now handles `.git` before
dependency filtering, matching normal capture. The full capture/restore
test verifies the original commit and saved work. The first green
attempt incorrectly compared command-result object identity; correcting
the test to compare stdout passed with no production change.
- Further red/green regressions cover read-only private-copy cleanup, a
regular file replaced by a FIFO before open, case aliases of Git
administrative metadata, and permission preservation under restrictive
umask. Nonblocking no-follow file handles verify identity before and
after reading. Cleanup changes only private controller directories.
Provider extraction preserves ordinary permissions, refuses special mode
bits before writing, and does not adopt source ownership, ACLs,
attributes or flags. Final macOS Daytona tests pass 65/65; the final
Linux qualification above was repeated on these exact provider sources.
- Focused strict TypeScript compilation of the inventory and archive
modules passes.
- The first sandboxed archive run failed three Unix socket fixtures with
EPERM. The permitted run outside that filesystem sandbox passed all
tests.
- Initial full local server typecheck could not resolve the unbuilt
Runner package and produced cascading errors. Exact-head `e24e173ec9` CI
typecheck, build, Runner and browser checks pass. That CI attempt failed
two server shards: the unchanged retained-cleanup backlog test timed out
at 15 seconds and passes locally unchanged; ten cache-reclaim fixture
oracles failed because directory fallback consumed their sealed-archive
counters/faults. Final commit `caed4e4f83` isolates the intended
unavailable-directory boundary while preserving archive counts, recovery
status, integrity and no-second-reclamation assertions. Those ten
failures reproduced locally before the fixture edit. The full affected
file passes 394/395; its sole other HTTP socket-hang-up case passes
unchanged in an isolated rerun. This is combined coverage of all 395
cases, not a single complete green run. The final branch integrates
master `53645a0104` at `98ea07225592930cbb0d18bcf250bc64f6bb13b4`,
preserving upstream workspace phase timings. After integration: 117
transport cases, the PostgreSQL durability acceptance, the existing
object-storage/timing regression, and 12 affected cache cases pass. The
expanded recovery file now has 421 cases. Final-head
`be6f7feacc45c1e32a26a2417e973f1e3752fd8d` CI passes all 55 applicable
checks (three skipped, one neutral review), including the expanded
recovery suite, typecheck, builds and Runner coverage. [CI
result](https://github.com/paperclipai/paperclip/actions/runs/42251345554).
- Provider exclusion regressions were red through four real Linux
transfer paths: Daytona direct archive, direct safe-link repair,
capacity PTY repair, and Kubernetes streaming. A root socket and nested
regular file share a basename; literal glob-character files/subtrees
also survive. Preserve mode now anchors tar exclusions as literal
root-relative paths and escapes `find -path` pruning. Default callers
keep glob semantics. Receipt-count regressions were also red before
correcting preserved-output counting. All 31 Kubernetes and 68 Daytona
tests, builds and typechecks pass on the final source with 815 matching
inputs.
- The final semantics documentation explicitly records special mode
bits, the stricter config allowlist (including innocuous unlisted keys),
and provider qualification limited to Daytona/Kubernetes; these address
the optional current-head review observations without broadening
controller Git authority.
- No Daytona credential is available in this session. Provider transfer
is a local filesystem boundary in these tests. Hosted task recovery and
rollout remain unverified.

## Risks

- Recovery reads the source repeatedly and transfers the standalone
tree. It can take longer than normal capture.
- Linked worktrees, initialized submodules, Git administrative symlinks,
nonportable Git configuration, special mode bits and alternate object
stores remain retained if the normal capture cannot finish. This
fallback does not follow their pointers onto the controller.
- A provider without usable directory export, a changed source, or
insufficient controller space cannot produce a receipt. The original
source stays retained.
- The server, SDK and affected provider plugins must be built from this
revision together. An older provider can fail inventory verification on
names it omits; it cannot publish an incomplete save. No merge,
publication or deployment is included.

## Model Used

OpenAI GPT-6 family through Codex, with reasoning, code editing, shell
tools and automated tests. The exact serving model ID and context window
are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 11:00:32 -06:00
..

@paperclipai/plugin-kubernetes (alpha)

First-party Paperclip sandbox-provider plugin for Kubernetes.

Alpha: the default backend (sandbox-cr) is built on kubernetes-sigs/agent-sandbox v1alpha1 — expect breaking changes as that CRD evolves toward Beta. A stable fallback backend (job, using batch/v1 Job) is available for clusters without agent-sandbox installed, but it does NOT support multi-command exec (paperclip-server's adapter-install pattern requires sandbox-cr).

Prerequisites

  1. A Kubernetes cluster running k8s 1.27+
  2. kubernetes-sigs/agent-sandbox controller installed in the cluster (alpha — installs the sandboxes.agents.x-k8s.io/v1alpha1 CRD and controller)
  3. Paperclip-server running with access to the cluster (in-cluster via inCluster: true or external via kubeconfig)

For job backend (stable fallback)

  1. A Kubernetes cluster running k8s 1.27+
  2. Paperclip-server with cluster access — no additional controllers or CRDs required

Installation

paperclipai plugin install @paperclipai/plugin-kubernetes

Or, for local development:

paperclipai plugin install --local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes

Backends

The plugin supports two backend modes, selected via the backend config field:

Backend Default Stability Multi-command exec Requires
sandbox-cr Yes Alpha Yes kubernetes-sigs/agent-sandbox controller
job No Stable No Nothing beyond k8s 1.27+

sandbox-cr (default): Creates a Sandbox CR (agents.x-k8s.io/v1alpha1) whose controller provisions a long-lived pod running sleep infinity. paperclip-server execs individual commands into the running pod — this is the multi-command adapter-install pattern. When you releaseLease, the Sandbox CR is deleted and the controller tears down the pod.

job (stable fallback): Creates a batch/v1 Job. The container entrypoint runs once and exits — no multi-command exec possible. Use this when you cannot install agent-sandbox, or when you need strictly stable Kubernetes APIs. Note: paperclip-server's adapter-install pattern will not work in job mode.

Migrating from job to sandbox-cr

  1. Install the agent-sandbox controller: kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/sandbox.yaml
  2. Update your environment config to set backend: "sandbox-cr" (or remove backend since sandbox-cr is the default)
  3. New leases will use the Sandbox CR backend. Existing leases created with job mode continue to use job semantics until they are released.

Configuration

Create a sandbox environment with driver: kubernetes. One of these auth fields is required:

  • inCluster: true — use the in-pod ServiceAccount credentials (when paperclip-server runs inside the same cluster).
  • kubeconfig: <YAML> — inline kubeconfig (stored as a company secret).
  • kubeconfigSecretRef: <secret-uuid> — reference to an existing Paperclip secret.

Common optional fields:

Field Default Purpose
backend "sandbox-cr" sandbox-cr (alpha, requires agent-sandbox controller) or job (stable, one-shot entrypoint).
adapterType "claude_local" One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, pi_local). Determines runtime image + env keys + egress allow-list.
namespacePrefix "paperclip-" Prefix for the per-company tenant namespace.
companySlug derived from companyId Override the auto-derived company slug.
imageRegistry (none) Override the default registry for agent runtime images.
imageAllowList [] Glob patterns of allowed target.imageOverride values. Empty = no override permitted.
imagePullSecrets [] Names of pre-created Docker image pull secrets in the tenant namespace.
egressAllowFqdns [] Additional FQDNs (beyond adapter defaults like api.anthropic.com).
egressAllowCidrs [] Additional CIDRs to allow egress to.
egressMode "standard" standard (NetworkPolicy + CIDRs) or cilium (CiliumNetworkPolicy + FQDN allow-list).
runtimeClassName (none) e.g. kata-fc for Firecracker-backed microVMs. Cluster must have the RuntimeClass installed.
serviceAccountAnnotations {} Annotations applied to per-tenant ServiceAccount (e.g. IRSA eks.amazonaws.com/role-arn).
jobTtlSecondsAfterFinished 900 Seconds after a Job completes before garbage-collection.
podActivityDeadlineSec 3600 Hard ceiling on a single run's wall-clock time.

Full JSON Schema in src/manifest.ts.

Task-scoped egress grants

Keep provider-level egress defaults narrow, then grant only the destinations a task needs through its execution workspace settings:

{
  "executionWorkspaceSettings": {
    "networkEgress": {
      "allowFqdns": ["github.com", "pypi.org"],
      "allowCidrs": []
    }
  }
}

The provider creates a workload-owned policy selected by the task run label, so the additional destinations do not become reachable from other concurrent agent pods. Cilium mode enforces FQDNs directly. Standard NetworkPolicy mode cannot express FQDNs, so an FQDN grant permits public IPv4 TCP 80/443 for that run while excluding private, loopback, link-local, CGNAT, and multicast ranges. Network failures that look policy-related include the grant path in stderr, and the sandbox exposes the effective policy through PAPERCLIP_NETWORK_EGRESS_* environment variables.

What gets created in your cluster

For each company that runs agents (created lazily on first dispatch):

Namespace          paperclip-{companySlug}        (PSS: restricted enforce + audit)
ServiceAccount     paperclip-tenant-sa
Role               paperclip-tenant-role          (only get pods/log)
RoleBinding        paperclip-tenant-rb
ResourceQuota      paperclip-quota                (pods, requests/limits cpu+memory)
LimitRange         paperclip-limits               (container max/min/default/defaultRequest)
NetworkPolicy      paperclip-deny-all             (deny ingress + egress baseline)
NetworkPolicy      paperclip-egress-allow         (DNS + paperclip-server callback + user CIDRs)
                   OR CiliumNetworkPolicy paperclip-egress-fqdn if egressMode=cilium

For each agent run (sandbox-cr backend):

Sandbox CR         pc-{ulid}                       (agents.x-k8s.io/v1alpha1; explicit delete on release)
Pod                pc-{ulid}-{podSuffix}           (managed by Sandbox controller; torn down on CR delete)
Secret             pc-{ulid}-env                   (owned by Sandbox CR; cascade-deleted)

For each agent run (job backend):

Job                pc-{ulid}                       (backoffLimit: 0, ttlSecondsAfterFinished from config)
Pod                pc-{ulid}-{podSuffix}           (owned by Job; cascade-deleted)
Secret             pc-{ulid}-env                   (owned by Job; cascade-deleted)

Security baseline

Every agent pod is:

  • non-root (runAsUser: 1000, runAsGroup: 1000, runAsNonRoot: true)
  • drops ALL Linux capabilities, allowPrivilegeEscalation: false
  • readOnlyRootFilesystem: true with explicit emptyDir mounts for /workspace, /home/paperclip, /home/paperclip/.cache, /tmp
  • seccompProfile: RuntimeDefault
  • Tini as PID 1 (reaps zombies, forwards signals)
  • fsGroupChangePolicy: OnRootMismatch (fast PVC startup; openclaw-operator lesson)
  • automountServiceAccountToken: true (for the agent shim's paperclip-server callback)

Plus per-namespace pod-security.kubernetes.io/enforce: restricted and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, configured FQDNs/CIDRs).

The per-run Secret carrying the bootstrap token and adapter API keys has ownerReferences pointing at the owning Job, so a single kubectl delete job … cascades cleanly to the Pod and Secret.

Optional Kata-FC microVM isolation

For stronger isolation, install Kata Containers with the Firecracker hypervisor, then set runtimeClassName: kata-fc in the plugin config. Each agent pod will run inside a Firecracker microVM. Requires nested-virt-capable nodes (bare-metal or specific cloud instance types).

Roadmap

  • Phase A (done): sandbox-cr backend — multi-command exec via agent-sandbox Sandbox CRD.
  • Phase B: Warm pool support — pre-provisioned Sandbox CRs for sub-second cold starts. The SandboxOrchestrator interface reserves optional pause?/resume? extension slots.
  • Phase C: Kata-FC + snapshots — runtimeClassName: kata-fc with VM snapshot for fast restore.
  • Phase D: Contribute back to agent-sandbox upstream if their Beta model diverges from our needs. The SandboxOrchestrator interface (src/sandbox-orchestrator.ts) is the clean swap point — a new implementation can be added without touching plugin.ts business logic.

Lessons learned (from openclaw-operator)

This plugin adopts patterns from openclaw-rocks/openclaw-operator:

  • Tini PID 1 (issue #471 — zombie helper processes)
  • Read-only rootFS with explicit writable mounts (issue #456 — ~/.config not writable)
  • Strategic merge on reconcile (issue #446 — preserve third-party annotations)
  • Multi-storage-class testing (issue #448 — local-path-provisioner differences)
  • Image version compat matrix (issue #462 — runtime deps cannot resolve after upgrade)

Development

cd packages/plugins/sandbox-providers/kubernetes
pnpm install --ignore-workspace
pnpm test           # unit tests only (fast)
pnpm typecheck
pnpm build

To run the kind-cluster integration test (requires kubectl --context kind-paperclip and a pre-loaded alpine image; see test/integration/end-to-end-run.test.ts):

RUN_K8S_INTEGRATION_TESTS=1 pnpm test test/integration/end-to-end-run.test.ts