Files
openclaw/src/agents/shell-utils.test.ts

552 lines
19 KiB
TypeScript

// Verifies shell selection, PATH lookup, and platform-specific shell helpers.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
buildShellCommandInvocation,
createStreamingBinaryOutputSanitizer,
detectRuntimeShell,
getBashShellConfig,
getBashShellEnv,
getShellConfig,
sanitizeBinaryOutput,
} from "./shell-utils.js";
const isWin = process.platform === "win32";
describe("sanitizeBinaryOutput", () => {
it("removes ANSI wrappers while retaining printable output", () => {
expect(sanitizeBinaryOutput("\u001b[31mred\u001b[0m")).toBe("red");
expect(sanitizeBinaryOutput("\u009b31mred\u009b0m")).toBe("red");
});
it("preserves unterminated OSC and pending CSI text at chunk boundaries", () => {
expect(sanitizeBinaryOutput("\u001b]unterminated")).toBe("\\x1b]unterminated");
expect(sanitizeBinaryOutput("before\u009b31;")).toBe("before\\x9b31;");
expect(sanitizeBinaryOutput("\u001b[") + sanitizeBinaryOutput("Ksecret")).toBe("\\x1b[Ksecret");
});
it("applies caller control policy while CSI remains active", () => {
// SOH executes independently, then "d" terminates CSI as its final byte.
expect(sanitizeBinaryOutput("\u009b\u0001done")).toBe("\\x01one");
expect(sanitizeBinaryOutput("\u009b31\u0018done")).toBe("done");
expect(sanitizeBinaryOutput("\u001b[31\u001adone")).toBe("done");
});
it("escapes residual C0, DEL, and C1 controls", () => {
expect(sanitizeBinaryOutput("a\u0000\u0007\u007f\u0080b\t\n")).toBe(
"a\\x00\\x07\\x7f\\x80b\t\n",
);
});
});
describe("createStreamingBinaryOutputSanitizer", () => {
it("carries ANSI state across process-output chunks", () => {
const sanitize = createStreamingBinaryOutputSanitizer();
expect(sanitize("A\u001b]0;title")).toBe("A");
expect(sanitize("\u0007B\u001b[31")).toBe("B");
expect(sanitize("mC")).toBe("C");
});
});
function createTempCommandDir(
tempDirs: string[],
files: Array<{ name: string; executable?: boolean }>,
): string {
// Temporary PATH entries model available shell binaries and permissions.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-"));
tempDirs.push(dir);
for (const file of files) {
const filePath = path.join(dir, file.name);
fs.writeFileSync(filePath, "");
fs.chmodSync(filePath, file.executable === false ? 0o644 : 0o755);
}
return dir;
}
type ShellConfig = ReturnType<typeof getBashShellConfig>;
describe("getShellConfig", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
const tempDirs: string[] = [];
beforeEach(() => {
envSnapshot = captureEnv(["SHELL", "PATH"]);
if (!isWin) {
process.env.SHELL = "/usr/bin/fish";
}
});
afterEach(() => {
envSnapshot.restore();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
if (isWin) {
it("uses PowerShell on Windows", () => {
const { shell, args } = getShellConfig();
const normalized = shell.toLowerCase();
if (normalized.includes("powershell")) {
expect(normalized).toContain("powershell");
} else {
expect(normalized).toContain("pwsh");
}
expect(args).toEqual(["-NoProfile", "-NonInteractive", "-Command"]);
});
return;
}
it("prefers bash when fish is default and bash is on PATH", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]);
process.env.PATH = binDir;
const { shell, args } = getShellConfig();
expect(shell).toBe(path.join(binDir, "bash"));
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
});
it("falls back to sh when fish is default and bash is missing", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
process.env.PATH = binDir;
const { shell, args } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
expect(args).toEqual(["-c"]);
});
it("falls back to env shell when fish is default and no sh is available", () => {
process.env.PATH = "";
const { shell, args } = getShellConfig();
expect(shell).toBe("/usr/bin/fish");
expect(args).toEqual(["--no-config", "-c"]);
});
it("uses startup-suppressed args for zsh env shells", () => {
process.env.SHELL = "/bin/zsh";
process.env.PATH = "";
const { shell, args } = getShellConfig();
expect(shell).toBe("/bin/zsh");
expect(args).toEqual(["-f", "-c"]);
});
it("uses startup-suppressed args for bash env shells", () => {
process.env.SHELL = "/bin/bash";
process.env.PATH = "";
const { shell, args } = getShellConfig();
expect(shell).toBe("/bin/bash");
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
});
it("uses sh when SHELL is unset", () => {
delete process.env.SHELL;
process.env.PATH = "";
const { shell, args } = getShellConfig();
expect(shell).toBe("sh");
expect(args).toEqual(["-c"]);
});
it("uses an explicit custom shell path through the same resolver", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "zsh" }]);
const shellPath = path.join(binDir, "zsh");
expect(getShellConfig(shellPath)).toEqual({
shell: shellPath,
args: ["-f", "-c"],
commandTransport: "argv",
});
});
it("rejects a missing explicit custom shell path", () => {
expect(() => getShellConfig(path.join(os.tmpdir(), "missing-openclaw-shell"))).toThrow(
"Custom shell path not found",
);
});
it("falls back to sh on PATH when SHELL is /usr/bin/false", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
process.env.SHELL = "/usr/bin/false";
process.env.PATH = binDir;
const { shell, args } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
expect(args).toEqual(["-c"]);
});
it("falls back to sh on PATH when SHELL is /sbin/nologin", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
process.env.SHELL = "/sbin/nologin";
process.env.PATH = binDir;
const { shell, args } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
expect(args).toEqual(["-c"]);
});
it("falls back to startup-suppressed bash on PATH when SHELL is a placeholder", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]);
process.env.SHELL = "/usr/bin/false";
process.env.PATH = binDir;
const { shell, args } = getShellConfig();
expect(shell).toBe(path.join(binDir, "bash"));
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
});
it("falls back to bare sh when SHELL is a placeholder and no sh is on PATH", () => {
process.env.SHELL = "/usr/bin/false";
process.env.PATH = "";
const { shell, args } = getShellConfig();
expect(shell).toBe("sh");
expect(args).toEqual(["-c"]);
});
});
describe("getBashShellConfig", () => {
const tempDirs: string[] = [];
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["ProgramFiles", "ProgramFiles(x86)", "PATH", "Path"]);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
});
afterEach(() => {
vi.restoreAllMocks();
envSnapshot.restore();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("finds Git Bash under ProgramFiles", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-git-bash-"));
tempDirs.push(programFiles);
const bashDir = path.join(programFiles, "Git", "bin");
fs.mkdirSync(bashDir, { recursive: true });
const bashPath = path.join(bashDir, "bash.exe");
fs.writeFileSync(bashPath, "");
process.env.ProgramFiles = programFiles;
process.env.PATH = "";
expect(getBashShellConfig()).toEqual({
shell: bashPath,
args: ["-c"],
commandTransport: "argv",
});
});
it("prepends coreutils for a standard Git for Windows install", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-git-bash-env-"));
tempDirs.push(programFiles);
const gitRoot = path.join(programFiles, "Git");
const bashPath = path.join(gitRoot, "bin", "bash.exe");
const usrBin = path.join(gitRoot, "usr", "bin");
fs.mkdirSync(path.dirname(bashPath), { recursive: true });
fs.mkdirSync(path.join(gitRoot, "cmd"), { recursive: true });
fs.mkdirSync(usrBin, { recursive: true });
fs.writeFileSync(bashPath, "");
fs.writeFileSync(path.join(gitRoot, "cmd", "git.exe"), "");
process.env.PATH = path.join(programFiles, "OtherBin");
const env = getBashShellEnv(bashPath);
expect(env.PATH?.split(path.delimiter)[0]).toBe(usrBin);
expect(env.PATH).toContain(process.env.PATH);
expect(Object.keys(env).filter((key) => key.toLowerCase() === "path")).toEqual(["PATH"]);
});
it("recognizes portable Git for Windows installs", () => {
const gitRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-portable-git-"));
tempDirs.push(gitRoot);
const bashPath = path.join(gitRoot, "usr", "bin", "bash.exe");
const usrBin = path.dirname(bashPath);
fs.mkdirSync(usrBin, { recursive: true });
fs.mkdirSync(path.join(gitRoot, "cmd"), { recursive: true });
fs.writeFileSync(bashPath, "");
fs.writeFileSync(path.join(gitRoot, "cmd", "git.exe"), "");
expect(getBashShellEnv(bashPath).PATH?.split(path.delimiter)[0]).toBe(usrBin);
});
it("leaves unrelated MSYS2 installs unchanged", () => {
const msysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-msys2-"));
tempDirs.push(msysRoot);
const bashPath = path.join(msysRoot, "usr", "bin", "bash.exe");
fs.mkdirSync(path.dirname(bashPath), { recursive: true });
fs.writeFileSync(bashPath, "");
process.env.PATH = path.join(msysRoot, "ucrt64", "bin");
const env = getBashShellEnv(bashPath);
expect(env.PATH?.split(path.delimiter)[0]).not.toBe(path.dirname(bashPath));
expect(env.PATH).toContain(process.env.PATH);
});
it.each(["System32", "Sysnative"])(
"uses stdin transport for the legacy %s WSL launcher",
(systemDirectory) => {
const shellPath = `C:\\Windows\\${systemDirectory}\\bash.exe`;
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => String(candidate) === shellPath);
expect(getBashShellConfig(shellPath)).toEqual({
shell: shellPath,
args: ["-s"],
commandTransport: "stdin",
});
},
);
it("builds a stdin invocation for the legacy WSL launcher", () => {
const config: ShellConfig = {
shell: "C:\\Windows\\System32\\bash.exe",
args: ["-s"],
commandTransport: "stdin",
};
expect(buildShellCommandInvocation("printf ready", config)).toEqual({
argv: [config.shell, "-s"],
input: "printf ready",
stdin: "pipe",
});
});
it("builds an argv invocation for regular shells", () => {
const config: ShellConfig = {
shell: "/bin/bash",
args: ["-c"],
commandTransport: "argv",
};
expect(buildShellCommandInvocation("printf ready", config)).toEqual({
argv: [config.shell, "-c", "printf ready"],
stdin: "ignore",
});
});
});
describe("getBashShellEnv", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["PATH", "Path"]);
});
afterEach(() => {
vi.restoreAllMocks();
envSnapshot.restore();
});
it("returns an env object with the OpenClaw bin dir on PATH", () => {
process.env.PATH = "/usr/bin";
const env = getBashShellEnv();
expect(env.PATH).toContain("/usr/bin");
expect(env.PATH).toContain(".openclaw");
});
it("collapses case-insensitive PATH duplicates before Windows spawn", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const env = getBashShellEnv(undefined, { PATH: "/selected", Path: "/discarded" });
expect(Object.keys(env).filter((key) => key.toLowerCase() === "path")).toEqual(["PATH"]);
expect(env.PATH).toContain("/selected");
expect(env.PATH).not.toContain("/discarded");
});
it.runIf(isWin)("passes one canonical PATH entry to a child process", () => {
const result = spawnSync(
process.execPath,
[
"-e",
"process.stdout.write(JSON.stringify(Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')))",
],
{ encoding: "utf8", env: getBashShellEnv() },
);
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toEqual(["PATH"]);
});
});
describe("detectRuntimeShell", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv([
"OPENCLAW_SHELL",
"SHELL",
"POWERSHELL_DISTRIBUTION_CHANNEL",
"BASH_VERSION",
"ZSH_VERSION",
"FISH_VERSION",
"KSH_VERSION",
"NU_VERSION",
"NUSHELL_VERSION",
]);
delete process.env.OPENCLAW_SHELL;
delete process.env.POWERSHELL_DISTRIBUTION_CHANNEL;
delete process.env.BASH_VERSION;
delete process.env.ZSH_VERSION;
delete process.env.FISH_VERSION;
delete process.env.KSH_VERSION;
delete process.env.NU_VERSION;
delete process.env.NUSHELL_VERSION;
});
afterEach(() => {
envSnapshot.restore();
});
if (!isWin) {
it("ignores non-interactive SHELL placeholders and falls through to runtime hints", () => {
process.env.SHELL = "/usr/bin/false";
process.env.BASH_VERSION = "5.2.0";
expect(detectRuntimeShell()).toBe("bash");
});
}
});
describe("getShellConfig on Windows", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
const tempDirs: string[] = [];
beforeEach(() => {
envSnapshot = captureEnv([
"ProgramFiles",
"PROGRAMFILES",
"ProgramW6432",
"SystemRoot",
"WINDIR",
"PATH",
]);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
});
afterEach(() => {
vi.restoreAllMocks();
envSnapshot.restore();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prefers PowerShell 7 in ProgramFiles", () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
tempDirs.push(base);
const pwsh7Dir = path.join(base, "PowerShell", "7");
fs.mkdirSync(pwsh7Dir, { recursive: true });
const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe");
fs.writeFileSync(pwsh7Path, "");
process.env.ProgramFiles = base;
process.env.PATH = "";
delete process.env.ProgramW6432;
delete process.env.SystemRoot;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(pwsh7Path);
});
it("prefers ProgramW6432 PowerShell 7 when ProgramFiles lacks pwsh", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
const programW6432 = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pw6432-"));
tempDirs.push(programFiles, programW6432);
const pwsh7Dir = path.join(programW6432, "PowerShell", "7");
fs.mkdirSync(pwsh7Dir, { recursive: true });
const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe");
fs.writeFileSync(pwsh7Path, "");
process.env.ProgramFiles = programFiles;
process.env.ProgramW6432 = programW6432;
process.env.PATH = "";
delete process.env.SystemRoot;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(pwsh7Path);
});
it("finds pwsh on PATH when not in standard install locations", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-"));
tempDirs.push(programFiles, binDir);
const pwshPath = path.join(binDir, "pwsh");
fs.writeFileSync(pwshPath, "");
fs.chmodSync(pwshPath, 0o755);
process.env.ProgramFiles = programFiles;
process.env.PATH = binDir;
delete process.env.ProgramW6432;
delete process.env.SystemRoot;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(pwshPath);
});
it("finds pwsh.exe on PATH when PowerShell 7 is not in ProgramFiles", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-"));
tempDirs.push(programFiles, binDir);
const pwshPath = path.join(binDir, "pwsh.exe");
fs.writeFileSync(pwshPath, "");
fs.chmodSync(pwshPath, 0o755);
process.env.ProgramFiles = programFiles;
process.env.PATH = binDir;
delete process.env.ProgramW6432;
delete process.env.SystemRoot;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(pwshPath);
});
it("prefers a native pwsh.exe over earlier bare shims, batch wrappers, and PowerShell 5.1", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shim-"));
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-"));
const systemRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-"));
tempDirs.push(programFiles, shimDir, binDir, systemRoot);
const bareShimPath = path.join(shimDir, "pwsh");
const pwshPath = path.join(binDir, "pwsh.exe");
const batchPath = path.join(binDir, "pwsh.cmd");
const powershellDir = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0");
fs.mkdirSync(powershellDir, { recursive: true });
fs.writeFileSync(bareShimPath, "");
fs.writeFileSync(pwshPath, "");
fs.writeFileSync(batchPath, "");
fs.writeFileSync(path.join(powershellDir, "powershell.exe"), "");
fs.chmodSync(bareShimPath, 0o755);
fs.chmodSync(pwshPath, 0o755);
fs.chmodSync(batchPath, 0o755);
process.env.ProgramFiles = programFiles;
process.env.SystemRoot = systemRoot;
process.env.PATH = [shimDir, binDir].join(path.delimiter);
delete process.env.ProgramW6432;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(pwshPath);
});
it("falls back to Windows PowerShell 5.1 path when pwsh is unavailable", () => {
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
const sysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-"));
tempDirs.push(programFiles, sysRoot);
const ps51Dir = path.join(sysRoot, "System32", "WindowsPowerShell", "v1.0");
fs.mkdirSync(ps51Dir, { recursive: true });
const ps51Path = path.join(ps51Dir, "powershell.exe");
fs.writeFileSync(ps51Path, "");
process.env.ProgramFiles = programFiles;
process.env.SystemRoot = sysRoot;
process.env.PATH = "";
delete process.env.ProgramW6432;
delete process.env.WINDIR;
expect(getShellConfig().shell).toBe(ps51Path);
});
});