Files
paperclip/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
T
Jannes Stubbemann 05ab45225a feat(plugin-kubernetes): self-hostable Kubernetes sandbox provider (stage 1/3: plugin package) (#5790)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox providers are the seam that lets agent runs execute in
isolated environments; today the only first-party remote provider is
Daytona, a hosted third-party service
> - Self-hosters running Paperclip on their own infrastructure (often
Kubernetes already) have no first-party way to run agent sandboxes on a
cluster they control
> - That gap matters for teams with data-residency, sovereignty, or cost
constraints who cannot or will not send workloads to a hosted sandbox
service
> - This pull request adds a Kubernetes sandbox-provider plugin as a
standalone, workspace-excluded package: it implements every
SandboxProvider hook the Daytona provider does, on infrastructure the
operator owns
> - The benefit is that any Paperclip deployment with a Kubernetes
cluster gets multi-tenant, network-isolated, quota-bounded agent
sandboxes with zero new external dependencies

## Linked Issues or Issue Description

No existing issue. Following the feature template:

- **Problem:** Paperclip's remote sandbox execution requires a hosted
third-party provider. Self-hosters cannot run agent sandboxes on their
own Kubernetes clusters with a first-party provider.
- **Proposed solution:** A `@paperclipai/plugin-kubernetes`
sandbox-provider plugin with two backends: long-lived sandboxes via the
[kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)
CRD (multi-command exec, adapter-install pattern) and one-shot
`batch/v1` Jobs (stable APIs only, no extra controllers).
- **Alternatives considered:** Driving kubectl from a generic shell
provider (no lifecycle/lease semantics), or requiring a hosted provider
(exactly the constraint this removes).

## What Changed

This is **stage 1 of 3** of a staged contribution (direction agreed with
maintainers): the plugin package alone. Stage 2 (server integration:
lease params, provider registration) and stage 3 (agent runtime images +
CI) are companion PRs that will be cross-linked from a comment here.

- New package `packages/plugins/sandbox-providers/kubernetes`
(workspace-excluded, like the path already carved out in
`pnpm-workspace.yaml`): src, unit + kind integration tests, operator
prerequisite manifests, README, smoke-test guide
- Implements the full SandboxProvider hook surface the Daytona provider
implements: `validateConfig`, `probe`, `acquireLease`, `resumeLease`,
`releaseLease`, `destroyLease`, `realizeWorkspace`, `execute`
- Two backends: `sandbox-cr` (default; long-lived pod via the
agent-sandbox `Sandbox` CR, supports multi-command exec) and `job`
(one-shot `batch/v1` Job; nothing beyond k8s 1.27+ required)
- Per-run adapter resolution: one environment serves mixed harnesses;
the per-run `adapterType` hint is read through a local optional type
extension, so the plugin typechecks and builds against the current
plugin SDK and simply falls back to the environment's configured default
adapter until stage 2 lands
- Exec-env wrapping: the Kubernetes exec API carries no environment, so
commands are wrapped to receive the run's env
- Fast-upload interception for workspace realization, scoped per lease
- Per-tenant isolation: derived namespace per company, RBAC,
ResourceQuota, restricted-PSS pod security (runAsNonRoot, drop ALL,
seccomp RuntimeDefault, no SA token automount)
- Network egress policy in two flavors: native `NetworkPolicy` and
`CiliumNetworkPolicy` (FQDN allowlists)
- Image allowlist with glob matching, registry override, and per-run
image override validation
- Per-run Kubernetes Secrets carrying agent credentials, ownerRef'd to
the Job or Sandbox CR for cascade GC

## Verification

- Standalone build, exactly as the README documents:
  ```bash
  cd packages/plugins/sandbox-providers/kubernetes
  pnpm install --ignore-workspace
  pnpm test        # 147 unit tests, 17 files, all green
  pnpm typecheck   # clean against the in-repo plugin SDK on master
pnpm build # dist/ emitted, manifest + worker entrypoints present
  ```
- A kind-cluster end-to-end integration test is included
(`RUN_K8S_INTEGRATION_TESTS=1 pnpm test
test/integration/end-to-end-run.test.ts`)
- Beyond CI: this provider has been verified in a production
multi-tenant deployment against five harnesses (opencode, pi, codex,
gemini, claude code) with real billed runs

## Risks

- **Zero behavior change for any existing deployment.** The package is
workspace-excluded; nothing in the server imports or loads it until
stage 2's integration lands. No existing code paths are touched.
- The default `sandbox-cr` backend depends on an alpha CRD
(`agents.x-k8s.io/v1alpha1`); the README flags this and the `job`
backend uses only stable APIs as a fallback.
- Risk surface is confined to deployments that explicitly install and
configure the plugin.
- The default runtime images (`ghcr.io/paperclipai/agent-runtime-*`) are
published by the stage 3 companion PR (#7934); until that lands,
deployments must point `runtimeImage` at their own images.

## Model Used

Claude Opus 4.8 (1M context), extended thinking, with tool use (Claude
Code).

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending this push)
- [ ] 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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:07:00 -07:00

865 lines
33 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
import {
kubernetesProviderConfigSchema,
type KubernetesProviderConfig,
type KubernetesLeaseMetadata,
} from "./types.js";
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
import { getAdapterDefaults, buildAdapterEnv, resolveRunAdapterType } from "./adapter-defaults.js";
import { resolveImage } from "./image-allowlist.js";
import { buildJobManifest } from "./pod-spec-builder.js";
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
import { ensureTenant } from "./tenant-orchestrator.js";
import { createPerRunSecret } from "./secret-manager.js";
import { FastUploadInterceptor } from "./upload-interceptor.js";
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
import {
sandboxCrOrchestrator,
SandboxCrTimeoutError,
} from "./sandbox-cr-orchestrator.js";
import { execInPod, wrapCommandWithEnv } from "./pod-exec.js";
import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
import {
deriveCompanySlug,
deriveNamespaceName,
newRunUlidDns,
paperclipLabels,
} from "./utils.js";
// The namespace paperclip-server itself runs in. Used when building
// NetworkPolicy manifests so the tenant namespace allows inbound traffic
// from the server pod.
const PAPERCLIP_SERVER_NAMESPACE = "paperclip";
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
// Resource quota defaults applied to every tenant namespace (tunable via
// config in a future iteration).
const DEFAULT_RESOURCE_QUOTA = {
pods: "20",
requestsCpu: "10",
requestsMemory: "20Gi",
limitsCpu: "20",
limitsMemory: "40Gi",
};
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
// TODO: future versions could thread companyName through AcquireLeaseParams
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
const slug = config.companySlug ?? deriveCompanySlug(companyId);
return deriveNamespaceName(config.namespacePrefix, slug);
}
function generateBootstrapToken(): string {
// TODO: tighten once the agent runtime shim (companion images PR) lands its
// callback auth scheme; paperclip-server's callback auth is out of scope for
// this plugin. For now this per-run random token is stored in the per-run
// Secret and read by the runtime image entrypoint for initial registration.
return randomBytes(32).toString("hex");
}
// One FastUploadInterceptor instance per active lease. Scoping per lease
// prevents `releaseLease` from wiping in-flight upload buffers belonging to
// other concurrent leases — a single shared singleton would do exactly that
// on `reset()`. The Map is keyed by `providerLeaseId`; entries are lazily
// created in `onEnvironmentExecute` and removed in `onEnvironmentReleaseLease`.
const uploadInterceptorsByLease = new Map<string, FastUploadInterceptor>();
function getOrCreateUploadInterceptor(leaseId: string): FastUploadInterceptor {
let interceptor = uploadInterceptorsByLease.get(leaseId);
if (!interceptor) {
interceptor = new FastUploadInterceptor();
uploadInterceptorsByLease.set(leaseId, interceptor);
}
return interceptor;
}
// In-memory cache of sandbox CR names we've already observed reaching the
// Ready condition during the current plugin-worker lifetime. The k8s
// sandbox-cr lifecycle means once a Sandbox pod is Running, subsequent
// execs into it don't need another readiness poll — saves one
// `getNamespacedCustomObject` round-trip per exec, which adds up across
// dozens of sequential exec calls in a typical adapter workflow.
// On worker restart this resets, which is fine: the first exec on each
// lease then re-confirms readiness from scratch.
const readySandboxesByLease = new Set<string>();
// How long onEnvironmentResumeLease waits for an existing Sandbox pod to
// report Ready before declaring the lease non-resumable. Deliberately short:
// this is a liveness check on an already-provisioned pod, not a fresh
// provision — if the pod isn't (almost) up, falling back to acquireLease is
// faster and more reliable than waiting.
const RESUME_READY_TIMEOUT_MS = 30_000;
const RESUME_READY_POLL_MS = 1_000;
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("Kubernetes sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((i) => i.message),
};
}
const warnings: string[] = [];
const cfg = parsed.data;
const adapterDefaults = getAdapterDefaults(cfg.adapterType, cfg.adapters);
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
if (cfg.egressAllowCidrs.length === 0) {
warnings.push(
`egressMode=standard cannot enforce FQDN-based egress rules (Kubernetes NetworkPolicy is CIDR-only). To keep the configured FQDNs reachable (${totalFqdns.join(", ")}) without operator intervention, the plugin will allow public IPv4 egress on TCP 80/443 with private/link-local/loopback/multicast ranges excluded. This is broader than exact FQDN allow-listing — switch egressMode to "cilium" (requires Cilium CNI) for precise enforcement, or set egressAllowCidrs explicitly to override the fallback.`,
);
} else {
warnings.push(
`egressMode=standard cannot enforce FQDN-based egress rules. The following FQDNs are reachable only via the operator-supplied egressAllowCidrs: ${totalFqdns.join(", ")}. Switch egressMode to "cilium" (requires Cilium CNI) for exact FQDN allow-listing.`,
);
}
}
return { ok: true, normalizedConfig: cfg as Record<string, unknown>, warnings: warnings.length > 0 ? warnings : undefined };
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
if (!parsed.success) {
return {
ok: false,
summary: "Invalid Kubernetes provider configuration.",
metadata: {
errors: parsed.error.issues.map((i) => i.message),
},
};
}
const config = parsed.data;
const namespace = deriveTenantNamespace(config, params.companyId);
try {
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
// Reachability check: list pods in the tenant namespace. If the namespace
// doesn't exist yet this will throw a 404 which we treat as "reachable
// but namespace not provisioned" — still a successful probe.
try {
await clients.core.listNamespacedPod({ namespace });
} catch (err) {
const code = (err as { code?: number; statusCode?: number }).code
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
// 404 means namespace doesn't exist yet — cluster is reachable.
}
return {
ok: true,
summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
metadata: { namespace, provider: "kubernetes" },
};
} catch (err) {
return {
ok: false,
summary: "Kubernetes cluster probe failed.",
metadata: {
namespace,
provider: "kubernetes",
error: err instanceof Error ? err.message : String(err),
},
};
}
},
async onEnvironmentAcquireLease(
// `adapterType` is an optional per-run hint the server may pass once the
// SDK lease params grow that field (companion server-integration PR). The
// plugin works without it: absent means "use the environment's configured
// default adapter", so it stays compatible with the current SDK.
params: PluginEnvironmentAcquireLeaseParams & { adapterType?: string },
): Promise<PluginEnvironmentLease> {
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace = deriveTenantNamespace(config, params.companyId);
// The adapter for THIS run is the agent's adapter (params.adapterType) when
// supplied, so one environment can serve mixed harnesses; otherwise fall back
// to the environment's configured default adapter. getAdapterDefaults validates
// it is a registered adapter (throws otherwise), so a curated-out adapter fails
// the lease as before.
const effectiveAdapterType = resolveRunAdapterType(params.adapterType, config.adapterType);
// Emit a runtime warning if FQDNs are configured but egressMode=standard
// cannot enforce them. Mirrors the validateConfig warning so operators see
// it in paperclip-server logs even if they missed the validation step.
const adapterDefaultsForWarn = getAdapterDefaults(effectiveAdapterType, config.adapters);
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
if (config.egressAllowCidrs.length === 0) {
console.warn(
`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules; falling back to public-IPv4 (TCP 80/443) with private/link-local ranges excluded so the configured FQDNs (${totalFqdnsForWarn.join(", ")}) remain reachable. Switch egressMode to "cilium" for exact FQDN allow-listing.`,
);
} else {
console.warn(
`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules. The following FQDNs are reachable only via operator-supplied egressAllowCidrs: ${totalFqdnsForWarn.join(", ")}. Switch egressMode to "cilium" for exact FQDN allow-listing.`,
);
}
}
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
// Ensure the tenant namespace and all its RBAC / network policy resources
// exist before we try to create the Job.
const adapterDefaults = getAdapterDefaults(effectiveAdapterType, config.adapters);
await ensureTenant(clients, {
namespace,
companyId: params.companyId,
paperclipServerNamespace: PAPERCLIP_SERVER_NAMESPACE,
serviceAccountAnnotations: config.serviceAccountAnnotations,
egressMode: config.egressMode,
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
egressAllowCidrs: config.egressAllowCidrs,
resourceQuota: DEFAULT_RESOURCE_QUOTA,
});
const jobName = `pc-${newRunUlidDns()}`;
const secretName = `${jobName}-env`;
// TODO: use params.runId as stand-in for agentId in labels; future
// versions will have a dedicated agentId on AcquireLeaseParams.
const labels = paperclipLabels({
runId: params.runId,
agentId: params.runId,
companyId: params.companyId,
adapterType: effectiveAdapterType,
});
const image = resolveImage(
{ imageOverride: null },
adapterDefaults,
{ imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry },
);
// Pick the orchestrator and build the appropriate manifest based on backend.
const isSandboxCrBackend = config.backend === "sandbox-cr";
const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
const manifest = isSandboxCrBackend
? buildSandboxCrManifest({
namespace,
sandboxName: jobName,
adapterType: effectiveAdapterType,
image,
envSecretName: secretName,
serviceAccountName: TENANT_SERVICE_ACCOUNT,
labels,
resources: config.defaultResources ?? {},
runtimeClassName: config.runtimeClassName,
imagePullSecrets: config.imagePullSecrets,
})
: buildJobManifest({
namespace,
jobName,
adapterType: effectiveAdapterType,
image,
envSecretName: secretName,
serviceAccountName: TENANT_SERVICE_ACCOUNT,
labels,
resources: config.defaultResources ?? {},
runtimeClassName: config.runtimeClassName,
activeDeadlineSec: config.podActivityDeadlineSec,
ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
imagePullSecrets: config.imagePullSecrets,
});
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
// defaultEnv (non-secret base, e.g. the inference base URL) is layered first;
// the process-env secrets named by envKeys override it.
const adapterEnv = buildAdapterEnv(adapterDefaults);
const bootstrapToken = generateBootstrapToken();
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
// For sandbox-cr backend, the Sandbox CR owns the Secret.
// NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
// quirk, the release() call will still clean it up via namespace GC or
// explicit delete in a future iteration.
await createPerRunSecret(clients, {
namespace,
secretName,
runId: params.runId,
ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
ownerName: jobName,
ownerUid,
bootstrapToken,
adapterEnv,
});
const podName = await orchestrator.findPod(clients, namespace, jobName);
const leaseMetadata: KubernetesLeaseMetadata = {
namespace,
jobName,
podName,
secretName,
phase: "Pending",
backend: config.backend,
};
return {
providerLeaseId: jobName,
metadata: leaseMetadata as unknown as Record<string, unknown>,
};
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof params.leaseMetadata?.namespace === "string"
? params.leaseMetadata.namespace
: deriveTenantNamespace(config, params.companyId);
const leaseBackend =
typeof params.leaseMetadata?.backend === "string"
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
: config.backend;
// acquireLease names the per-run Secret `${jobName}-env` and uses jobName
// as the providerLeaseId, so the suffix fallback reconstructs it exactly.
const secretName =
typeof params.leaseMetadata?.secretName === "string"
? params.leaseMetadata.secretName
: `${params.providerLeaseId}-env`;
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
const check = await checkLeaseResumable(clients, {
namespace,
name: params.providerLeaseId,
backend: leaseBackend,
readyTimeoutMs: RESUME_READY_TIMEOUT_MS,
pollMs: RESUME_READY_POLL_MS,
});
if (!check.resumable) {
// Kubernetes pods are NOT restartable the way Daytona sandboxes are: a
// stopped Daytona sandbox can be started again by ID, but a k8s pod that
// is gone or terminally failed can never be revived in place. Gone = not
// resumable, by design. Returning providerLeaseId: null tells the server
// the lease expired so it falls back to a fresh acquireLease.
return {
providerLeaseId: null,
metadata: { expired: true, reason: check.reason },
};
}
// A resumed lease starts with clean per-lease state: drop any stale upload
// interceptor buffers a previous run on this lease may have left behind.
uploadInterceptorsByLease.delete(params.providerLeaseId);
if (leaseBackend === "sandbox-cr") {
// We just observed the Sandbox pod Ready, so the first exec on the
// resumed lease can skip its readiness poll.
readySandboxesByLease.add(params.providerLeaseId);
}
const leaseMetadata: KubernetesLeaseMetadata = {
namespace,
jobName: params.providerLeaseId,
podName: check.podName,
secretName,
phase: check.phase,
backend: leaseBackend,
};
return {
providerLeaseId: params.providerLeaseId,
metadata: {
...leaseMetadata,
resumedLease: true,
} as unknown as Record<string, unknown>,
};
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
// The agent pod already has /workspace mounted as an emptyDir at pod
// scheduling time (see pod-spec-builder). Nothing to provision here —
// we just hand back the cwd. Honor a caller-supplied remotePath if set.
const cwd =
params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
? params.workspace.remotePath.trim()
: "/workspace";
return {
cwd,
metadata: {
provider: "kubernetes",
remoteCwd: cwd,
},
};
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof params.leaseMetadata?.namespace === "string"
? params.leaseMetadata.namespace
: deriveTenantNamespace(config, params.companyId);
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
const leaseBackend =
typeof params.leaseMetadata?.backend === "string"
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
: config.backend;
const releaseOrchestrator =
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
// Drop the FastUploadInterceptor associated with THIS lease (only).
// Each lease has its own interceptor instance via uploadInterceptorsByLease,
// so unrelated concurrent leases keep their in-flight buffers intact.
uploadInterceptorsByLease.delete(params.providerLeaseId);
readySandboxesByLease.delete(params.providerLeaseId);
try {
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
} catch (err) {
// If the resource is already gone (404), that's fine.
const code = (err as { code?: number; statusCode?: number }).code
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
}
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof params.leaseMetadata?.namespace === "string"
? params.leaseMetadata.namespace
: deriveTenantNamespace(config, params.companyId);
const leaseBackend =
typeof params.leaseMetadata?.backend === "string"
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
: config.backend;
const secretName =
typeof params.leaseMetadata?.secretName === "string"
? params.leaseMetadata.secretName
: `${params.providerLeaseId}-env`;
const podName =
typeof params.leaseMetadata?.podName === "string" &&
params.leaseMetadata.podName.length > 0
? params.leaseMetadata.podName
: null;
// Clear per-lease in-memory state up front, regardless of what the
// cluster says — the lease is dead either way.
uploadInterceptorsByLease.delete(params.providerLeaseId);
readySandboxesByLease.delete(params.providerLeaseId);
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
// Forcibly delete everything acquireLease created (Sandbox CR / Job, pod,
// per-run Secret). 404s are success — destroy must be idempotent.
await destroyLeaseResources(clients, {
namespace,
name: params.providerLeaseId,
backend: leaseBackend,
podName,
secretName,
});
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
const { lease, timeoutMs } = params;
if (!lease.providerLeaseId) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof lease.metadata?.namespace === "string"
? lease.metadata.namespace
: deriveTenantNamespace(config, params.companyId);
// Determine which backend this lease was created with.
const leaseBackend =
typeof lease.metadata?.backend === "string"
? (lease.metadata.backend as "sandbox-cr" | "job")
: config.backend;
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
const effectiveTimeoutMs =
typeof timeoutMs === "number" && timeoutMs > 0
? timeoutMs
: config.podActivityDeadlineSec * 1000;
if (leaseBackend === "sandbox-cr") {
// ── Sandbox-CR backend ──────────────────────────────────────────────────
// 1. Ensure the Sandbox pod is Ready (wait only on first exec for this lease).
// 2. Exec the command into the running pod.
// 3. Return exec result directly (no log scraping needed).
let podName =
typeof lease.metadata?.podName === "string" && lease.metadata.podName
? lease.metadata.podName
: null;
// Skip the readiness poll if we've already observed this Sandbox CR
// reaching Ready during this worker's lifetime. See readySandboxesByLease
// declaration for rationale.
const podAlreadyKnownReady = readySandboxesByLease.has(lease.providerLeaseId);
// The caller's timeout is a budget for the WHOLE execute call: readiness
// wait + exec must share it, or the first exec on a fresh lease could
// block for up to twice the requested timeout.
const executeStartedAt = Date.now();
if (!podAlreadyKnownReady) {
try {
await sandboxCrOrchestrator.waitForCompletion(
clients,
namespace,
lease.providerLeaseId,
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
);
readySandboxesByLease.add(lease.providerLeaseId);
} catch (err) {
if (err instanceof SandboxCrTimeoutError) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
},
};
}
throw err;
}
}
// Resolve pod name (may now be populated in Sandbox status).
if (!podName) {
podName = await sandboxCrOrchestrator.findPod(
clients,
namespace,
lease.providerLeaseId,
);
}
if (!podName) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Sandbox pod is Ready but podName could not be resolved.",
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
},
};
}
// Build the command to exec. The adapter passes shell invocations as
// `command: "sh", args: ["-c", "<script>"]` — must combine both, NOT
// drop args. If only command is present (no args), wrap in a login shell.
const command = typeof params.command === "string" ? params.command.trim() : "";
const args = Array.isArray(params.args) ? params.args : [];
// Fast-upload interceptor: short-circuit the chunked-shell file transfer
// protocol (adapter-utils writeFile) so an N-chunk upload becomes 1 exec
// instead of N+2. Falls back transparently when patterns don't match.
// See upload-interceptor.ts.
const shellScript =
command === "sh" && args[0] === "-c" && typeof args[1] === "string"
? args[1]
: null;
if (shellScript) {
const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
if (decision.action === "ack") {
return {
exitCode: 0,
timedOut: false,
stdout: "",
stderr: "",
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "ack",
},
};
}
if (decision.action === "flush") {
// Single exec: `head -c <N> | base64 -d > '<TARGET>'` with stdin =
// base64 ASCII. `head -c` reads EXACTLY N bytes and exits, so we
// don't depend on WebSocket-driven EOF detection on stdin (which is
// racy against the `base64 -d` exit timing in @kubernetes/client-node
// v0.21.0 — see pod-exec.ts). All bytes are sent through the
// WebSocket data channel; size is unbounded by ARG_MAX.
const base64Body = decision.flush.payload.toString("base64");
const dir = decision.flush.targetPath.substring(
0,
decision.flush.targetPath.lastIndexOf("/"),
);
const script =
`mkdir -p '${dir}' && ` +
`head -c ${base64Body.length} | base64 -d > '${decision.flush.targetPath}'`;
// The flush shares the caller's single execute budget (same contract
// as the normal exec path below) and surfaces watchdog/WebSocket
// failures as a timed-out result instead of an uncaught throw.
const flushTimeoutMs = Math.max(
5_000,
effectiveTimeoutMs - (Date.now() - executeStartedAt),
);
let flushResult: { exitCode: number; stdout: string; stderr: string };
try {
flushResult = await execInPod(
kc,
namespace,
podName,
"agent",
["/bin/sh", "-c", script],
base64Body,
flushTimeoutMs,
);
} catch (err) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `fast-upload flush failed: ${err instanceof Error ? err.message : String(err)}`,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "flush",
},
};
}
return {
exitCode: flushResult.exitCode,
timedOut: false,
stdout: flushResult.stdout,
stderr: flushResult.stderr,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "flush",
uploadedBytes: decision.flush.payload.length,
},
};
}
// decision.action === "passthrough" — fall through to normal exec
}
const baseExecCommand =
command.length > 0 && args.length > 0
? [command, ...args]
: command.length > 0
? ["/bin/sh", "-lc", command]
: ["/bin/sh", "-l"];
// Apply the caller-provided run env (params.env) to the in-pod process. Without
// this the adapter's runtime env (e.g. XDG_CONFIG_HOME pointing at the shipped
// OpenCode config, plus helper settings like small_model/provider routing) never
// reaches the harness, which falls back to its in-image HOME config -> wrong or
// partial behaviour.
const execCommand = wrapCommandWithEnv(baseExecCommand, params.env);
// Remaining share of the caller's budget after the readiness wait (floor
// of 5s so an exec attempt is still made when readiness consumed most of
// it; the watchdog then bounds it tightly).
const remainingTimeoutMs = Math.max(
5_000,
effectiveTimeoutMs - (Date.now() - executeStartedAt),
);
let execResult: { exitCode: number; stdout: string; stderr: string };
try {
execResult = await execInPod(
kc,
namespace,
podName,
"agent",
execCommand,
typeof params.stdin === "string" ? params.stdin : undefined,
remainingTimeoutMs,
);
} catch (err) {
// Watchdog-fired or WebSocket-setup error. Surface as a timeout so
// the caller can retry instead of hanging forever.
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: err instanceof Error ? err.message : String(err),
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
},
};
}
return {
exitCode: execResult.exitCode,
timedOut: false,
stdout: execResult.stdout,
stderr: execResult.stderr,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
},
};
} else {
// ── Job backend (legacy / stable fallback) ──────────────────────────────
// The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
// We do NOT re-exec command/args — instead we wait for the Job to finish
// and collect its logs.
//
// params.command / params.args / params.stdin are intentionally ignored.
let status;
let timedOut = false;
try {
status = await jobOrchestrator.waitForCompletion(
clients,
namespace,
lease.providerLeaseId,
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
);
} catch (err) {
if (err instanceof JobTimeoutError) {
timedOut = true;
status = null;
} else {
throw err;
}
}
// Collect logs from the pod.
const podName =
typeof lease.metadata?.podName === "string"
? lease.metadata.podName
: await jobOrchestrator.findPod(
clients,
namespace,
lease.providerLeaseId,
);
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
if (podName) {
await jobOrchestrator.streamLogs(
clients,
namespace,
podName,
async (stream, text) => {
if (stream === "stdout") stdoutChunks.push(text);
else stderrChunks.push(text);
},
);
}
return {
exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
timedOut,
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
metadata: {
provider: "kubernetes",
backend: "job",
namespace,
jobName: lease.providerLeaseId,
podName: podName ?? null,
phase: status?.phase ?? null,
},
};
}
},
});
export default plugin;