diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index b27c93b7a5..deb42387db 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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, }) diff --git a/server/src/services/native-runtime/native-ssh-command-runner.test.ts b/server/src/services/native-runtime/native-ssh-command-runner.test.ts new file mode 100644 index 0000000000..be76d4ea2c --- /dev/null +++ b/server/src/services/native-runtime/native-ssh-command-runner.test.ts @@ -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); + }); +}); diff --git a/server/src/services/native-runtime/native-ssh-command-runner.ts b/server/src/services/native-runtime/native-ssh-command-runner.ts new file mode 100644 index 0000000000..2c1e28a3ea --- /dev/null +++ b/server/src/services/native-runtime/native-ssh-command-runner.ts @@ -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[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), + }); +}