diff --git a/src/lib/deploy.ts b/src/lib/deploy.ts index 2cddce9a5e7..f30c17661d4 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy.ts @@ -59,8 +59,8 @@ export interface DeployExecutionOptions { getCredential: (key: string) => string | null; validateName: (value: string, label: string) => string; shellQuote: (value: string) => string; - run: (command: string, opts?: { ignoreError?: boolean }) => void; - runInteractive: (command: string) => void; + run: (command: readonly string[], opts?: { ignoreError?: boolean }) => void; + runInteractive: (command: readonly string[]) => void; execFileSync: (file: string, args: string[], opts?: ExecLikeOptions) => string; spawnSync: (file: string, args: string[], opts?: ExecLikeOptions) => void; log: (message?: string) => void; @@ -264,7 +264,6 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise } const name = validateName(instanceName, "instance name"); - const qname = shellQuote(name); const gpu = env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp") .trim() @@ -324,12 +323,12 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise if (!exists) { log(` Creating Brev instance '${name}' (${gpu}, provider=${brevProvider})...`); - run(`brev create ${qname} --type ${shellQuote(gpu)} --provider ${shellQuote(brevProvider)}`); + run(["brev", "create", name, "--type", gpu, "--provider", brevProvider]); } else { log(` Brev instance '${name}' already exists.`); } - run("brev refresh", { ignoreError: true }); + run(["brev", "refresh"], { ignoreError: true }); stdoutWrite(" Waiting for Brev instance readiness "); for (let i = 0; i < 60; i++) { @@ -411,10 +410,24 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise const remoteDir = `${remoteHome}/nemoclaw`; log(" Syncing NemoClaw to VM..."); - run(`ssh ${sshOpts} ${qname} 'mkdir -p ${shellQuote(remoteDir)}'`); - run( - `rsync -az --delete --exclude node_modules --exclude .git --exclude dist --exclude .venv -e "ssh ${sshOpts}" "${rootDir}/" ${qname}:${shellQuote(`${remoteDir}/`)}`, - ); + run(["ssh", ...sshArgs, name, `mkdir -p ${shellQuote(remoteDir)}`]); + run([ + "rsync", + "-az", + "--delete", + "--exclude", + "node_modules", + "--exclude", + ".git", + "--exclude", + "dist", + "--exclude", + ".venv", + "-e", + `ssh ${sshOpts}`, + `${rootDir}/`, + `${name}:${remoteDir}/`, + ]); const envLines = buildDeployEnvLines({ env, @@ -427,8 +440,8 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise const envTmp = path.join(envDir, "env"); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { - run(`scp -q ${sshOpts} ${shellQuote(envTmp)} ${qname}:${shellQuote(`${remoteDir}/.env`)}`); - run(`ssh -q ${sshOpts} ${qname} 'chmod 600 ${shellQuote(`${remoteDir}/.env`)}'`); + run(["scp", "-q", ...sshArgs, envTmp, `${name}:${remoteDir}/.env`]); + run(["ssh", "-q", ...sshArgs, name, `chmod 600 ${shellQuote(`${remoteDir}/.env`)}`]); } finally { try { fs.unlinkSync(envTmp); @@ -443,9 +456,13 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise } log(" Running setup..."); - runInteractive( - `ssh -t ${sshOpts} ${qname} 'cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software'`, - ); + runInteractive([ + "ssh", + "-t", + ...sshArgs, + name, + `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software`, + ]); if ( !skipStartServices && @@ -454,9 +471,12 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise credentials.SLACK_BOT_TOKEN) ) { log(" Starting services..."); - run( - `ssh ${sshOpts} ${qname} 'cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/start-services.sh'`, - ); + run([ + "ssh", + ...sshArgs, + name, + `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/start-services.sh`, + ]); } if (skipStartServices) { @@ -474,9 +494,13 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise log(""); log(" Connecting to sandbox..."); log(""); - runInteractive( - `ssh -t ${sshOpts} ${qname} 'cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && openshell sandbox connect ${shellQuote(sandboxName)}'`, - ); + runInteractive([ + "ssh", + "-t", + ...sshArgs, + name, + `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && openshell sandbox connect ${shellQuote(sandboxName)}`, + ]); } finally { fs.rmSync(khDir, { recursive: true, force: true }); } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c8f01d46da4..83048b2b9f0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -26,7 +26,7 @@ const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT", * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const runner: typeof import("./runner") = require("./runner"); -const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote, validateName } = runner; +const { ROOT, SCRIPTS, redact, run, runShell, runCapture, runFile, shellQuote, validateName } = runner; const errnoUtils: typeof import("./errno") = require("./errno"); const { isErrnoException } = errnoUtils; @@ -233,7 +233,7 @@ const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; function verifyGatewayContainerRunning() { const containerName = `openshell-cluster-${GATEWAY_NAME}`; const result = run( - `docker inspect --type container --format '{{.State.Running}}' ${containerName}`, + ["docker", "inspect", "--type", "container", "--format", "{{.State.Running}}", containerName], { ignoreError: true, suppressOutput: true }, ); if (result.status === 0 && String(result.stdout || "").trim() === "true") { @@ -1912,11 +1912,7 @@ function destroyGateway() { } // openshell gateway destroy doesn't remove Docker volumes, which leaves // corrupted cluster state that breaks the next gateway start. Clean them up. - // Shell required: pipe (|), && chaining, || fallback. - run( - `docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm || true`, - { ignoreError: true }, - ); + removeGatewayClusterVolumes(); } function getGatewayClusterContainerState(): string { @@ -1966,6 +1962,24 @@ function buildGatewayClusterExecArgv(script: string): string[] { return ["docker", "exec", getGatewayClusterContainerName(), "sh", "-lc", script]; } +function getGatewayClusterVolumeNames(): string[] { + return runCapture(["docker", "volume", "ls", "-q", "--filter", `name=${getGatewayClusterContainerName()}`], { + ignoreError: true, + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function removeGatewayClusterVolumes(opts: { suppressOutput?: boolean } = {}): void { + const names = getGatewayClusterVolumeNames(); + if (names.length === 0) return; + run(["docker", "volume", "rm", ...names], { + ignoreError: true, + ...(opts.suppressOutput ? { suppressOutput: true } : {}), + }); +} + function hostCommandExists(commandName: string): boolean { return !!runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { ignoreError: true, @@ -2556,10 +2570,7 @@ async function preflight(): Promise> { suppressOutput: true, }); if (postInspectResult.status !== 0) { - run( - `docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm 2>/dev/null || true`, - { ignoreError: true, suppressOutput: true }, - ); + removeGatewayClusterVolumes({ suppressOutput: true }); registry.clearAll(); console.log(" ✓ Orphaned gateway container removed"); } else { @@ -2597,7 +2608,7 @@ async function preflight(): Promise> { console.log( ` Cleaning up orphaned SSH port-forward on port ${port} (PID ${portCheck.pid})...`, ); - run(`kill ${portCheck.pid} 2>/dev/null || true`, { ignoreError: true }); + run(["kill", String(portCheck.pid)], { ignoreError: true }); sleep(1); portCheck = await checkPortAvailable(port); if (portCheck.ok) { @@ -4621,7 +4632,7 @@ async function setupNim(gpu: ReturnType): Promise<{ // because WSL2 relays IPv4-only sockets to the Windows host. // Shell required: backgrounding (&), env var prefix, output redirection. const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} `; - run(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); + runShell(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); sleep(2); if (!isWsl()) printOllamaExposureWarning(); } @@ -4702,11 +4713,11 @@ async function setupNim(gpu: ReturnType): Promise<{ run(["brew", "install", "ollama"], { ignoreError: true }); } else { console.log(" Installing Ollama via official installer..."); - run("set -o pipefail; curl -fsSL https://ollama.com/install.sh | sh"); + runShell("set -o pipefail; curl -fsSL https://ollama.com/install.sh | sh"); } console.log(" Starting Ollama..."); // Shell required: backgrounding (&), env var prefix, output redirection. - run(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { + runShell(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { ignoreError: true, }); sleep(2); diff --git a/src/lib/runner-argv.test.ts b/src/lib/runner-argv.test.ts index 41abda367a3..bb05b789f7b 100644 --- a/src/lib/runner-argv.test.ts +++ b/src/lib/runner-argv.test.ts @@ -46,9 +46,10 @@ describe("run with argv array", () => { expect(result).toContain("rm"); }); - it("still works with string commands (legacy path)", () => { - const result = runner.run("echo hello", { suppressOutput: true }); - expect(result.status).toBe(0); + it("rejects string commands", () => { + expect(() => runner.run("echo hello", { suppressOutput: true })).toThrow( + /argv array instead/, + ); }); it("surfaces ENOENT error for missing executables", () => { @@ -62,6 +63,39 @@ describe("run with argv array", () => { }); }); +describe("runShell", () => { + it("runs an explicit shell command string", () => { + const result = runner.runShell("echo hello", { suppressOutput: true }); + expect(result.status).toBe(0); + }); +}); + +describe("runInteractive with argv array", () => { + it("executes an interactive argv command", () => { + const result = runner.runInteractive(["echo", "hello"], { suppressOutput: true }); + expect(result.status).toBe(0); + }); + + it("rejects string commands", () => { + expect(() => runner.runInteractive("echo hello", { suppressOutput: true })).toThrow( + /argv array instead/, + ); + }); + + it("rejects shell: true to prevent security bypass", () => { + expect(() => runner.runInteractive(["echo", "hello"], { shell: true })).toThrow( + /shell option is forbidden/, + ); + }); +}); + +describe("runInteractiveShell", () => { + it("runs an explicit interactive shell command string", () => { + const result = runner.runInteractiveShell("echo hello", { suppressOutput: true }); + expect(result.status).toBe(0); + }); +}); + describe("runCapture with argv array", () => { it("captures stdout from a simple command", () => { const output = runner.runCapture(["echo", "hello world"]); diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 091794f0b9b..7fc4bd29246 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -80,20 +80,24 @@ function spawnAndHandle( } /** - * Run a command, streaming stdout/stderr (redacted) to the terminal. + * Run a program directly with argv-style arguments, bypassing shell parsing. * Exits the process on failure unless opts.ignoreError is true. * - * Accepts two forms: - * run("bash -c string") — legacy: passes the string to bash for interpretation - * run(["docker", "rm", name]) — safe: calls spawnSync(exe, args) with no shell - * - * When an argv array is passed, the shell option is forbidden to prevent - * callers from accidentally re-enabling shell interpretation. + * Shell-string execution is intentionally unsupported here. If a caller truly + * needs shell parsing, it must opt in explicitly via runShell(). */ -function run(cmd: string | readonly string[], opts: RunnerOptions = {}): SpawnResult { - if (Array.isArray(cmd)) { - return runArrayCmd(cmd, opts); +function run(cmd: readonly string[], opts: RunnerOptions = {}): SpawnResult { + if (!Array.isArray(cmd)) { + throw new Error("run no longer accepts shell strings; pass an argv array instead"); } + return runArrayCmd(cmd, opts); +} + +/** + * Run an explicit shell command string through bash -c. + * Exits the process on failure unless opts.ignoreError is true. + */ +function runShell(cmd: string, opts: RunnerOptions = {}): SpawnResult { const shellCmd = String(cmd); const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; return spawnAndHandle("bash", ["-c", shellCmd], opts, stdio, shellCmd); @@ -101,11 +105,16 @@ function run(cmd: string | readonly string[], opts: RunnerOptions = {}): SpawnRe /** * Internal: execute an argv array via spawnSync with no shell. - * Shared by run() and kept separate for clarity. + * Shared by run() and runInteractive() and kept separate for clarity. */ -function runArrayCmd(cmd: readonly string[], opts: RunnerOptions = {}): SpawnResult { +function runArrayCmd( + cmd: readonly string[], + opts: RunnerOptions = {}, + defaultStdio: RunnerOptions["stdio"] = ["ignore", "pipe", "pipe"], + callerName = "run", +): SpawnResult { if (cmd.length === 0) { - throw new Error("run: argv array must not be empty"); + throw new Error(`${callerName}: argv array must not be empty`); } const exe = cmd[0]; @@ -114,10 +123,10 @@ function runArrayCmd(cmd: readonly string[], opts: RunnerOptions = {}): SpawnRes // Guard: re-enabling shell interpretation defeats the purpose of argv arrays. if (spawnOpts.shell) { - throw new Error("run: shell option is forbidden when passing an argv array"); + throw new Error(`${callerName}: shell option is forbidden when passing an argv array`); } - const stdio = stdioCfg ?? ["ignore", "pipe", "pipe"]; + const stdio = stdioCfg ?? defaultStdio; const result = spawnSync(exe, args, { ...spawnOpts, @@ -145,10 +154,21 @@ function runArrayCmd(cmd: readonly string[], opts: RunnerOptions = {}): SpawnRes } /** - * Run a shell command interactively (stdin inherited) while capturing and redacting stdout/stderr. + * Run a program directly with argv-style arguments while inheriting stdin. + * Exits the process on failure unless opts.ignoreError is true. + */ +function runInteractive(cmd: readonly string[], opts: RunnerOptions = {}): SpawnResult { + if (!Array.isArray(cmd)) { + throw new Error("runInteractive no longer accepts shell strings; pass an argv array instead"); + } + return runArrayCmd(cmd, opts, ["inherit", "pipe", "pipe"], "runInteractive"); +} + +/** + * Run an explicit shell command string interactively (stdin inherited). * Exits the process on failure unless opts.ignoreError is true. */ -function runInteractive(cmd: string, opts: RunnerOptions = {}): SpawnResult { +function runInteractiveShell(cmd: string, opts: RunnerOptions = {}): SpawnResult { const stdio = opts.stdio ?? ["inherit", "pipe", "pipe"]; return spawnAndHandle("bash", ["-c", cmd], opts, stdio, cmd); } @@ -259,9 +279,11 @@ export { SCRIPTS, redact, run, + runShell, runCapture, runFile, runInteractive, + runInteractiveShell, shellQuote, validateName, }; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index f82b3ecfc55..188e46dde05 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -167,16 +167,25 @@ function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { }); } +function removeGatewayClusterVolumes(): void { + const prefix = `openshell-cluster-${NEMOCLAW_GATEWAY_NAME}`; + const names = _runCapture(["docker", "volume", "ls", "-q", "--filter", `name=${prefix}`], { + ignoreError: true, + }) + .split(/\r?\n/) + .map((line: string) => line.trim()) + .filter((line: string) => line.startsWith(prefix)); + if (names.length === 0) return; + run(["docker", "volume", "rm", ...names], { ignoreError: true }); +} + function cleanupGatewayAfterLastSandbox() { runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { ignoreError: true, stdio: ["ignore", "ignore", "ignore"], }); runOpenshell(["gateway", "destroy", "-g", NEMOCLAW_GATEWAY_NAME], { ignoreError: true }); - run( - `docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | xargs docker volume rm || true`, - { ignoreError: true }, - ); + removeGatewayClusterVolumes(); } function hasNoLiveSandboxes() { diff --git a/test/cli.test.ts b/test/cli.test.ts index afe52599ef2..832b0d38126 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -613,7 +613,7 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); fs.writeFileSync( - path.join(localBin, "bash"), + path.join(localBin, "docker"), [ "#!/bin/sh", `log_file=${JSON.stringify(bashLog)}`, @@ -633,7 +633,7 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(openshellLog, "utf8")).toContain("NAME STATUS"); expect(fs.readFileSync(openshellLog, "utf8")).toContain("forward stop 18789"); expect(fs.readFileSync(openshellLog, "utf8")).toContain("gateway destroy -g nemoclaw"); - expect(fs.readFileSync(bashLog, "utf8")).toContain("docker volume ls -q --filter"); + expect(fs.readFileSync(bashLog, "utf8")).toContain("volume ls -q --filter"); }); it("keeps the gateway runtime when other sandboxes still exist", () => { @@ -683,7 +683,7 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); fs.writeFileSync( - path.join(localBin, "bash"), + path.join(localBin, "docker"), [ "#!/bin/sh", `log_file=${JSON.stringify(bashLog)}`, @@ -703,7 +703,7 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); if (fs.existsSync(bashLog)) { - expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("volume ls -q --filter"); } }); @@ -747,7 +747,7 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); fs.writeFileSync( - path.join(localBin, "bash"), + path.join(localBin, "docker"), [ "#!/bin/sh", `log_file=${JSON.stringify(bashLog)}`, @@ -768,7 +768,7 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); if (fs.existsSync(bashLog)) { - expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("volume ls -q --filter"); } }); @@ -873,7 +873,7 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); fs.writeFileSync( - path.join(localBin, "bash"), + path.join(localBin, "docker"), [ "#!/bin/sh", `log_file=${JSON.stringify(bashLog)}`, @@ -899,7 +899,7 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); expect(fs.readFileSync(openshellLog, "utf8")).toContain("forward stop 18789"); expect(fs.readFileSync(openshellLog, "utf8")).toContain("gateway destroy -g nemoclaw"); - expect(fs.readFileSync(bashLog, "utf8")).toContain("docker volume ls -q --filter"); + expect(fs.readFileSync(bashLog, "utf8")).toContain("volume ls -q --filter"); }); it("deletes messaging providers when destroying a sandbox", () => { @@ -941,7 +941,7 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); fs.writeFileSync( - path.join(localBin, "bash"), + path.join(localBin, "docker"), [ "#!/bin/sh", `log_file=${JSON.stringify(bashLog)}`, diff --git a/test/gateway-cleanup.test.ts b/test/gateway-cleanup.test.ts index cf29135ad17..fefb3683a73 100644 --- a/test/gateway-cleanup.test.ts +++ b/test/gateway-cleanup.test.ts @@ -16,7 +16,9 @@ const ROOT = path.resolve(import.meta.dirname, ".."); describe("gateway cleanup: Docker volumes removed on failure (#17)", () => { it("onboard.js: destroyGateway() removes Docker volumes", () => { const content = fs.readFileSync(path.join(ROOT, "src/lib/onboard.ts"), "utf-8"); - expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); + expect(content).toContain("removeGatewayClusterVolumes"); + expect(content).toContain('"docker", "volume", "ls", "-q"'); + expect(content).toContain("openshell-cluster"); }); it("onboard.js: volume cleanup runs on gateway start failure", () => { diff --git a/test/gateway-liveness-probe.test.ts b/test/gateway-liveness-probe.test.ts index 390c04f6fdb..41ac125a22f 100644 --- a/test/gateway-liveness-probe.test.ts +++ b/test/gateway-liveness-probe.test.ts @@ -20,7 +20,7 @@ describe("gateway liveness probe (#2020)", () => { it("verifyGatewayContainerRunning() helper exists and checks Docker state", () => { expect(content).toContain("function verifyGatewayContainerRunning()"); // Must use docker inspect to probe container state - expect(content).toContain("docker inspect --type container"); + expect(content).toMatch(/"docker",\s*"inspect",\s*"--type",\s*"container"/); // Must check .State.Running, not just container existence expect(content).toContain("{{.State.Running}}"); }); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index dd7c14f969b..b6aada92801 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -3308,6 +3308,9 @@ runner.runCapture = (command) => { runner.run = (command, opts) => { runCommands.push(typeof command === "string" ? command : command.join(" ")); }; +runner.runShell = (command, opts) => { + runCommands.push(command); +}; registry.updateSandbox = (_name, update) => updates.push(update); // Force platform to linux for this test diff --git a/test/runner.test.ts b/test/runner.test.ts index 637af4106ad..9c231fff045 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -48,9 +48,9 @@ function requireCall(calls: SpawnCall[], index: number): SpawnCall { describe("runner helpers", () => { it("does not let child commands consume installer stdin", () => { const script = ` - const { run } = require(${JSON.stringify(runnerPath)}); + const { runShell } = require(${JSON.stringify(runnerPath)}); process.stdin.setEncoding("utf8"); - run("cat >/dev/null || true"); + runShell("cat >/dev/null || true"); process.stdin.once("data", (chunk) => { process.stdout.write(chunk); }); @@ -75,8 +75,8 @@ describe("runner helpers", () => { try { delete require.cache[require.resolve(runnerPath)]; const { run, runInteractive } = require(runnerPath); - run("echo noninteractive"); - runInteractive("echo interactive"); + run(["echo", "noninteractive"]); + runInteractive(["echo", "interactive"]); } finally { childProcess.spawnSync = originalSpawnSync; delete require.cache[require.resolve(runnerPath)]; @@ -177,7 +177,7 @@ describe("runner env merging", () => { delete require.cache[require.resolve(runnerPath)]; const { run } = require(runnerPath); process.env.PATH = "/usr/local/bin:/usr/bin"; - run("echo test", { + run(["echo", "test"], { env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" }, }); } finally { @@ -504,7 +504,7 @@ describe("regression guards", () => { try { delete require.cache[require.resolve(runnerPath)]; const { run } = require(runnerPath); - expect(() => run("echo fail")).toThrow("exit:1"); + expect(() => run(["echo", "fail"])).toThrow("exit:1"); expect(stdoutSpy).toHaveBeenCalledWith("token ghp_********************\n"); expect(stderrSpy).toHaveBeenCalledWith('export SERVICE_KEY="supe*****************"\n'); expect(errorSpy).toHaveBeenCalledWith(" Command failed (exit 1): echo fail"); @@ -534,7 +534,7 @@ describe("regression guards", () => { try { delete require.cache[require.resolve(runnerPath)]; const { runInteractive } = require(runnerPath); - runInteractive("echo interactive"); + runInteractive(["echo", "interactive"]); const firstCall = requireCall(calls, 0); expect(firstCall[2]?.stdio).toEqual(["inherit", "pipe", "pipe"]); expect(stdoutSpy).toHaveBeenCalledWith("visit https://****:****@example.com/?token=****\n"); @@ -834,10 +834,10 @@ describe("regression guards", () => { "utf-8", ); expect(src).not.toContain("--exclude src"); - expect(src).toContain('"${rootDir}/"'); - expect(src).toContain("--exclude dist"); + expect(src).toContain("`${rootDir}/`"); + expect(src).toMatch(/"--exclude",\s*"dist"/); expect(src).toContain('const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp")'); - expect(src).toContain("--provider ${shellQuote(brevProvider)}"); + expect(src).toContain('"--provider", brevProvider'); }); it("deploy supports test-friendly non-interactive skip flags", () => {