import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { detectRuntimeShell, getShellConfig, resolvePowerShellPath, resolveShellFromPath, } from "./shell-utils.js"; const isWin = process.platform === "win32"; function createTempCommandDir( tempDirs: string[], files: Array<{ name: string; executable?: boolean }>, ): string { 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; } describe("getShellConfig", () => { let envSnapshot: ReturnType; 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("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("resolveShellFromPath", () => { let envSnapshot: ReturnType; const tempDirs: string[] = []; beforeEach(() => { envSnapshot = captureEnv(["PATH"]); }); afterEach(() => { envSnapshot.restore(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); it("returns undefined when PATH is empty", () => { process.env.PATH = ""; expect(resolveShellFromPath("bash")).toBeUndefined(); }); if (isWin) { return; } it("returns the first executable match from PATH", () => { const notExecutable = createTempCommandDir(tempDirs, [{ name: "bash", executable: false }]); const executable = createTempCommandDir(tempDirs, [{ name: "bash", executable: true }]); process.env.PATH = [notExecutable, executable].join(path.delimiter); expect(resolveShellFromPath("bash")).toBe(path.join(executable, "bash")); }); it("returns undefined when command does not exist", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-empty-")); tempDirs.push(dir); process.env.PATH = dir; expect(resolveShellFromPath("bash")).toBeUndefined(); }); }); describe("detectRuntimeShell", () => { let envSnapshot: ReturnType; 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("resolvePowerShellPath", () => { let envSnapshot: ReturnType; const tempDirs: string[] = []; beforeEach(() => { envSnapshot = captureEnv([ "ProgramFiles", "PROGRAMFILES", "ProgramW6432", "SystemRoot", "WINDIR", "PATH", ]); }); afterEach(() => { 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(resolvePowerShellPath()).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(resolvePowerShellPath()).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(resolvePowerShellPath()).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(resolvePowerShellPath()).toBe(ps51Path); }); });