fix(runner): bound SSH output for verified remote files

Allow one maximum-size base64 deliverable through the native SSH command transport while retaining a finite output cap. Exercise the real SSH adapter subprocess limit with exact-size and overflow controls.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 22:13:00 -05:00
parent 7552b5deaa
commit 2582bf814d
3 changed files with 65 additions and 2 deletions

View File

@ -78,7 +78,7 @@ import {
type NativeSessionGoalControl,
} from "../../vendor/paperclip-runner/index.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh";
import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import {
resolvePaperclipRunnerTransport,
@ -9919,7 +9919,7 @@ async function createRunnerdBackendWithinSessionClaim(
const remoteTarget = target.kind === "remote" ? target : null;
const remoteCommandRunner = remoteTarget
? remoteTarget.transport === "ssh"
? createSshCommandManagedRuntimeRunner({
? createNativeSshCommandRunner({
spec: remoteTarget.spec,
defaultCwd: remoteTarget.remoteCwd,
})

View File

@ -0,0 +1,49 @@
import { createHash } from "node:crypto";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js";
import { MAX_REMOTE_DELIVERABLE_BYTES, readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js";
describe("native SSH deliverable output budget", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "paperclip-ssh-output-"));
// Exercise the real SSH command adapter and its execFile output limit,
// replacing only the network executable with a deterministic byte source.
const executable = join(root, "ssh");
await writeFile(executable, `#!${process.execPath}\nprocess.stdout.write(require('node:fs').readFileSync(${JSON.stringify(join(root, "response"))}));\n`);
await chmod(executable, 0o700);
vi.stubEnv("PATH", `${root}:${process.env.PATH}`);
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
});
const createRunner = () => createNativeSshCommandRunner({
spec: {
host: "fixture.invalid", port: 22, username: "fixture",
remoteWorkspacePath: "/workspace", remoteCwd: "/workspace",
privateKey: null, knownHosts: null, strictHostKeyChecking: true,
},
defaultCwd: "/workspace",
});
it.each([64, MAX_REMOTE_DELIVERABLE_BYTES])("returns exact verified bytes for a %i-byte file through the SSH adapter", async (byteSize) => {
const body = Buffer.alloc(byteSize, 65);
await writeFile(join(root, "response"), body.toString("base64"));
const result = await readVerifiedRemoteWorkspaceFile({
runner: createRunner(), workspaceRoot: "/workspace", contentRef: "result.md",
byteSize, sha256: createHash("sha256").update(body).digest("hex"),
});
expect(result.equals(body)).toBe(true);
});
it("stops a remote command that exceeds the maximum encoded envelope", async () => {
await writeFile(join(root, "response"), Buffer.alloc(4 * Math.ceil(MAX_REMOTE_DELIVERABLE_BYTES / 3) + 1, 65));
const result = await createRunner().execute({ command: "node", args: ["unused"], timeoutMs: 10_000 });
expect(result.exitCode).not.toBe(0);
});
});

View File

@ -0,0 +1,14 @@
import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh";
import { MAX_REMOTE_DELIVERABLE_BYTES } from "./remote-deliverable-file.js";
/** The command transport used by a native session on an operator-bound SSH host. */
export function createNativeSshCommandRunner(
input: Pick<Parameters<typeof createSshCommandManagedRuntimeRunner>[0], "spec" | "defaultCwd">,
) {
return createSshCommandManagedRuntimeRunner({
...input,
// The verified reader returns base64. Bound the command output to one
// maximum-size encoded file; the adapter's 1 MiB default truncates it.
maxBufferBytes: 4 * Math.ceil(MAX_REMOTE_DELIVERABLE_BYTES / 3),
});
}