diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6ab0d3e9e37..7edd08fde99 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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 diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 7d96f2cc63e..7a22a29306d 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -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 }); diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index ed091f03b66..4d973209651 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -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((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 diff --git a/packages/adapter-utils/src/remote-pid-lock.ts b/packages/adapter-utils/src/remote-pid-lock.ts new file mode 100644 index 00000000000..1a3ed7e0152 --- /dev/null +++ b/packages/adapter-utils/src/remote-pid-lock.ts @@ -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", + ]; +} diff --git a/packages/adapter-utils/src/remote-text-file-sync.ts b/packages/adapter-utils/src/remote-text-file-sync.ts new file mode 100644 index 00000000000..309964038b9 --- /dev/null +++ b/packages/adapter-utils/src/remote-text-file-sync.ts @@ -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; + 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)}`); + } +} diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 707a6fcabda..7b41808b3d6 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -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 { 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: {