forked from farhoodlabs/paperclip
feat(plugin): add kubernetes fast upload interceptor
This commit is contained in:
@@ -104,6 +104,12 @@ Pod pc-{ulid}-{podSuffix} (managed by Sandbox controlle
|
|||||||
Secret pc-{ulid}-env (owned by Sandbox CR; cascade-deleted)
|
Secret pc-{ulid}-env (owned by Sandbox CR; cascade-deleted)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fast workspace uploads
|
||||||
|
|
||||||
|
The `sandbox-cr` backend recognizes the chunked base64 upload protocol emitted by `@paperclipai/adapter-utils` for workspace, skill, and config-seed file transfers. Instead of running one Kubernetes exec per base64 chunk, the plugin buffers the upload in worker memory and flushes the final payload through a single `head -c <bytes> | base64 -d` exec with stdin.
|
||||||
|
|
||||||
|
The interceptor is intentionally narrow: only the exact `mkdir`/`printf`/`base64 -d` command shape generated by adapter-utils is optimized. Unknown commands, missing init state, or uploads over the 100 MB buffer cap fall back to normal exec behavior.
|
||||||
|
|
||||||
For each agent run (job backend):
|
For each agent run (job backend):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ The plugin's `onEnvironmentAcquireLease` will:
|
|||||||
|
|
||||||
1. `ensureTenant` — provision the `paperclip-smoke` namespace, SA, Role,
|
1. `ensureTenant` — provision the `paperclip-smoke` namespace, SA, Role,
|
||||||
RoleBinding, ResourceQuota, LimitRange, NetworkPolicies
|
RoleBinding, ResourceQuota, LimitRange, NetworkPolicies
|
||||||
2. `buildJobManifest` — render the security-hardened Job manifest
|
2. `buildSandboxCrManifest` — render the security-hardened Sandbox CR manifest
|
||||||
3. `createJob` — submit to `batch/v1`
|
3. `createNamespacedCustomObject` — submit to `agents.x-k8s.io/v1alpha1`
|
||||||
4. `createPerRunSecret` — owned by the Job for cascade-delete
|
4. `createPerRunSecret` — owned by the Sandbox CR for cascade-delete
|
||||||
|
5. Fast-upload workspace/config/skill payloads by collapsing adapter-utils chunked uploads into a single stdin-backed exec per file
|
||||||
|
|
||||||
### 7. Verify the tenant resources
|
### 7. Verify the tenant resources
|
||||||
|
|
||||||
@@ -113,8 +114,9 @@ Expected:
|
|||||||
- Role `paperclip-tenant-role`, RoleBinding `paperclip-tenant-rb`
|
- Role `paperclip-tenant-role`, RoleBinding `paperclip-tenant-rb`
|
||||||
- ResourceQuota `paperclip-quota`, LimitRange `paperclip-limits`
|
- ResourceQuota `paperclip-quota`, LimitRange `paperclip-limits`
|
||||||
- NetworkPolicies `paperclip-deny-all` + `paperclip-egress-allow`
|
- NetworkPolicies `paperclip-deny-all` + `paperclip-egress-allow`
|
||||||
- Job `pc-{ulid}` and its child Pod
|
- Sandbox `pc-{ulid}` and its managed Pod
|
||||||
- Secret `pc-{ulid}-env` with `ownerReferences` pointing at the Job
|
- Secret `pc-{ulid}-env` with `ownerReferences` pointing at the Sandbox CR
|
||||||
|
- Run logs or plugin metadata include `fastUpload: "flush"` entries during workspace/config/skill upload
|
||||||
|
|
||||||
### 8. Tear down
|
### 8. Tear down
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { buildJobManifest } from "./pod-spec-builder.js";
|
|||||||
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
|
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
|
||||||
import { ensureTenant } from "./tenant-orchestrator.js";
|
import { ensureTenant } from "./tenant-orchestrator.js";
|
||||||
import { createPerRunSecret } from "./secret-manager.js";
|
import { createPerRunSecret } from "./secret-manager.js";
|
||||||
|
import { FastUploadInterceptor } from "./upload-interceptor.js";
|
||||||
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
|
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
|
||||||
import {
|
import {
|
||||||
sandboxCrOrchestrator,
|
sandboxCrOrchestrator,
|
||||||
@@ -51,6 +52,29 @@ const DEFAULT_RESOURCE_QUOTA = {
|
|||||||
limitsMemory: "40Gi",
|
limitsMemory: "40Gi",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractShellScript(
|
||||||
|
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||||
|
): string | null {
|
||||||
|
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||||
|
const args = Array.isArray(params.args) ? params.args : [];
|
||||||
|
const isShell = command === "sh" || command === "bash" || command.endsWith("/sh") || command.endsWith("/bash");
|
||||||
|
if (isShell && args[0] === "-c" && typeof args[1] === "string") {
|
||||||
|
return args[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
|
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
|
||||||
// TODO: future versions could thread companyName through AcquireLeaseParams
|
// TODO: future versions could thread companyName through AcquireLeaseParams
|
||||||
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
|
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
|
||||||
@@ -93,6 +117,24 @@ function shellQuoteArg(arg: string): string {
|
|||||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSandboxExecCommand(
|
||||||
|
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||||
|
): string[] {
|
||||||
|
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||||
|
const args = Array.isArray(params.args) ? params.args : [];
|
||||||
|
|
||||||
|
if (command.length > 0 && args.length > 0) {
|
||||||
|
return [command, ...args];
|
||||||
|
}
|
||||||
|
if (command.length > 0) {
|
||||||
|
return ["/bin/sh", "-lc", command];
|
||||||
|
}
|
||||||
|
if (args.length > 0) {
|
||||||
|
return ["/bin/sh", "-lc", args.map(shellQuoteArg).join(" ")];
|
||||||
|
}
|
||||||
|
return ["/bin/sh", "-l"];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSandboxExecShellCommand(
|
export function buildSandboxExecShellCommand(
|
||||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||||
): string {
|
): string {
|
||||||
@@ -379,6 +421,8 @@ const plugin = definePlugin({
|
|||||||
const releaseOrchestrator =
|
const releaseOrchestrator =
|
||||||
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
|
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
|
||||||
|
|
||||||
|
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
|
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -488,15 +532,62 @@ const plugin = definePlugin({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the command to exec. If params.command is provided use it;
|
|
||||||
// otherwise wrap in a login shell so profile scripts run.
|
|
||||||
const rawCommand = buildSandboxExecShellCommand(params);
|
|
||||||
|
|
||||||
const execCommand = rawCommand.length > 0
|
|
||||||
? ["/bin/sh", "-lc", rawCommand]
|
|
||||||
: ["/bin/sh", "-l"];
|
|
||||||
|
|
||||||
const remainingTimeoutMs = Math.max(1, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
const remainingTimeoutMs = Math.max(1, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
||||||
|
|
||||||
|
const shellScript = extractShellScript(params);
|
||||||
|
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") {
|
||||||
|
const base64Body = decision.flush.payload.toString("base64");
|
||||||
|
const slashIndex = decision.flush.targetPath.lastIndexOf("/");
|
||||||
|
const dir = slashIndex > 0 ? decision.flush.targetPath.slice(0, slashIndex) : ".";
|
||||||
|
const script =
|
||||||
|
`mkdir -p ${shellQuoteArg(dir)} && ` +
|
||||||
|
`head -c ${base64Body.length} | base64 -d > ${shellQuoteArg(decision.flush.targetPath)}`;
|
||||||
|
const flushResult = await execInPod(
|
||||||
|
kc,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
"agent",
|
||||||
|
["/bin/sh", "-c", script],
|
||||||
|
base64Body,
|
||||||
|
remainingTimeoutMs,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
exitCode: flushResult.exitCode,
|
||||||
|
timedOut: flushResult.timedOut,
|
||||||
|
stdout: flushResult.stdout,
|
||||||
|
stderr: flushResult.stderr,
|
||||||
|
metadata: {
|
||||||
|
provider: "kubernetes",
|
||||||
|
backend: "sandbox-cr",
|
||||||
|
namespace,
|
||||||
|
sandboxName: lease.providerLeaseId,
|
||||||
|
podName,
|
||||||
|
fastUpload: "flush",
|
||||||
|
uploadedBytes: decision.flush.payload.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const execCommand = buildSandboxExecCommand(params);
|
||||||
const execResult = await execInPod(
|
const execResult = await execInPod(
|
||||||
kc,
|
kc,
|
||||||
namespace,
|
namespace,
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { Exec } from "@kubernetes/client-node";
|
|||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import type { KubeConfig } from "@kubernetes/client-node";
|
import type { KubeConfig } from "@kubernetes/client-node";
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
close(): void;
|
||||||
|
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||||
|
on(event: "error", listener: (err: Error) => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExecInPodResult {
|
export interface ExecInPodResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
timedOut: boolean;
|
timedOut: boolean;
|
||||||
@@ -21,24 +27,31 @@ export interface ExecInPodResult {
|
|||||||
stderr: string;
|
stderr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shQuoteArg(arg: string): string {
|
||||||
|
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
export async function execInPod(
|
export async function execInPod(
|
||||||
kc: KubeConfig,
|
kc: KubeConfig,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
podName: string,
|
podName: string,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
command: string[],
|
command: string[],
|
||||||
stdin?: string,
|
stdin?: string | Buffer,
|
||||||
timeoutMs?: number,
|
timeoutMs?: number,
|
||||||
): Promise<ExecInPodResult> {
|
): Promise<ExecInPodResult> {
|
||||||
const exec = new Exec(kc);
|
const exec = new Exec(kc);
|
||||||
const stdoutStream = new PassThrough();
|
const stdoutStream = new PassThrough();
|
||||||
const stderrStream = new PassThrough();
|
const stderrStream = new PassThrough();
|
||||||
|
|
||||||
// If stdin is provided build a readable stream from it; the Exec API accepts
|
const stdinPayload: Buffer | null =
|
||||||
// a Readable | null for stdin.
|
Buffer.isBuffer(stdin) ? stdin
|
||||||
const stdinStream: import("node:stream").Readable | null = stdin
|
: typeof stdin === "string" && stdin.length > 0 ? Buffer.from(stdin, "utf-8")
|
||||||
? PassThrough.from(stdin)
|
|
||||||
: null;
|
: null;
|
||||||
|
const stdinStream: PassThrough | null = stdinPayload ? new PassThrough() : null;
|
||||||
|
const effectiveCommand = stdinPayload
|
||||||
|
? ["/bin/sh", "-c", `head -c ${stdinPayload.length} | ${command.map(shQuoteArg).join(" ")}`]
|
||||||
|
: command;
|
||||||
|
|
||||||
let stdoutData = "";
|
let stdoutData = "";
|
||||||
let stderrData = "";
|
let stderrData = "";
|
||||||
@@ -52,17 +65,27 @@ export async function execInPod(
|
|||||||
|
|
||||||
return await new Promise<ExecInPodResult>(
|
return await new Promise<ExecInPodResult>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
|
let ws: WebSocketLike | null = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let pendingResult: Omit<ExecInPodResult, "stdout" | "stderr"> | null = null;
|
||||||
|
let stdoutEnded = false;
|
||||||
|
let stderrEnded = false;
|
||||||
const timeout =
|
const timeout =
|
||||||
typeof timeoutMs === "number" && timeoutMs > 0
|
typeof timeoutMs === "number" && timeoutMs > 0
|
||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
finishWithTransportFailure(`Kubernetes exec timed out after ${timeoutMs}ms`, true);
|
finishWithTransportFailure(`Kubernetes exec timed out after ${timeoutMs}ms`, true);
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const finish = (result: ExecInPodResult) => {
|
const finish = (result: ExecInPodResult) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
ws?.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore best-effort close failures.
|
||||||
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
const finishWithTransportFailure = (message: string, timedOut = false) => {
|
const finishWithTransportFailure = (message: string, timedOut = false) => {
|
||||||
@@ -74,13 +97,30 @@ export async function execInPod(
|
|||||||
stderr: `${stderrData}${separator}${message}`,
|
stderr: `${stderrData}${separator}${message}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const tryFinish = () => {
|
||||||
|
if (settled || !pendingResult || !stdoutEnded || !stderrEnded) return;
|
||||||
|
finish({
|
||||||
|
...pendingResult,
|
||||||
|
stdout: stdoutData,
|
||||||
|
stderr: stderrData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
stdoutStream.on("end", () => {
|
||||||
|
stdoutEnded = true;
|
||||||
|
tryFinish();
|
||||||
|
});
|
||||||
|
stderrStream.on("end", () => {
|
||||||
|
stderrEnded = true;
|
||||||
|
tryFinish();
|
||||||
|
});
|
||||||
|
|
||||||
const websocketPromise = exec
|
const websocketPromise = exec
|
||||||
.exec(
|
.exec(
|
||||||
namespace,
|
namespace,
|
||||||
podName,
|
podName,
|
||||||
containerName,
|
containerName,
|
||||||
command,
|
effectiveCommand,
|
||||||
stdoutStream,
|
stdoutStream,
|
||||||
stderrStream,
|
stderrStream,
|
||||||
stdinStream,
|
stdinStream,
|
||||||
@@ -88,7 +128,8 @@ export async function execInPod(
|
|||||||
(status) => {
|
(status) => {
|
||||||
// status.status is "Success" | "Failure"
|
// status.status is "Success" | "Failure"
|
||||||
if (status.status === "Success") {
|
if (status.status === "Success") {
|
||||||
finish({ exitCode: 0, timedOut: false, stdout: stdoutData, stderr: stderrData });
|
pendingResult = { exitCode: 0, timedOut: false };
|
||||||
|
tryFinish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// On failure, the exit code surfaces via
|
// On failure, the exit code surfaces via
|
||||||
@@ -101,19 +142,25 @@ export async function execInPod(
|
|||||||
const exitCode = exitCodeCause?.message
|
const exitCode = exitCodeCause?.message
|
||||||
? Number(exitCodeCause.message)
|
? Number(exitCodeCause.message)
|
||||||
: 1;
|
: 1;
|
||||||
finish({ exitCode, timedOut: false, stdout: stdoutData, stderr: stderrData });
|
pendingResult = { exitCode, timedOut: false };
|
||||||
|
tryFinish();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
websocketPromise
|
websocketPromise
|
||||||
.then((ws) => {
|
.then((webSocket) => {
|
||||||
|
ws = webSocket as WebSocketLike;
|
||||||
|
if (stdinStream && stdinPayload) {
|
||||||
|
stdinStream.removeAllListeners("end");
|
||||||
|
stdinStream.end(stdinPayload);
|
||||||
|
}
|
||||||
ws.on("close", (code: number, reason: Buffer) => {
|
ws.on("close", (code: number, reason: Buffer) => {
|
||||||
if (settled) return;
|
if (settled || pendingResult) return;
|
||||||
const reasonText = reason.length > 0 ? `: ${reason.toString("utf-8")}` : "";
|
const reasonText = reason.length > 0 ? `: ${reason.toString("utf-8")}` : "";
|
||||||
finishWithTransportFailure(`Kubernetes exec websocket closed before status frame (${code})${reasonText}`);
|
finishWithTransportFailure(`Kubernetes exec websocket closed before status frame (${code})${reasonText}`);
|
||||||
});
|
});
|
||||||
ws.on("error", (err: Error) => {
|
ws.on("error", (err: Error) => {
|
||||||
if (settled) return;
|
if (settled || pendingResult) return;
|
||||||
finishWithTransportFailure(`Kubernetes exec websocket failed before status frame: ${err.message}`);
|
finishWithTransportFailure(`Kubernetes exec websocket failed before status frame: ${err.message}`);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Fast-upload interceptor for the chunked-shell file transfer protocol used by
|
||||||
|
* `@paperclipai/adapter-utils` command-managed runtimes.
|
||||||
|
*
|
||||||
|
* The normal path writes files through many shell execs:
|
||||||
|
* 1. mkdir/rm/touch `<target>.paperclip-upload.b64`
|
||||||
|
* 2. append many base64 chunks with printf
|
||||||
|
* 3. base64-decode the temp file into the final target
|
||||||
|
*
|
||||||
|
* On Kubernetes each exec is a new WebSocket round trip. This state machine
|
||||||
|
* recognizes that exact protocol, buffers the base64 chunks in the plugin
|
||||||
|
* worker, and lets the caller flush the final payload through one exec.
|
||||||
|
* Pattern drift or missing state falls through to the original exec path.
|
||||||
|
*/
|
||||||
|
import { posix as pathPosix } from "node:path";
|
||||||
|
|
||||||
|
const INIT_RE =
|
||||||
|
/^mkdir -p '([^']+)' && rm -f '([^']+)\.paperclip-upload\.b64' && : > '\2\.paperclip-upload\.b64'$/;
|
||||||
|
const CHUNK_RE =
|
||||||
|
/^printf '%s' '([A-Za-z0-9+/=]+)' >> '([^']+)\.paperclip-upload\.b64'$/;
|
||||||
|
const FINALIZE_RE =
|
||||||
|
/^base64 -d < '([^']+)\.paperclip-upload\.b64' > '\1' && rm -f '\1\.paperclip-upload\.b64'$/;
|
||||||
|
|
||||||
|
const MAX_BUFFER_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface FastUploadFlush {
|
||||||
|
targetPath: string;
|
||||||
|
payload: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FastUploadDecision =
|
||||||
|
| { action: "ack"; reason: string }
|
||||||
|
| { action: "flush"; flush: FastUploadFlush }
|
||||||
|
| { action: "passthrough"; reason: string };
|
||||||
|
|
||||||
|
interface BufferedUpload {
|
||||||
|
targetPath: string;
|
||||||
|
chunks: string[];
|
||||||
|
totalBase64Chars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FastUploadInterceptor {
|
||||||
|
private readonly buffers = new Map<string, BufferedUpload>();
|
||||||
|
|
||||||
|
decide(command: string): FastUploadDecision {
|
||||||
|
const initMatch = INIT_RE.exec(command);
|
||||||
|
if (initMatch) {
|
||||||
|
const dir = initMatch[1];
|
||||||
|
const targetPath = initMatch[2];
|
||||||
|
if (pathPosix.dirname(targetPath) !== dir) {
|
||||||
|
return { action: "passthrough", reason: "init dir/target mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buffers.set(`${targetPath}.paperclip-upload.b64`, {
|
||||||
|
targetPath,
|
||||||
|
chunks: [],
|
||||||
|
totalBase64Chars: 0,
|
||||||
|
});
|
||||||
|
return { action: "ack", reason: `init upload to ${targetPath}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkMatch = CHUNK_RE.exec(command);
|
||||||
|
if (chunkMatch) {
|
||||||
|
const base64Chunk = chunkMatch[1];
|
||||||
|
const targetPath = chunkMatch[2];
|
||||||
|
const tempPath = `${targetPath}.paperclip-upload.b64`;
|
||||||
|
const upload = this.buffers.get(tempPath);
|
||||||
|
if (!upload) {
|
||||||
|
return { action: "passthrough", reason: "chunk without prior init" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload.totalBase64Chars + base64Chunk.length > (MAX_BUFFER_BYTES * 4) / 3) {
|
||||||
|
this.buffers.delete(tempPath);
|
||||||
|
return { action: "passthrough", reason: "buffer cap exceeded" };
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.chunks.push(base64Chunk);
|
||||||
|
upload.totalBase64Chars += base64Chunk.length;
|
||||||
|
return { action: "ack", reason: `buffered ${base64Chunk.length} base64 chars` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeMatch = FINALIZE_RE.exec(command);
|
||||||
|
if (finalizeMatch) {
|
||||||
|
const targetPath = finalizeMatch[1];
|
||||||
|
const tempPath = `${targetPath}.paperclip-upload.b64`;
|
||||||
|
const upload = this.buffers.get(tempPath);
|
||||||
|
if (!upload) {
|
||||||
|
return { action: "passthrough", reason: "finalize without buffered state" };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buffers.delete(tempPath);
|
||||||
|
return {
|
||||||
|
action: "flush",
|
||||||
|
flush: {
|
||||||
|
targetPath: upload.targetPath,
|
||||||
|
payload: Buffer.from(upload.chunks.join(""), "base64"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "passthrough", reason: "no upload pattern" };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.buffers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get pendingCount(): number {
|
||||||
|
return this.buffers.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import plugin, {
|
import plugin, {
|
||||||
|
buildSandboxExecCommand,
|
||||||
buildSandboxExecShellCommand,
|
buildSandboxExecShellCommand,
|
||||||
extractAdapterEnvFromProcess,
|
extractAdapterEnvFromProcess,
|
||||||
} from "../../src/plugin.js";
|
} from "../../src/plugin.js";
|
||||||
@@ -161,4 +162,21 @@ describe("plugin", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("pnpm test -- --runInBand");
|
).toBe("pnpm test -- --runInBand");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes command and args directly to Kubernetes exec", () => {
|
||||||
|
expect(
|
||||||
|
buildSandboxExecCommand({
|
||||||
|
command: "sh",
|
||||||
|
args: ["-c", "printf '%s' ok"],
|
||||||
|
}),
|
||||||
|
).toEqual(["sh", "-c", "printf '%s' ok"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps command-only execution in a login shell", () => {
|
||||||
|
expect(
|
||||||
|
buildSandboxExecCommand({
|
||||||
|
command: "pnpm test -- --runInBand",
|
||||||
|
}),
|
||||||
|
).toEqual(["/bin/sh", "-lc", "pnpm test -- --runInBand"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe("execInPod", () => {
|
|||||||
it("returns success when the Kubernetes exec status callback reports success", async () => {
|
it("returns success when the Kubernetes exec status callback reports success", async () => {
|
||||||
execMock.mockImplementation((_namespace, _pod, _container, _command, stdout, _stderr, _stdin, _tty, statusCallback) => {
|
execMock.mockImplementation((_namespace, _pod, _container, _command, stdout, _stderr, _stdin, _tty, statusCallback) => {
|
||||||
stdout.write("ok\n");
|
stdout.write("ok\n");
|
||||||
|
stdout.end();
|
||||||
|
_stderr.end();
|
||||||
statusCallback({ status: "Success" });
|
statusCallback({ status: "Success" });
|
||||||
return Promise.resolve(new EventEmitter());
|
return Promise.resolve(new EventEmitter());
|
||||||
});
|
});
|
||||||
@@ -49,4 +51,31 @@ describe("execInPod", () => {
|
|||||||
expect(result.timedOut).toBe(true);
|
expect(result.timedOut).toBe(true);
|
||||||
expect(result.stderr).toContain("Kubernetes exec timed out after 5ms");
|
expect(result.stderr).toContain("Kubernetes exec timed out after 5ms");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wraps stdin commands with a byte-counted head prefix", async () => {
|
||||||
|
let observedCommand: string[] | undefined;
|
||||||
|
let observedStdin = "";
|
||||||
|
let observedStdinFinished = false;
|
||||||
|
|
||||||
|
execMock.mockImplementation((_namespace, _pod, _container, command, stdout, stderr, stdin, _tty, statusCallback) => {
|
||||||
|
observedCommand = command;
|
||||||
|
stdin?.on("data", (chunk: Buffer) => {
|
||||||
|
observedStdin += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stdin?.on("finish", () => {
|
||||||
|
observedStdinFinished = true;
|
||||||
|
});
|
||||||
|
stdout.end();
|
||||||
|
stderr.end();
|
||||||
|
statusCallback({ status: "Success" });
|
||||||
|
return Promise.resolve(new EventEmitter());
|
||||||
|
});
|
||||||
|
|
||||||
|
await execInPod({} as never, "ns", "pod-1", "agent", ["base64", "-d"], "abc");
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(observedCommand).toEqual(["/bin/sh", "-c", "head -c 3 | 'base64' '-d'"]);
|
||||||
|
expect(observedStdin).toBe("abc");
|
||||||
|
expect(observedStdinFinished).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { FastUploadInterceptor } from "../../src/upload-interceptor.js";
|
||||||
|
|
||||||
|
describe("FastUploadInterceptor", () => {
|
||||||
|
it("collapses the adapter-utils chunked upload protocol into one flush", () => {
|
||||||
|
const interceptor = new FastUploadInterceptor();
|
||||||
|
const target = "/workspace/.paperclip-runtime/skills.tar";
|
||||||
|
const chunkA = Buffer.from("hello ").toString("base64").slice(0, 4);
|
||||||
|
const chunkB = Buffer.from("hello ").toString("base64").slice(4) + Buffer.from("world").toString("base64");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
interceptor.decide(
|
||||||
|
`mkdir -p '/workspace/.paperclip-runtime' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||||
|
),
|
||||||
|
).toMatchObject({ action: "ack" });
|
||||||
|
expect(interceptor.pendingCount).toBe(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
interceptor.decide(`printf '%s' '${chunkA}' >> '${target}.paperclip-upload.b64'`),
|
||||||
|
).toMatchObject({ action: "ack" });
|
||||||
|
expect(
|
||||||
|
interceptor.decide(`printf '%s' '${chunkB}' >> '${target}.paperclip-upload.b64'`),
|
||||||
|
).toMatchObject({ action: "ack" });
|
||||||
|
|
||||||
|
const decision = interceptor.decide(
|
||||||
|
`base64 -d < '${target}.paperclip-upload.b64' > '${target}' && rm -f '${target}.paperclip-upload.b64'`,
|
||||||
|
);
|
||||||
|
expect(decision.action).toBe("flush");
|
||||||
|
if (decision.action !== "flush") throw new Error("expected flush");
|
||||||
|
expect(decision.flush.targetPath).toBe(target);
|
||||||
|
expect(decision.flush.payload.toString("utf8")).toBe("hello world");
|
||||||
|
expect(interceptor.pendingCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through chunks and finalizers without a matching init", () => {
|
||||||
|
const interceptor = new FastUploadInterceptor();
|
||||||
|
const target = "/workspace/file.bin";
|
||||||
|
|
||||||
|
expect(
|
||||||
|
interceptor.decide(`printf '%s' 'aGVsbG8=' >> '${target}.paperclip-upload.b64'`),
|
||||||
|
).toMatchObject({ action: "passthrough", reason: "chunk without prior init" });
|
||||||
|
expect(
|
||||||
|
interceptor.decide(
|
||||||
|
`base64 -d < '${target}.paperclip-upload.b64' > '${target}' && rm -f '${target}.paperclip-upload.b64'`,
|
||||||
|
),
|
||||||
|
).toMatchObject({ action: "passthrough", reason: "finalize without buffered state" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through when the init command does not match the target parent directory", () => {
|
||||||
|
const interceptor = new FastUploadInterceptor();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
interceptor.decide(
|
||||||
|
"mkdir -p '/tmp' && rm -f '/workspace/file.bin.paperclip-upload.b64' && : > '/workspace/file.bin.paperclip-upload.b64'",
|
||||||
|
),
|
||||||
|
).toMatchObject({ action: "passthrough", reason: "init dir/target mismatch" });
|
||||||
|
expect(interceptor.pendingCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears buffered uploads on reset", () => {
|
||||||
|
const interceptor = new FastUploadInterceptor();
|
||||||
|
const target = "/workspace/file.bin";
|
||||||
|
|
||||||
|
interceptor.decide(
|
||||||
|
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||||
|
);
|
||||||
|
expect(interceptor.pendingCount).toBe(1);
|
||||||
|
|
||||||
|
interceptor.reset();
|
||||||
|
expect(interceptor.pendingCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user