From 849f06ae31670e8e06cbf733bd647fcffd824e11 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 15:29:01 -0500 Subject: [PATCH] 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 --- .../src/ssh-command-managed.test.ts | 71 +++++++++++++++++++ packages/adapter-utils/src/ssh.ts | 18 ++--- 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 packages/adapter-utils/src/ssh-command-managed.test.ts diff --git a/packages/adapter-utils/src/ssh-command-managed.test.ts b/packages/adapter-utils/src/ssh-command-managed.test.ts new file mode 100644 index 00000000000..12d7c7f7788 --- /dev/null +++ b/packages/adapter-utils/src/ssh-command-managed.test.ts @@ -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(); + // Execute the authored remote command locally, through both subprocess paths. + return { + ...actual, + execFile: vi.fn((file: string, args: string[], options: object, callback: Parameters[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[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"); + }); +}); diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 68e61fc6390..342ca553bd1 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -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 {