mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
Stage the nine managed GitHub launcher files through one bounded control command. Preserve per-file locks, content verification, atomic replacement and control-channel dispatch. Real Daytona staging decreased from about one second to 0.24–0.34 seconds. Full CI, E2E, Superagent and Apex 5/5 passed on the reviewed head. Co-Authored-By: Paperclip <noreply@paperclip.ing>
138 lines
6.2 KiB
TypeScript
138 lines
6.2 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
|
import type { RunProcessResult } from "./server-utils.js";
|
|
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
|
import { buildRemotePidLockAcquireScript, buildRemotePidLockCleanupScript } from "./remote-pid-lock.js";
|
|
|
|
export interface RemoteTextFileSyncFile {
|
|
remoteDir: string;
|
|
remotePath: string;
|
|
body: string;
|
|
label: string;
|
|
action: string;
|
|
lockDir: string;
|
|
executable?: boolean;
|
|
}
|
|
|
|
export interface RemoteTextFileSyncInput extends RemoteTextFileSyncFile {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
timeoutMs?: number | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}
|
|
|
|
export interface RemoteTextFileBatchInput {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
files: RemoteTextFileSyncFile[];
|
|
ensureDirectories?: string[];
|
|
action: string;
|
|
timeoutMs?: number | null;
|
|
shellCommand?: "bash" | "sh" | null;
|
|
}
|
|
|
|
interface RemoteTextSyncExecution {
|
|
runShell(runner: CommandManagedRuntimeRunner, cwd: string, script: string, timeoutMs: number,
|
|
shellCommand: "bash" | "sh", stdin?: string): Promise<RunProcessResult>;
|
|
check(action: string, result: RunProcessResult): RunProcessResult;
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function timeout(value: number | null | undefined) {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 30_000;
|
|
}
|
|
|
|
function program(input: RemoteTextFileSyncFile) {
|
|
const remotePartial = `${input.remotePath}.partial`;
|
|
const remoteUploadPath = `${input.remotePath}.paperclip-upload.b64`;
|
|
const base64Body = Buffer.from(input.body, "utf8").toString("base64");
|
|
const sha256 = createHash("sha256").update(input.body, "utf8").digest("hex");
|
|
const script = [
|
|
"set -eu",
|
|
`remote_dir=${shellQuote(input.remoteDir)}`,
|
|
`remote_path=${shellQuote(input.remotePath)}`,
|
|
`remote_partial=${shellQuote(remotePartial)}`,
|
|
`remote_upload=${shellQuote(remoteUploadPath)}`,
|
|
`lock_dir=${shellQuote(input.lockDir)}`,
|
|
`expected_sha=${shellQuote(sha256)}`,
|
|
"hash_file() {",
|
|
" if command -v sha256sum >/dev/null 2>&1; then",
|
|
" sha256sum \"$1\" | awk '{print $1}'",
|
|
" return 0",
|
|
" fi",
|
|
" if command -v shasum >/dev/null 2>&1; then",
|
|
" shasum -a 256 \"$1\" | awk '{print $1}'",
|
|
" return 0",
|
|
" fi",
|
|
" return 127",
|
|
"}",
|
|
"mkdir -p \"$remote_dir\"",
|
|
...buildRemotePidLockAcquireScript("\"$lock_dir\"", `Timed out acquiring ${input.label} upload lock.`),
|
|
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
|
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
]),
|
|
"current_sha=\"\"",
|
|
"if [ -f \"$remote_path\" ]; then",
|
|
" current_sha=\"$(hash_file \"$remote_path\" 2>/dev/null)\" || current_sha=\"\"",
|
|
"fi",
|
|
"if [ -n \"$current_sha\" ] && [ \"$current_sha\" = \"$expected_sha\" ]; then",
|
|
...(input.executable ? [' chmod 700 "$remote_path"'] : []),
|
|
" printf '{\"uploaded\":false}\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
"cat > \"$remote_upload\"",
|
|
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
|
|
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
|
|
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
|
|
` echo ${shellQuote(`${input.label} upload sha mismatch.`)} >&2`,
|
|
" exit 1",
|
|
" fi",
|
|
"else",
|
|
` echo ${shellQuote(`${input.label} sha verify skipped: no sha256sum/shasum on remote.`)} >&2`,
|
|
"fi",
|
|
...(input.executable ? ['chmod 700 "$remote_partial"'] : []),
|
|
"mv \"$remote_partial\" \"$remote_path\"",
|
|
"printf '{\"uploaded\":true}\\n'",
|
|
].join("\n");
|
|
return { script, base64Body, sha256 };
|
|
}
|
|
|
|
export async function syncRemoteTextFile(input: RemoteTextFileSyncInput, execution: RemoteTextSyncExecution) {
|
|
const prepared = program(input);
|
|
const result = await execution.runShell(input.runner, input.remoteCwd, prepared.script, timeout(input.timeoutMs),
|
|
preferredShellForSandbox(input.shellCommand), prepared.base64Body);
|
|
execution.check(input.action, result);
|
|
let uploaded = false;
|
|
try { uploaded = JSON.parse(result.stdout.trim())?.uploaded === true; }
|
|
catch (error) {
|
|
throw new Error(`${input.label} sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
return { uploaded, sha256: prepared.sha256 };
|
|
}
|
|
|
|
export async function syncRemoteTextFiles(input: RemoteTextFileBatchInput, execution: RemoteTextSyncExecution) {
|
|
if (input.files.length === 0 || input.files.length > 16) throw new Error("Remote text sync requires 1 to 16 files");
|
|
const shell = preferredShellForSandbox(input.shellCommand);
|
|
const programs = input.files.map(program);
|
|
const script = ["set -eu", ...programs.map((file, index) => {
|
|
const delimiter = `PAPERCLIP_TEXT_${index}_${randomUUID().replaceAll("-", "")}`;
|
|
return `${shellQuote(shell)} -c ${shellQuote(file.script)} <<'${delimiter}'\n${file.base64Body}\n${delimiter}`;
|
|
}), ...(input.ensureDirectories?.length ? [`mkdir -p -- ${input.ensureDirectories.map(shellQuote).join(" ")}`] : [])].join("\n") + "\n";
|
|
if (Buffer.byteLength(script, "utf8") > 1024 * 1024) throw new Error("Remote text sync batch exceeds 1 MiB");
|
|
const result = await execution.runShell(input.runner, input.remoteCwd, `exec ${shell} -s`, timeout(input.timeoutMs), shell, script);
|
|
execution.check(input.action, result);
|
|
try {
|
|
const results: unknown[] = result.stdout.trim().split("\n").map(line => JSON.parse(line));
|
|
if (results.length !== programs.length || results.some(row => !row || typeof row !== "object" || typeof (row as { uploaded?: unknown }).uploaded !== "boolean")) {
|
|
throw new Error("Invalid file result count or upload state");
|
|
}
|
|
return programs.map((file, index) => ({ uploaded: (results[index] as { uploaded: boolean }).uploaded, sha256: file.sha256 }));
|
|
} catch (error) {
|
|
throw new Error(`Remote text sync batch wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|