@paperclipai/plugin-kubernetes 0.1.0 → 2026.620.0-canary.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapter-defaults.d.ts +29 -0
  3. package/dist/adapter-defaults.d.ts.map +1 -0
  4. package/dist/adapter-defaults.js +104 -0
  5. package/dist/adapter-defaults.js.map +1 -0
  6. package/dist/adapter-registry.d.ts +64 -0
  7. package/dist/adapter-registry.d.ts.map +1 -0
  8. package/dist/adapter-registry.js +25 -0
  9. package/dist/adapter-registry.js.map +1 -0
  10. package/dist/cilium-network-policy.d.ts +8 -0
  11. package/dist/cilium-network-policy.d.ts.map +1 -0
  12. package/dist/cilium-network-policy.js +57 -0
  13. package/dist/cilium-network-policy.js.map +1 -0
  14. package/dist/image-allowlist.d.ts +18 -0
  15. package/dist/image-allowlist.d.ts.map +1 -0
  16. package/dist/image-allowlist.js +39 -0
  17. package/dist/image-allowlist.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/job-orchestrator.d.ts +24 -0
  23. package/dist/job-orchestrator.d.ts.map +1 -0
  24. package/dist/job-orchestrator.js +90 -0
  25. package/dist/job-orchestrator.js.map +1 -0
  26. package/dist/kube-client.d.ts +15 -0
  27. package/dist/kube-client.d.ts.map +1 -0
  28. package/dist/kube-client.js +23 -0
  29. package/dist/kube-client.js.map +1 -0
  30. package/dist/lease-lifecycle.d.ts +62 -0
  31. package/dist/lease-lifecycle.d.ts.map +1 -0
  32. package/dist/lease-lifecycle.js +155 -0
  33. package/dist/lease-lifecycle.js.map +1 -0
  34. package/dist/manifest.d.ts +4 -0
  35. package/dist/manifest.d.ts.map +1 -0
  36. package/dist/manifest.js +108 -0
  37. package/dist/manifest.js.map +1 -0
  38. package/dist/network-policy.d.ts +18 -0
  39. package/dist/network-policy.d.ts.map +1 -0
  40. package/dist/network-policy.js +113 -0
  41. package/dist/network-policy.js.map +1 -0
  42. package/dist/plugin.d.ts +3 -0
  43. package/dist/plugin.d.ts.map +1 -0
  44. package/dist/plugin.js +687 -0
  45. package/dist/plugin.js.map +1 -0
  46. package/dist/pod-exec.d.ts +28 -0
  47. package/dist/pod-exec.d.ts.map +1 -0
  48. package/dist/pod-exec.js +164 -0
  49. package/dist/pod-exec.js.map +1 -0
  50. package/dist/pod-spec-builder.d.ts +25 -0
  51. package/dist/pod-spec-builder.d.ts.map +1 -0
  52. package/dist/pod-spec-builder.js +82 -0
  53. package/dist/pod-spec-builder.js.map +1 -0
  54. package/dist/sandbox-cr-builder.d.ts +40 -0
  55. package/dist/sandbox-cr-builder.d.ts.map +1 -0
  56. package/dist/sandbox-cr-builder.js +123 -0
  57. package/dist/sandbox-cr-builder.js.map +1 -0
  58. package/dist/sandbox-cr-orchestrator.d.ts +62 -0
  59. package/dist/sandbox-cr-orchestrator.d.ts.map +1 -0
  60. package/dist/sandbox-cr-orchestrator.js +248 -0
  61. package/dist/sandbox-cr-orchestrator.js.map +1 -0
  62. package/dist/sandbox-orchestrator.d.ts +39 -0
  63. package/dist/sandbox-orchestrator.d.ts.map +1 -0
  64. package/dist/sandbox-orchestrator.js +2 -0
  65. package/dist/sandbox-orchestrator.js.map +1 -0
  66. package/dist/secret-manager.d.ts +14 -0
  67. package/dist/secret-manager.d.ts.map +1 -0
  68. package/dist/secret-manager.js +39 -0
  69. package/dist/secret-manager.js.map +1 -0
  70. package/dist/tenant-orchestrator.d.ts +35 -0
  71. package/dist/tenant-orchestrator.d.ts.map +1 -0
  72. package/dist/tenant-orchestrator.js +263 -0
  73. package/dist/tenant-orchestrator.js.map +1 -0
  74. package/dist/types.d.ts +264 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/types.js +65 -0
  77. package/dist/types.js.map +1 -0
  78. package/dist/upload-interceptor.d.ts +44 -0
  79. package/dist/upload-interceptor.d.ts.map +1 -0
  80. package/dist/upload-interceptor.js +114 -0
  81. package/dist/upload-interceptor.js.map +1 -0
  82. package/dist/utils.d.ts +11 -0
  83. package/dist/utils.d.ts.map +1 -0
  84. package/dist/utils.js +51 -0
  85. package/dist/utils.js.map +1 -0
  86. package/dist/worker.d.ts +3 -0
  87. package/dist/worker.d.ts.map +1 -0
  88. package/dist/worker.js +5 -0
  89. package/dist/worker.js.map +1 -0
  90. package/package.json +4 -12
package/dist/plugin.js ADDED
@@ -0,0 +1,687 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { definePlugin } from "@paperclipai/plugin-sdk";
3
+ import { kubernetesProviderConfigSchema, } from "./types.js";
4
+ import { createKubeConfig, makeKubeClients } from "./kube-client.js";
5
+ import { getAdapterDefaults, buildAdapterEnv, resolveRunAdapterType } from "./adapter-defaults.js";
6
+ import { resolveImage } from "./image-allowlist.js";
7
+ import { buildJobManifest } from "./pod-spec-builder.js";
8
+ import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
9
+ import { ensureTenant } from "./tenant-orchestrator.js";
10
+ import { createPerRunSecret } from "./secret-manager.js";
11
+ import { FastUploadInterceptor } from "./upload-interceptor.js";
12
+ import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
13
+ import { sandboxCrOrchestrator, SandboxCrTimeoutError, } from "./sandbox-cr-orchestrator.js";
14
+ import { execInPod, wrapCommandWithEnv } from "./pod-exec.js";
15
+ import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
16
+ import { deriveCompanySlug, deriveNamespaceName, newRunUlidDns, paperclipLabels, } from "./utils.js";
17
+ // The namespace paperclip-server itself runs in. Used when building
18
+ // NetworkPolicy manifests so the tenant namespace allows inbound traffic
19
+ // from the server pod.
20
+ const PAPERCLIP_SERVER_NAMESPACE = "paperclip";
21
+ // Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
22
+ const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
23
+ // Resource quota defaults applied to every tenant namespace (tunable via
24
+ // config in a future iteration).
25
+ const DEFAULT_RESOURCE_QUOTA = {
26
+ pods: "20",
27
+ requestsCpu: "10",
28
+ requestsMemory: "20Gi",
29
+ limitsCpu: "20",
30
+ limitsMemory: "40Gi",
31
+ };
32
+ function deriveTenantNamespace(config, companyId) {
33
+ // TODO: future versions could thread companyName through AcquireLeaseParams
34
+ // to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
35
+ const slug = config.companySlug ?? deriveCompanySlug(companyId);
36
+ return deriveNamespaceName(config.namespacePrefix, slug);
37
+ }
38
+ function generateBootstrapToken() {
39
+ // TODO: tighten once the agent runtime shim (companion images PR) lands its
40
+ // callback auth scheme; paperclip-server's callback auth is out of scope for
41
+ // this plugin. For now this per-run random token is stored in the per-run
42
+ // Secret and read by the runtime image entrypoint for initial registration.
43
+ return randomBytes(32).toString("hex");
44
+ }
45
+ // One FastUploadInterceptor instance per active lease. Scoping per lease
46
+ // prevents `releaseLease` from wiping in-flight upload buffers belonging to
47
+ // other concurrent leases — a single shared singleton would do exactly that
48
+ // on `reset()`. The Map is keyed by `providerLeaseId`; entries are lazily
49
+ // created in `onEnvironmentExecute` and removed in `onEnvironmentReleaseLease`.
50
+ const uploadInterceptorsByLease = new Map();
51
+ function getOrCreateUploadInterceptor(leaseId) {
52
+ let interceptor = uploadInterceptorsByLease.get(leaseId);
53
+ if (!interceptor) {
54
+ interceptor = new FastUploadInterceptor();
55
+ uploadInterceptorsByLease.set(leaseId, interceptor);
56
+ }
57
+ return interceptor;
58
+ }
59
+ // In-memory cache of sandbox CR names we've already observed reaching the
60
+ // Ready condition during the current plugin-worker lifetime. The k8s
61
+ // sandbox-cr lifecycle means once a Sandbox pod is Running, subsequent
62
+ // execs into it don't need another readiness poll — saves one
63
+ // `getNamespacedCustomObject` round-trip per exec, which adds up across
64
+ // dozens of sequential exec calls in a typical adapter workflow.
65
+ // On worker restart this resets, which is fine: the first exec on each
66
+ // lease then re-confirms readiness from scratch.
67
+ const readySandboxesByLease = new Set();
68
+ // How long onEnvironmentResumeLease waits for an existing Sandbox pod to
69
+ // report Ready before declaring the lease non-resumable. Deliberately short:
70
+ // this is a liveness check on an already-provisioned pod, not a fresh
71
+ // provision — if the pod isn't (almost) up, falling back to acquireLease is
72
+ // faster and more reliable than waiting.
73
+ const RESUME_READY_TIMEOUT_MS = 30_000;
74
+ const RESUME_READY_POLL_MS = 1_000;
75
+ const plugin = definePlugin({
76
+ async setup(ctx) {
77
+ ctx.logger.info("Kubernetes sandbox provider plugin ready");
78
+ },
79
+ async onHealth() {
80
+ return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
81
+ },
82
+ async onEnvironmentValidateConfig(params) {
83
+ const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
84
+ if (!parsed.success) {
85
+ return {
86
+ ok: false,
87
+ errors: parsed.error.issues.map((i) => i.message),
88
+ };
89
+ }
90
+ const warnings = [];
91
+ const cfg = parsed.data;
92
+ const adapterDefaults = getAdapterDefaults(cfg.adapterType, cfg.adapters);
93
+ const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
94
+ if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
95
+ if (cfg.egressAllowCidrs.length === 0) {
96
+ 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.`);
97
+ }
98
+ else {
99
+ 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.`);
100
+ }
101
+ }
102
+ return { ok: true, normalizedConfig: cfg, warnings: warnings.length > 0 ? warnings : undefined };
103
+ },
104
+ async onEnvironmentProbe(params) {
105
+ const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
106
+ if (!parsed.success) {
107
+ return {
108
+ ok: false,
109
+ summary: "Invalid Kubernetes provider configuration.",
110
+ metadata: {
111
+ errors: parsed.error.issues.map((i) => i.message),
112
+ },
113
+ };
114
+ }
115
+ const config = parsed.data;
116
+ const namespace = deriveTenantNamespace(config, params.companyId);
117
+ try {
118
+ const kc = createKubeConfig({
119
+ inCluster: config.inCluster,
120
+ kubeconfig: config.kubeconfig,
121
+ });
122
+ const clients = makeKubeClients(kc);
123
+ // Reachability check: list pods in the tenant namespace. If the namespace
124
+ // doesn't exist yet this will throw a 404 which we treat as "reachable
125
+ // but namespace not provisioned" — still a successful probe.
126
+ try {
127
+ await clients.core.listNamespacedPod({ namespace });
128
+ }
129
+ catch (err) {
130
+ const code = err.code
131
+ ?? err.statusCode;
132
+ if (code !== 404)
133
+ throw err;
134
+ // 404 means namespace doesn't exist yet — cluster is reachable.
135
+ }
136
+ return {
137
+ ok: true,
138
+ summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
139
+ metadata: { namespace, provider: "kubernetes" },
140
+ };
141
+ }
142
+ catch (err) {
143
+ return {
144
+ ok: false,
145
+ summary: "Kubernetes cluster probe failed.",
146
+ metadata: {
147
+ namespace,
148
+ provider: "kubernetes",
149
+ error: err instanceof Error ? err.message : String(err),
150
+ },
151
+ };
152
+ }
153
+ },
154
+ async onEnvironmentAcquireLease(
155
+ // `adapterType` is an optional per-run hint the server may pass once the
156
+ // SDK lease params grow that field (companion server-integration PR). The
157
+ // plugin works without it: absent means "use the environment's configured
158
+ // default adapter", so it stays compatible with the current SDK.
159
+ params) {
160
+ const config = kubernetesProviderConfigSchema.parse(params.config);
161
+ const namespace = deriveTenantNamespace(config, params.companyId);
162
+ // The adapter for THIS run is the agent's adapter (params.adapterType) when
163
+ // supplied, so one environment can serve mixed harnesses; otherwise fall back
164
+ // to the environment's configured default adapter. getAdapterDefaults validates
165
+ // it is a registered adapter (throws otherwise), so a curated-out adapter fails
166
+ // the lease as before.
167
+ const effectiveAdapterType = resolveRunAdapterType(params.adapterType, config.adapterType);
168
+ // Emit a runtime warning if FQDNs are configured but egressMode=standard
169
+ // cannot enforce them. Mirrors the validateConfig warning so operators see
170
+ // it in paperclip-server logs even if they missed the validation step.
171
+ const adapterDefaultsForWarn = getAdapterDefaults(effectiveAdapterType, config.adapters);
172
+ const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
173
+ if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
174
+ if (config.egressAllowCidrs.length === 0) {
175
+ 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.`);
176
+ }
177
+ else {
178
+ 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.`);
179
+ }
180
+ }
181
+ const kc = createKubeConfig({
182
+ inCluster: config.inCluster,
183
+ kubeconfig: config.kubeconfig,
184
+ });
185
+ const clients = makeKubeClients(kc);
186
+ // Ensure the tenant namespace and all its RBAC / network policy resources
187
+ // exist before we try to create the Job.
188
+ const adapterDefaults = getAdapterDefaults(effectiveAdapterType, config.adapters);
189
+ await ensureTenant(clients, {
190
+ namespace,
191
+ companyId: params.companyId,
192
+ paperclipServerNamespace: PAPERCLIP_SERVER_NAMESPACE,
193
+ serviceAccountAnnotations: config.serviceAccountAnnotations,
194
+ egressMode: config.egressMode,
195
+ egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
196
+ egressAllowCidrs: config.egressAllowCidrs,
197
+ resourceQuota: DEFAULT_RESOURCE_QUOTA,
198
+ });
199
+ const jobName = `pc-${newRunUlidDns()}`;
200
+ const secretName = `${jobName}-env`;
201
+ // TODO: use params.runId as stand-in for agentId in labels; future
202
+ // versions will have a dedicated agentId on AcquireLeaseParams.
203
+ const labels = paperclipLabels({
204
+ runId: params.runId,
205
+ agentId: params.runId,
206
+ companyId: params.companyId,
207
+ adapterType: effectiveAdapterType,
208
+ });
209
+ const image = resolveImage({ imageOverride: null }, adapterDefaults, { imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry });
210
+ // Pick the orchestrator and build the appropriate manifest based on backend.
211
+ const isSandboxCrBackend = config.backend === "sandbox-cr";
212
+ const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
213
+ const manifest = isSandboxCrBackend
214
+ ? buildSandboxCrManifest({
215
+ namespace,
216
+ sandboxName: jobName,
217
+ adapterType: effectiveAdapterType,
218
+ image,
219
+ envSecretName: secretName,
220
+ serviceAccountName: TENANT_SERVICE_ACCOUNT,
221
+ labels,
222
+ resources: config.defaultResources ?? {},
223
+ runtimeClassName: config.runtimeClassName,
224
+ imagePullSecrets: config.imagePullSecrets,
225
+ })
226
+ : buildJobManifest({
227
+ namespace,
228
+ jobName,
229
+ adapterType: effectiveAdapterType,
230
+ image,
231
+ envSecretName: secretName,
232
+ serviceAccountName: TENANT_SERVICE_ACCOUNT,
233
+ labels,
234
+ resources: config.defaultResources ?? {},
235
+ runtimeClassName: config.runtimeClassName,
236
+ activeDeadlineSec: config.podActivityDeadlineSec,
237
+ ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
238
+ imagePullSecrets: config.imagePullSecrets,
239
+ });
240
+ const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
241
+ // defaultEnv (non-secret base, e.g. the inference base URL) is layered first;
242
+ // the process-env secrets named by envKeys override it.
243
+ const adapterEnv = buildAdapterEnv(adapterDefaults);
244
+ const bootstrapToken = generateBootstrapToken();
245
+ // Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
246
+ // For sandbox-cr backend, the Sandbox CR owns the Secret.
247
+ // NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
248
+ // quirk, the release() call will still clean it up via namespace GC or
249
+ // explicit delete in a future iteration.
250
+ await createPerRunSecret(clients, {
251
+ namespace,
252
+ secretName,
253
+ runId: params.runId,
254
+ ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
255
+ ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
256
+ ownerName: jobName,
257
+ ownerUid,
258
+ bootstrapToken,
259
+ adapterEnv,
260
+ });
261
+ const podName = await orchestrator.findPod(clients, namespace, jobName);
262
+ const leaseMetadata = {
263
+ namespace,
264
+ jobName,
265
+ podName,
266
+ secretName,
267
+ phase: "Pending",
268
+ backend: config.backend,
269
+ };
270
+ return {
271
+ providerLeaseId: jobName,
272
+ metadata: leaseMetadata,
273
+ };
274
+ },
275
+ async onEnvironmentResumeLease(params) {
276
+ const config = kubernetesProviderConfigSchema.parse(params.config);
277
+ const namespace = typeof params.leaseMetadata?.namespace === "string"
278
+ ? params.leaseMetadata.namespace
279
+ : deriveTenantNamespace(config, params.companyId);
280
+ const leaseBackend = typeof params.leaseMetadata?.backend === "string"
281
+ ? params.leaseMetadata.backend
282
+ : config.backend;
283
+ // acquireLease names the per-run Secret `${jobName}-env` and uses jobName
284
+ // as the providerLeaseId, so the suffix fallback reconstructs it exactly.
285
+ const secretName = typeof params.leaseMetadata?.secretName === "string"
286
+ ? params.leaseMetadata.secretName
287
+ : `${params.providerLeaseId}-env`;
288
+ const kc = createKubeConfig({
289
+ inCluster: config.inCluster,
290
+ kubeconfig: config.kubeconfig,
291
+ });
292
+ const clients = makeKubeClients(kc);
293
+ const check = await checkLeaseResumable(clients, {
294
+ namespace,
295
+ name: params.providerLeaseId,
296
+ backend: leaseBackend,
297
+ readyTimeoutMs: RESUME_READY_TIMEOUT_MS,
298
+ pollMs: RESUME_READY_POLL_MS,
299
+ });
300
+ if (!check.resumable) {
301
+ // Kubernetes pods are NOT restartable the way Daytona sandboxes are: a
302
+ // stopped Daytona sandbox can be started again by ID, but a k8s pod that
303
+ // is gone or terminally failed can never be revived in place. Gone = not
304
+ // resumable, by design. Returning providerLeaseId: null tells the server
305
+ // the lease expired so it falls back to a fresh acquireLease.
306
+ return {
307
+ providerLeaseId: null,
308
+ metadata: { expired: true, reason: check.reason },
309
+ };
310
+ }
311
+ // A resumed lease starts with clean per-lease state: drop any stale upload
312
+ // interceptor buffers a previous run on this lease may have left behind.
313
+ uploadInterceptorsByLease.delete(params.providerLeaseId);
314
+ if (leaseBackend === "sandbox-cr") {
315
+ // We just observed the Sandbox pod Ready, so the first exec on the
316
+ // resumed lease can skip its readiness poll.
317
+ readySandboxesByLease.add(params.providerLeaseId);
318
+ }
319
+ const leaseMetadata = {
320
+ namespace,
321
+ jobName: params.providerLeaseId,
322
+ podName: check.podName,
323
+ secretName,
324
+ phase: check.phase,
325
+ backend: leaseBackend,
326
+ };
327
+ return {
328
+ providerLeaseId: params.providerLeaseId,
329
+ metadata: {
330
+ ...leaseMetadata,
331
+ resumedLease: true,
332
+ },
333
+ };
334
+ },
335
+ async onEnvironmentRealizeWorkspace(params) {
336
+ // The agent pod already has /workspace mounted as an emptyDir at pod
337
+ // scheduling time (see pod-spec-builder). Nothing to provision here —
338
+ // we just hand back the cwd. Honor a caller-supplied remotePath if set.
339
+ const cwd = params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
340
+ ? params.workspace.remotePath.trim()
341
+ : "/workspace";
342
+ return {
343
+ cwd,
344
+ metadata: {
345
+ provider: "kubernetes",
346
+ remoteCwd: cwd,
347
+ },
348
+ };
349
+ },
350
+ async onEnvironmentReleaseLease(params) {
351
+ if (!params.providerLeaseId)
352
+ return;
353
+ const config = kubernetesProviderConfigSchema.parse(params.config);
354
+ const namespace = typeof params.leaseMetadata?.namespace === "string"
355
+ ? params.leaseMetadata.namespace
356
+ : deriveTenantNamespace(config, params.companyId);
357
+ const kc = createKubeConfig({
358
+ inCluster: config.inCluster,
359
+ kubeconfig: config.kubeconfig,
360
+ });
361
+ const clients = makeKubeClients(kc);
362
+ const leaseBackend = typeof params.leaseMetadata?.backend === "string"
363
+ ? params.leaseMetadata.backend
364
+ : config.backend;
365
+ const releaseOrchestrator = leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
366
+ // Drop the FastUploadInterceptor associated with THIS lease (only).
367
+ // Each lease has its own interceptor instance via uploadInterceptorsByLease,
368
+ // so unrelated concurrent leases keep their in-flight buffers intact.
369
+ uploadInterceptorsByLease.delete(params.providerLeaseId);
370
+ readySandboxesByLease.delete(params.providerLeaseId);
371
+ try {
372
+ await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
373
+ }
374
+ catch (err) {
375
+ // If the resource is already gone (404), that's fine.
376
+ const code = err.code
377
+ ?? err.statusCode;
378
+ if (code !== 404)
379
+ throw err;
380
+ }
381
+ },
382
+ async onEnvironmentDestroyLease(params) {
383
+ if (!params.providerLeaseId)
384
+ return;
385
+ const config = kubernetesProviderConfigSchema.parse(params.config);
386
+ const namespace = typeof params.leaseMetadata?.namespace === "string"
387
+ ? params.leaseMetadata.namespace
388
+ : deriveTenantNamespace(config, params.companyId);
389
+ const leaseBackend = typeof params.leaseMetadata?.backend === "string"
390
+ ? params.leaseMetadata.backend
391
+ : config.backend;
392
+ const secretName = typeof params.leaseMetadata?.secretName === "string"
393
+ ? params.leaseMetadata.secretName
394
+ : `${params.providerLeaseId}-env`;
395
+ const podName = typeof params.leaseMetadata?.podName === "string" &&
396
+ params.leaseMetadata.podName.length > 0
397
+ ? params.leaseMetadata.podName
398
+ : null;
399
+ // Clear per-lease in-memory state up front, regardless of what the
400
+ // cluster says — the lease is dead either way.
401
+ uploadInterceptorsByLease.delete(params.providerLeaseId);
402
+ readySandboxesByLease.delete(params.providerLeaseId);
403
+ const kc = createKubeConfig({
404
+ inCluster: config.inCluster,
405
+ kubeconfig: config.kubeconfig,
406
+ });
407
+ const clients = makeKubeClients(kc);
408
+ // Forcibly delete everything acquireLease created (Sandbox CR / Job, pod,
409
+ // per-run Secret). 404s are success — destroy must be idempotent.
410
+ await destroyLeaseResources(clients, {
411
+ namespace,
412
+ name: params.providerLeaseId,
413
+ backend: leaseBackend,
414
+ podName,
415
+ secretName,
416
+ });
417
+ },
418
+ async onEnvironmentExecute(params) {
419
+ const { lease, timeoutMs } = params;
420
+ if (!lease.providerLeaseId) {
421
+ return {
422
+ exitCode: 1,
423
+ timedOut: false,
424
+ stdout: "",
425
+ stderr: "No provider lease ID available for execution.",
426
+ };
427
+ }
428
+ const config = kubernetesProviderConfigSchema.parse(params.config);
429
+ const namespace = typeof lease.metadata?.namespace === "string"
430
+ ? lease.metadata.namespace
431
+ : deriveTenantNamespace(config, params.companyId);
432
+ // Determine which backend this lease was created with.
433
+ const leaseBackend = typeof lease.metadata?.backend === "string"
434
+ ? lease.metadata.backend
435
+ : config.backend;
436
+ const kc = createKubeConfig({
437
+ inCluster: config.inCluster,
438
+ kubeconfig: config.kubeconfig,
439
+ });
440
+ const clients = makeKubeClients(kc);
441
+ const effectiveTimeoutMs = typeof timeoutMs === "number" && timeoutMs > 0
442
+ ? timeoutMs
443
+ : config.podActivityDeadlineSec * 1000;
444
+ if (leaseBackend === "sandbox-cr") {
445
+ // ── Sandbox-CR backend ──────────────────────────────────────────────────
446
+ // 1. Ensure the Sandbox pod is Ready (wait only on first exec for this lease).
447
+ // 2. Exec the command into the running pod.
448
+ // 3. Return exec result directly (no log scraping needed).
449
+ let podName = typeof lease.metadata?.podName === "string" && lease.metadata.podName
450
+ ? lease.metadata.podName
451
+ : null;
452
+ // Skip the readiness poll if we've already observed this Sandbox CR
453
+ // reaching Ready during this worker's lifetime. See readySandboxesByLease
454
+ // declaration for rationale.
455
+ const podAlreadyKnownReady = readySandboxesByLease.has(lease.providerLeaseId);
456
+ // The caller's timeout is a budget for the WHOLE execute call: readiness
457
+ // wait + exec must share it, or the first exec on a fresh lease could
458
+ // block for up to twice the requested timeout.
459
+ const executeStartedAt = Date.now();
460
+ if (!podAlreadyKnownReady) {
461
+ try {
462
+ await sandboxCrOrchestrator.waitForCompletion(clients, namespace, lease.providerLeaseId, { timeoutMs: effectiveTimeoutMs, pollMs: 2000 });
463
+ readySandboxesByLease.add(lease.providerLeaseId);
464
+ }
465
+ catch (err) {
466
+ if (err instanceof SandboxCrTimeoutError) {
467
+ return {
468
+ exitCode: null,
469
+ timedOut: true,
470
+ stdout: "",
471
+ stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
472
+ metadata: {
473
+ provider: "kubernetes",
474
+ backend: "sandbox-cr",
475
+ namespace,
476
+ sandboxName: lease.providerLeaseId,
477
+ },
478
+ };
479
+ }
480
+ throw err;
481
+ }
482
+ }
483
+ // Resolve pod name (may now be populated in Sandbox status).
484
+ if (!podName) {
485
+ podName = await sandboxCrOrchestrator.findPod(clients, namespace, lease.providerLeaseId);
486
+ }
487
+ if (!podName) {
488
+ return {
489
+ exitCode: 1,
490
+ timedOut: false,
491
+ stdout: "",
492
+ stderr: "Sandbox pod is Ready but podName could not be resolved.",
493
+ metadata: {
494
+ provider: "kubernetes",
495
+ backend: "sandbox-cr",
496
+ namespace,
497
+ sandboxName: lease.providerLeaseId,
498
+ },
499
+ };
500
+ }
501
+ // Build the command to exec. The adapter passes shell invocations as
502
+ // `command: "sh", args: ["-c", "<script>"]` — must combine both, NOT
503
+ // drop args. If only command is present (no args), wrap in a login shell.
504
+ const command = typeof params.command === "string" ? params.command.trim() : "";
505
+ const args = Array.isArray(params.args) ? params.args : [];
506
+ // Fast-upload interceptor: short-circuit the chunked-shell file transfer
507
+ // protocol (adapter-utils writeFile) so an N-chunk upload becomes 1 exec
508
+ // instead of N+2. Falls back transparently when patterns don't match.
509
+ // See upload-interceptor.ts.
510
+ const shellScript = command === "sh" && args[0] === "-c" && typeof args[1] === "string"
511
+ ? args[1]
512
+ : null;
513
+ if (shellScript) {
514
+ const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
515
+ if (decision.action === "ack") {
516
+ return {
517
+ exitCode: 0,
518
+ timedOut: false,
519
+ stdout: "",
520
+ stderr: "",
521
+ metadata: {
522
+ provider: "kubernetes",
523
+ backend: "sandbox-cr",
524
+ namespace,
525
+ sandboxName: lease.providerLeaseId,
526
+ podName,
527
+ fastUpload: "ack",
528
+ },
529
+ };
530
+ }
531
+ if (decision.action === "flush") {
532
+ // Single exec: `head -c <N> | base64 -d > '<TARGET>'` with stdin =
533
+ // base64 ASCII. `head -c` reads EXACTLY N bytes and exits, so we
534
+ // don't depend on WebSocket-driven EOF detection on stdin (which is
535
+ // racy against the `base64 -d` exit timing in @kubernetes/client-node
536
+ // v0.21.0 — see pod-exec.ts). All bytes are sent through the
537
+ // WebSocket data channel; size is unbounded by ARG_MAX.
538
+ const base64Body = decision.flush.payload.toString("base64");
539
+ const dir = decision.flush.targetPath.substring(0, decision.flush.targetPath.lastIndexOf("/"));
540
+ const script = `mkdir -p '${dir}' && ` +
541
+ `head -c ${base64Body.length} | base64 -d > '${decision.flush.targetPath}'`;
542
+ // The flush shares the caller's single execute budget (same contract
543
+ // as the normal exec path below) and surfaces watchdog/WebSocket
544
+ // failures as a timed-out result instead of an uncaught throw.
545
+ const flushTimeoutMs = Math.max(5_000, effectiveTimeoutMs - (Date.now() - executeStartedAt));
546
+ let flushResult;
547
+ try {
548
+ flushResult = await execInPod(kc, namespace, podName, "agent", ["/bin/sh", "-c", script], base64Body, flushTimeoutMs);
549
+ }
550
+ catch (err) {
551
+ return {
552
+ exitCode: null,
553
+ timedOut: true,
554
+ stdout: "",
555
+ stderr: `fast-upload flush failed: ${err instanceof Error ? err.message : String(err)}`,
556
+ metadata: {
557
+ provider: "kubernetes",
558
+ backend: "sandbox-cr",
559
+ namespace,
560
+ sandboxName: lease.providerLeaseId,
561
+ podName,
562
+ fastUpload: "flush",
563
+ },
564
+ };
565
+ }
566
+ return {
567
+ exitCode: flushResult.exitCode,
568
+ timedOut: false,
569
+ stdout: flushResult.stdout,
570
+ stderr: flushResult.stderr,
571
+ metadata: {
572
+ provider: "kubernetes",
573
+ backend: "sandbox-cr",
574
+ namespace,
575
+ sandboxName: lease.providerLeaseId,
576
+ podName,
577
+ fastUpload: "flush",
578
+ uploadedBytes: decision.flush.payload.length,
579
+ },
580
+ };
581
+ }
582
+ // decision.action === "passthrough" — fall through to normal exec
583
+ }
584
+ const baseExecCommand = command.length > 0 && args.length > 0
585
+ ? [command, ...args]
586
+ : command.length > 0
587
+ ? ["/bin/sh", "-lc", command]
588
+ : ["/bin/sh", "-l"];
589
+ // Apply the caller-provided run env (params.env) to the in-pod process. Without
590
+ // this the adapter's runtime env (e.g. XDG_CONFIG_HOME pointing at the shipped
591
+ // OpenCode config, plus helper settings like small_model/provider routing) never
592
+ // reaches the harness, which falls back to its in-image HOME config -> wrong or
593
+ // partial behaviour.
594
+ const execCommand = wrapCommandWithEnv(baseExecCommand, params.env);
595
+ // Remaining share of the caller's budget after the readiness wait (floor
596
+ // of 5s so an exec attempt is still made when readiness consumed most of
597
+ // it; the watchdog then bounds it tightly).
598
+ const remainingTimeoutMs = Math.max(5_000, effectiveTimeoutMs - (Date.now() - executeStartedAt));
599
+ let execResult;
600
+ try {
601
+ execResult = await execInPod(kc, namespace, podName, "agent", execCommand, typeof params.stdin === "string" ? params.stdin : undefined, remainingTimeoutMs);
602
+ }
603
+ catch (err) {
604
+ // Watchdog-fired or WebSocket-setup error. Surface as a timeout so
605
+ // the caller can retry instead of hanging forever.
606
+ return {
607
+ exitCode: null,
608
+ timedOut: true,
609
+ stdout: "",
610
+ stderr: err instanceof Error ? err.message : String(err),
611
+ metadata: {
612
+ provider: "kubernetes",
613
+ backend: "sandbox-cr",
614
+ namespace,
615
+ sandboxName: lease.providerLeaseId,
616
+ podName,
617
+ },
618
+ };
619
+ }
620
+ return {
621
+ exitCode: execResult.exitCode,
622
+ timedOut: false,
623
+ stdout: execResult.stdout,
624
+ stderr: execResult.stderr,
625
+ metadata: {
626
+ provider: "kubernetes",
627
+ backend: "sandbox-cr",
628
+ namespace,
629
+ sandboxName: lease.providerLeaseId,
630
+ podName,
631
+ },
632
+ };
633
+ }
634
+ else {
635
+ // ── Job backend (legacy / stable fallback) ──────────────────────────────
636
+ // The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
637
+ // We do NOT re-exec command/args — instead we wait for the Job to finish
638
+ // and collect its logs.
639
+ //
640
+ // params.command / params.args / params.stdin are intentionally ignored.
641
+ let status;
642
+ let timedOut = false;
643
+ try {
644
+ status = await jobOrchestrator.waitForCompletion(clients, namespace, lease.providerLeaseId, { timeoutMs: effectiveTimeoutMs, pollMs: 2000 });
645
+ }
646
+ catch (err) {
647
+ if (err instanceof JobTimeoutError) {
648
+ timedOut = true;
649
+ status = null;
650
+ }
651
+ else {
652
+ throw err;
653
+ }
654
+ }
655
+ // Collect logs from the pod.
656
+ const podName = typeof lease.metadata?.podName === "string"
657
+ ? lease.metadata.podName
658
+ : await jobOrchestrator.findPod(clients, namespace, lease.providerLeaseId);
659
+ const stdoutChunks = [];
660
+ const stderrChunks = [];
661
+ if (podName) {
662
+ await jobOrchestrator.streamLogs(clients, namespace, podName, async (stream, text) => {
663
+ if (stream === "stdout")
664
+ stdoutChunks.push(text);
665
+ else
666
+ stderrChunks.push(text);
667
+ });
668
+ }
669
+ return {
670
+ exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
671
+ timedOut,
672
+ stdout: stdoutChunks.join(""),
673
+ stderr: stderrChunks.join(""),
674
+ metadata: {
675
+ provider: "kubernetes",
676
+ backend: "job",
677
+ namespace,
678
+ jobName: lease.providerLeaseId,
679
+ podName: podName ?? null,
680
+ phase: status?.phase ?? null,
681
+ },
682
+ };
683
+ }
684
+ },
685
+ });
686
+ export default plugin;
687
+ //# sourceMappingURL=plugin.js.map