shell: fall back to sh when SHELL is /usr/bin/false or nologin

This commit is contained in:
Sk7n4k3d
2026-04-20 13:17:17 +02:00
committed by Peter Steinberger
parent 7b1871b99b
commit 7b414d8c0b
2 changed files with 47 additions and 4 deletions

View File

@@ -74,6 +74,30 @@ describe("getShellConfig", () => {
const { shell } = getShellConfig();
expect(shell).toBe("sh");
});
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 } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
});
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 } = getShellConfig();
expect(shell).toBe("sh");
});
});
describe("resolveShellFromPath", () => {

View File

@@ -38,6 +38,19 @@ export function resolvePowerShellPath(): string {
return "powershell.exe";
}
// Non-interactive placeholder shells that reject "-c"-style invocations.
// macOS LaunchDaemon service users commonly use /usr/bin/false so login sessions
// cannot be opened; honoring SHELL in that case causes every exec to exit 1.
// See https://github.com/openclaw/openclaw/issues/69077.
const NON_INTERACTIVE_SHELLS = new Set(["false", "nologin"]);
function isNonInteractiveShell(shellPath: string): boolean {
if (!shellPath) {
return false;
}
return NON_INTERACTIVE_SHELLS.has(path.basename(shellPath));
}
export function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
// Use PowerShell instead of cmd.exe on Windows.
@@ -51,7 +64,8 @@ export function getShellConfig(): { shell: string; args: string[] } {
};
}
const envShell = process.env.SHELL?.trim();
const rawEnvShell = process.env.SHELL?.trim();
const envShell = rawEnvShell && !isNonInteractiveShell(rawEnvShell) ? rawEnvShell : undefined;
const shellName = envShell ? path.basename(envShell) : "";
// Fish rejects common bashisms used by tools, so prefer bash when detected.
if (shellName === "fish") {
@@ -64,8 +78,13 @@ export function getShellConfig(): { shell: string; args: string[] } {
return { shell: sh, args: ["-c"] };
}
}
const shell = envShell && envShell.length > 0 ? envShell : "sh";
return { shell, args: ["-c"] };
if (envShell) {
return { shell: envShell, args: ["-c"] };
}
// Placeholder SHELL (or unset): prefer a resolved sh/bash on PATH so we do not
// re-invoke the placeholder and get a spurious exitCode=1.
const sh = resolveShellFromPath("sh") ?? resolveShellFromPath("bash");
return { shell: sh ?? "sh", args: ["-c"] };
}
export function resolveShellFromPath(name: string): string | undefined {
@@ -114,7 +133,7 @@ export function detectRuntimeShell(): string | undefined {
}
const envShell = process.env.SHELL?.trim();
if (envShell) {
if (envShell && !isNonInteractiveShell(envShell)) {
const name = normalizeShellName(envShell);
if (name) {
return name;