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:
DottaandPaperclip committed 2026-10-12 15:31:45 -05:00
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");
});
});
+9 -9
View File
@@ -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 {