From 7b414d8c0b9f2d2770443ec1eb39c3f7ffb7f804 Mon Sep 17 00:00:00 2001 From: Sk7n4k3d Date: Mon, 20 Apr 2026 13:17:17 +0200 Subject: [PATCH] shell: fall back to sh when SHELL is /usr/bin/false or nologin --- src/agents/shell-utils.test.ts | 24 ++++++++++++++++++++++++ src/agents/shell-utils.ts | 27 +++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 9716fb73c8d..e03867c2768 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -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", () => { diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 1baf28b4fb9..511b8044ecf 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -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;