mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(adapter-utils): preserve SSH command positional arguments
Keep shell argv intact and apply environment assignments after exec. Validate environment keys before constructing the remote command. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ddcd746eb6
commit
849f06ae31
2 files changed
+80
-9
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
// Execute the authored remote command locally, through both subprocess paths.
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn((file: string, args: string[], options: object, callback: Parameters<typeof actual.execFile>[3]) => {
|
||||
if (file !== "ssh") throw new Error(`Unexpected subprocess ${file}`);
|
||||
return actual.execFile("sh", ["-c", args.at(-1)!], options, callback);
|
||||
}),
|
||||
spawn: vi.fn((file: string, args: string[], options: Parameters<typeof actual.spawn>[2]) => {
|
||||
if (file !== "ssh") throw new Error(`Unexpected subprocess ${file}`);
|
||||
return actual.spawn("sh", ["-c", args.at(-1)!], options);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createSshCommandManagedRuntimeRunner } from "./ssh.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runner() {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "ssh-managed-argv-"));
|
||||
roots.push(cwd);
|
||||
return createSshCommandManagedRuntimeRunner({
|
||||
spec: {
|
||||
host: "fixture.invalid", username: "fixture", port: 22,
|
||||
remoteCwd: cwd, remoteWorkspacePath: cwd,
|
||||
privateKey: null, knownHosts: null, strictHostKeyChecking: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("SSH command-managed argv", () => {
|
||||
it("rejects environment keys that could be interpreted as env options", async () => {
|
||||
await expect((await runner()).execute({ command: "true", env: { "-u": "PATH" } }))
|
||||
.rejects.toThrow("Invalid SSH environment variable key");
|
||||
});
|
||||
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])(
|
||||
"preserves shell positional arguments with environment=%s and stdin=%s",
|
||||
async (withEnvironment, withStdin) => {
|
||||
const value = "literal 'quote' $(printf unsafe) `printf unsafe`";
|
||||
const result = await (await runner()).execute({
|
||||
command: "sh",
|
||||
args: ["-c", 'set -eu; printf "%s\\n" "$0" "$1" "$2" "${SENTINEL_TEXT-unset}"', "named-probe", "two words", value],
|
||||
...(withEnvironment ? { env: { SENTINEL_TEXT: value } } : {}),
|
||||
...(withStdin ? { stdin: "" } : {}),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe(["named-probe", "two words", value, withEnvironment ? value : "unset"].join("\n") + "\n");
|
||||
},
|
||||
);
|
||||
|
||||
it("applies environment to a non-shell command", async () => {
|
||||
const result = await (await runner()).execute({
|
||||
command: "printenv", args: ["SENTINEL_TEXT"],
|
||||
env: { SENTINEL_TEXT: "two words" }, timeoutMs: 5000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("two words\n");
|
||||
});
|
||||
});
|
||||
@@ -60,17 +60,17 @@ export function createSshCommandManagedRuntimeRunner(input: {
|
||||
const cwd = commandInput.cwd?.trim() || defaultCwd;
|
||||
const envEntries = Object.entries(commandInput.env ?? {})
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
|
||||
for (const [key] of envEntries) {
|
||||
if (!isValidShellEnvKey(key)) {
|
||||
throw new Error(`Invalid SSH environment variable key: ${key}`);
|
||||
}
|
||||
}
|
||||
const envPrefix = envEntries.length > 0
|
||||
? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
|
||||
? `env ${envEntries.map(([key, value]) => shellQuote(`${key}=${value}`)).join(" ")} `
|
||||
: "";
|
||||
const exportPrefix = envEntries.length > 0
|
||||
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
|
||||
: "";
|
||||
const commandScript = command === "sh" || command === "bash"
|
||||
? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string"
|
||||
? `${exportPrefix}${args[1]}`
|
||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
|
||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
||||
// Keep sh -c's command name and positional arguments intact. Flattening
|
||||
// its script loses $0/$1/... and breaks native runner launch/identity.
|
||||
const commandScript = `exec ${envPrefix}${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
||||
const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`;
|
||||
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user