mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
perf(adapter-utils): batch remote GitHub launcher staging (#16552)
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>
This commit is contained in:
1 parent
8f1cb11a03
commit
8556539364
6 files changed
+282
-171
No files matched your search
@@ -1810,6 +1810,17 @@ versions across directories. With these artifacts preinstalled, startup links
|
||||
and verifies them without uploading a binary or installing packages. Deploy
|
||||
the updated sandbox image with the matching runner qualification changes.
|
||||
|
||||
### Remote GitHub launcher staging
|
||||
|
||||
Managed GitHub launchers stage their nine text files in one bounded control
|
||||
command. Each file retains its content-hash check, process lock, upload check,
|
||||
and atomic rename. The command runs outside the persistent agent session and
|
||||
stops at the first failed file. Earlier files can remain after a later failure;
|
||||
the launcher environment is returned only after the whole command succeeds.
|
||||
The batch permits at most 16 files and 1 MiB of shell input. Launcher staging
|
||||
keeps its 15-second deadline. Run `packages/adapter-utils/src/github-launcher-environment.test.ts`
|
||||
and `packages/adapter-utils/src/sandbox-callback-bridge.test.ts` for these contracts.
|
||||
|
||||
### Native runner restart recovery
|
||||
|
||||
Custom source-mode HTTP launchers should create their listener with
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
syncRemoteTextFileWithHashSkip,
|
||||
syncRemoteTextFilesWithHashSkip,
|
||||
syncSandboxCallbackBridgeEntrypoint,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
import {
|
||||
@@ -1770,22 +1771,17 @@ export async function prepareGitHubOperationLaunchers(input: {
|
||||
]);
|
||||
if (remote) {
|
||||
const runner = adapterExecutionTargetCommandRunner(remote);
|
||||
const entries = Object.entries(files);
|
||||
const width = runner.allowConcurrentSyncOperations === true ? 3 : 1;
|
||||
for (let offset = 0; offset < entries.length; offset += width) {
|
||||
const results = await Promise.allSettled(entries.slice(offset, offset + width).map(([program, body]) =>
|
||||
syncRemoteTextFileWithHashSkip({
|
||||
runner, remoteCwd: remote.remoteCwd, remoteDir: directory,
|
||||
remotePath: path.posix.join(directory, program), body,
|
||||
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
|
||||
lockDir: path.posix.join(directory, `.${program}.lock`),
|
||||
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
|
||||
})));
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") throw failure.reason;
|
||||
}
|
||||
const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 });
|
||||
if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers");
|
||||
await syncRemoteTextFilesWithHashSkip({
|
||||
runner, remoteCwd: remote.remoteCwd,
|
||||
files: Object.entries(files).map(([program, body]) => ({
|
||||
remoteDir: directory, remotePath: path.posix.join(directory, program), body,
|
||||
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
|
||||
lockDir: path.posix.join(directory, `.${program}.lock`),
|
||||
executable: program === "git" || program === "gh",
|
||||
})),
|
||||
ensureDirectories: [configDirectory], action: "stage GitHub operation launchers",
|
||||
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
|
||||
});
|
||||
} else {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await fs.mkdir(configDirectory, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { watch } from "node:fs";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssh from "./ssh.js";
|
||||
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
||||
import { githubBrokerEnvironment } from "./github-launcher.js";
|
||||
import { syncRemoteTextFilesWithHashSkip } from "./sandbox-callback-bridge.js";
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareGitHubOperationLaunchers,
|
||||
@@ -68,7 +69,20 @@ async function sandbox(layout: string, concurrent?: boolean) {
|
||||
}
|
||||
|
||||
describe("managed GitHub launcher environment", () => {
|
||||
it("bounds independent remote launcher uploads and preserves the staged environment", async () => {
|
||||
it("stages the complete remote launcher set in one control command", async () => {
|
||||
const fixture = await sandbox("tools with 'quotes'/bin", true);
|
||||
const env = await prepareGitHubOperationLaunchers({
|
||||
runId: "single-control-stage", target: fixture.target, cwd: fixture.root,
|
||||
env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }),
|
||||
});
|
||||
expect(fixture.runner.execute).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.runner.execute.mock.calls[0]![0].bypassSession).toBe(true);
|
||||
const result = await fixture.runner.execute({ command: "sh", args: ["-c", "git; gh"], env });
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe("git started\ngh started\n");
|
||||
});
|
||||
|
||||
it("uses one bounded control command and preserves the staged environment", async () => {
|
||||
const fixture = await sandbox("tools with 'quotes'/bin", true);
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
let release!: () => void;
|
||||
@@ -91,13 +105,13 @@ describe("managed GitHub launcher environment", () => {
|
||||
env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }),
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(started).toBe(3));
|
||||
expect(active).toBe(3);
|
||||
await vi.waitFor(() => expect(started).toBe(1));
|
||||
expect(active).toBe(1);
|
||||
} finally { release(); await preparation; }
|
||||
const env = await preparation;
|
||||
expect(maximum).toBe(3);
|
||||
expect(maximum).toBe(1);
|
||||
expect(active).toBe(0);
|
||||
expect(started).toBe(9);
|
||||
expect(started).toBe(1);
|
||||
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
||||
const command = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", "git; gh"], env });
|
||||
expect(command.exitCode, command.stderr).toBe(0);
|
||||
@@ -112,21 +126,18 @@ describe("managed GitHub launcher environment", () => {
|
||||
|
||||
it("settles failed upload batches before rejecting and starting no later files", async () => {
|
||||
const fixture = await sandbox("usr/bin", true);
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
let started = 0;
|
||||
let active = 0;
|
||||
let settled = false;
|
||||
fixture.runner.execute.mockImplementation(async (input) => {
|
||||
if (!input.stdin) return execute(input);
|
||||
const index = started++;
|
||||
started += 1;
|
||||
active += 1;
|
||||
try {
|
||||
if (index === 0) return { exitCode: 42, signal: null, timedOut: false, pid: null,
|
||||
startedAt: new Date().toISOString(), stdout: "", stderr: "fixture upload failed" };
|
||||
await gate;
|
||||
return await execute(input);
|
||||
return { exitCode: 42, signal: null, timedOut: false, pid: null,
|
||||
startedAt: new Date().toISOString(), stdout: "", stderr: "fixture upload failed" };
|
||||
} finally { active -= 1; }
|
||||
});
|
||||
const preparation = prepareGitHubOperationLaunchers({
|
||||
@@ -134,13 +145,13 @@ describe("managed GitHub launcher environment", () => {
|
||||
});
|
||||
const outcome = preparation.then(() => { settled = true; return null; }, (error: unknown) => { settled = true; return error; });
|
||||
try {
|
||||
await vi.waitFor(() => expect(started).toBe(3));
|
||||
await vi.waitFor(() => expect(started).toBe(1));
|
||||
expect(settled).toBe(false);
|
||||
expect(active).toBe(2);
|
||||
expect(active).toBe(1);
|
||||
} finally { release(); await outcome; }
|
||||
expect(await outcome).toBeInstanceOf(Error);
|
||||
expect(String(await outcome)).toContain("stage GitHub operation launcher");
|
||||
expect(started).toBe(3);
|
||||
expect(started).toBe(1);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.runner.execute.mock.calls.some(([input]) => input.args?.join(" ").startsWith("chmod 700"))).toBe(false);
|
||||
});
|
||||
@@ -171,7 +182,59 @@ describe("managed GitHub launcher environment", () => {
|
||||
cwd: fixture.root, env: { PATH: fixture.remotePath } });
|
||||
expect(maximum).toBe(1);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.runner.execute).toHaveBeenCalledTimes(10);
|
||||
expect(fixture.runner.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects corrupt batch input before replacing that file or staging later files", async () => {
|
||||
const fixture = await sandbox("usr/bin");
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
const directory = path.join(fixture.root, "batch");
|
||||
const files = ["first", "corrupt", "later"].map(name => ({
|
||||
remoteDir: directory, remotePath: path.join(directory, name), body: name + "\n",
|
||||
label: name, action: "stage fixture", lockDir: path.join(directory, "." + name + ".lock"),
|
||||
}));
|
||||
fixture.runner.execute.mockImplementation(input => execute({ ...input,
|
||||
stdin: input.stdin?.replace(Buffer.from("corrupt\n").toString("base64"), Buffer.from("invalid\n").toString("base64")),
|
||||
}));
|
||||
await expect(syncRemoteTextFilesWithHashSkip({ runner: fixture.runner, remoteCwd: fixture.root,
|
||||
files, action: "stage fixtures" })).rejects.toThrow("sha mismatch");
|
||||
expect(await readFile(path.join(directory, "first"), "utf8")).toBe("first\n");
|
||||
expect(await readdir(directory)).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("hash-skips a complete batch and repairs a missing executable bit", async () => {
|
||||
const fixture = await sandbox("usr/bin");
|
||||
const directory = path.join(fixture.root, "batch 'quoted'");
|
||||
const files = [{ remoteDir: directory, remotePath: path.join(directory, "entry"), body: "#!/bin/sh\nprintf done\n",
|
||||
label: "entry", action: "stage fixture", lockDir: path.join(directory, ".entry.lock"), executable: true }];
|
||||
const input = { runner: fixture.runner, remoteCwd: fixture.root, files, action: "stage fixtures" };
|
||||
expect((await syncRemoteTextFilesWithHashSkip(input))[0]?.uploaded).toBe(true);
|
||||
const firstHash = (await syncRemoteTextFilesWithHashSkip(input))[0]?.sha256;
|
||||
await fixture.runner.execute({ command: "chmod", args: ["600", files[0]!.remotePath] });
|
||||
const restored = await syncRemoteTextFilesWithHashSkip(input);
|
||||
expect(restored).toEqual([{ uploaded: false, sha256: firstHash }]);
|
||||
const result = await fixture.runner.execute({ command: files[0]!.remotePath });
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe("done");
|
||||
expect(await readdir(directory)).toEqual(["entry"]);
|
||||
});
|
||||
|
||||
it.each([0, 17])("rejects an invalid batch size before remote work (%s)", async count => {
|
||||
const fixture = await sandbox("usr/bin");
|
||||
const file = { remoteDir: fixture.root, remotePath: path.join(fixture.root, "entry"), body: "entry",
|
||||
label: "entry", action: "stage fixture", lockDir: path.join(fixture.root, ".entry.lock") };
|
||||
await expect(syncRemoteTextFilesWithHashSkip({ runner: fixture.runner, remoteCwd: fixture.root,
|
||||
files: Array.from({ length: count }, () => file), action: "stage fixtures" })).rejects.toThrow("1 to 16 files");
|
||||
expect(fixture.runner.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized batch before remote work", async () => {
|
||||
const fixture = await sandbox("usr/bin");
|
||||
await expect(syncRemoteTextFilesWithHashSkip({ runner: fixture.runner, remoteCwd: fixture.root,
|
||||
files: [{ remoteDir: fixture.root, remotePath: path.join(fixture.root, "entry"), body: "x".repeat(1024 * 1024),
|
||||
label: "entry", action: "stage fixture", lockDir: path.join(fixture.root, ".entry.lock") }],
|
||||
action: "stage fixtures" })).rejects.toThrow("1 MiB");
|
||||
expect(fixture.runner.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The Daytona native directory oracle uses Linux inotify. Run its real
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function buildRemotePidLockAcquireScript(lockDirExpr: string, timeoutMessage: string): string[] {
|
||||
return [
|
||||
"attempts=0",
|
||||
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
|
||||
" holder_pid=\"\"",
|
||||
` if [ -s ${lockDirExpr}/pid ]; then`,
|
||||
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
|
||||
" fi",
|
||||
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
|
||||
` rm -rf ${lockDirExpr}`,
|
||||
" continue",
|
||||
" fi",
|
||||
" attempts=$((attempts + 1))",
|
||||
" if [ \"$attempts\" -ge 600 ]; then",
|
||||
` echo ${shellQuote(timeoutMessage)} >&2`,
|
||||
" exit 1",
|
||||
" fi",
|
||||
" sleep 0.05",
|
||||
"done",
|
||||
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildRemotePidLockCleanupScript(lockDirExpr: string, cleanupLines: string[]): string[] {
|
||||
return [
|
||||
"cleanup() {",
|
||||
...cleanupLines.map((line) => ` ${line}`),
|
||||
` rm -rf ${lockDirExpr}`,
|
||||
"}",
|
||||
"trap cleanup EXIT INT TERM",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildRemotePidLockAcquireScript, buildRemotePidLockCleanupScript } from "./remote-pid-lock.js";
|
||||
import { syncRemoteTextFile, syncRemoteTextFiles, type RemoteTextFileSyncInput, type RemoteTextFileBatchInput } from "./remote-text-file-sync.js";
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import http2 from "node:http2";
|
||||
@@ -414,39 +416,6 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
return await fs.stat(filePath).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
function buildRemotePidLockAcquireScript(lockDirExpr: string, timeoutMessage: string): string[] {
|
||||
return [
|
||||
"attempts=0",
|
||||
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
|
||||
" holder_pid=\"\"",
|
||||
` if [ -s ${lockDirExpr}/pid ]; then`,
|
||||
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
|
||||
" fi",
|
||||
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
|
||||
` rm -rf ${lockDirExpr}`,
|
||||
" continue",
|
||||
" fi",
|
||||
" attempts=$((attempts + 1))",
|
||||
" if [ \"$attempts\" -ge 600 ]; then",
|
||||
` echo ${shellQuote(timeoutMessage)} >&2`,
|
||||
" exit 1",
|
||||
" fi",
|
||||
" sleep 0.05",
|
||||
"done",
|
||||
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildRemotePidLockCleanupScript(lockDirExpr: string, cleanupLines: string[]): string[] {
|
||||
return [
|
||||
"cleanup() {",
|
||||
...cleanupLines.map((line) => ` ${line}`),
|
||||
` rm -rf ${lockDirExpr}`,
|
||||
"}",
|
||||
"trap cleanup EXIT INT TERM",
|
||||
];
|
||||
}
|
||||
|
||||
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
|
||||
return randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
@@ -1671,113 +1640,12 @@ export async function startSandboxCallbackBridgeWorker(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-hash-skip write of a Paperclip-authored text file into the sandbox, in
|
||||
* a SINGLE remote exec. The body's sha256 is computed on the host; the one shell
|
||||
* round-trip skips the write entirely when the remote file already hashes to the
|
||||
* same value (warm start — 0 write execs), otherwise it uploads (base64 over
|
||||
* stdin), verifies the decoded bytes, and atomically renames into place. A
|
||||
* PID-liveness lock serializes concurrent writers to the same path and the
|
||||
* verify step guards against a torn upload.
|
||||
*
|
||||
* Fail loudly: a non-zero remote exit (surfaced by `requireSuccessfulResult`) or
|
||||
* malformed result JSON throws rather than silently re-uploading and masking a
|
||||
* failed check. The only intentional degradation is when the remote has neither
|
||||
* `sha256sum` nor `shasum` — then the skip cannot be proven and we conservatively
|
||||
* re-upload (and the post-upload verify is best-effort, as noted inline).
|
||||
*/
|
||||
export async function syncRemoteTextFileWithHashSkip(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
remoteDir: string;
|
||||
remotePath: string;
|
||||
body: string;
|
||||
// Human-readable noun phrase used in fail-loud messages, e.g.
|
||||
// "Sandbox callback bridge entrypoint" / "Process session remote script".
|
||||
label: string;
|
||||
// Short action label for `requireSuccessfulResult`, e.g.
|
||||
// "sync sandbox callback bridge entrypoint".
|
||||
action: string;
|
||||
lockDir: string;
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): Promise<{ uploaded: boolean; sha256: string }> {
|
||||
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const remotePartial = `${input.remotePath}.partial`;
|
||||
const remoteUploadPath = `${input.remotePath}.paperclip-upload.b64`;
|
||||
const base64Body = toBuffer(Buffer.from(input.body, "utf8")).toString("base64");
|
||||
const sha256 = createHash("sha256").update(input.body, "utf8").digest("hex");
|
||||
export async function syncRemoteTextFileWithHashSkip(input: RemoteTextFileSyncInput) {
|
||||
return syncRemoteTextFile(input, { runShell, check: requireSuccessfulResult });
|
||||
}
|
||||
|
||||
const syncResult = await runShell(
|
||||
input.runner,
|
||||
input.remoteCwd,
|
||||
[
|
||||
"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",
|
||||
" printf '{\"uploaded\":false}\\n'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
||||
"cat > \"$remote_upload\"",
|
||||
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
|
||||
// Verify upload integrity. If neither sha256sum nor shasum is on PATH
|
||||
// (minimal Alpine/scratch images), surface the missing-tool error
|
||||
// instead of a misleading "sha mismatch" — the verify step is then
|
||||
// best-effort and we trust base64-decode + atomic rename below.
|
||||
"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",
|
||||
"mv \"$remote_partial\" \"$remote_path\"",
|
||||
"printf '{\"uploaded\":true}\\n'",
|
||||
].join("\n"),
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
base64Body,
|
||||
);
|
||||
requireSuccessfulResult(input.action, syncResult);
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
uploaded = JSON.parse(syncResult.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 };
|
||||
export async function syncRemoteTextFilesWithHashSkip(input: RemoteTextFileBatchInput) {
|
||||
return syncRemoteTextFiles(input, { runShell, check: requireSuccessfulResult });
|
||||
}
|
||||
|
||||
export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
||||
|
||||
Reference in new issue
Block a user