fix(exec): prevent shell startup files from overriding daemon env

Carries forward the focused shell startup suppression fix from #40200 by NewdlDewdl.

- launch bash, zsh, and fish exec shells with startup files suppressed
- preserve fish/bash/sh PATH fallback, non-interactive shell fallback, and Windows PowerShell behavior
- add regression coverage for the affected shell arg paths

Fixes #40179.
Carries forward #40200.
Thanks @NewdlDewdl.
This commit is contained in:
openclaw-clownfish[bot]
2026-04-29 02:01:07 -07:00
committed by GitHub
parent d35e6f79e1
commit ea9f17256a
3 changed files with 58 additions and 12 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Agents/model config: resolve per-model extra params through canonical model keys while preserving legacy double-prefixed fallback entries, so provider-prefixed model ids such as `openrouter/auto` keep their configured runtime params. (#44319) Thanks @HenryXiaoYang.
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
- Control UI: keep Agents Overview and config-form select dropdowns on their configured value after options render while preserving inherited agent model placeholders. Fixes #40352; carries forward #52948. Thanks @xiaoquanidea.
- Agents/exec: launch zsh, bash, and fish host exec shells with startup files suppressed while preserving existing PATH fallbacks, so daemon env is not overridden by shell startup files. Carries forward #40200; fixes #40179. Thanks @NewdlDewdl.
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc.
- Active Memory: register the prompt-build hook with the configured recall timeout plus setup grace instead of the 150s maximum budget, so default memory recall cannot delay turn startup for multiple minutes. Thanks @vincentkoc.

View File

@@ -46,9 +46,10 @@ describe("getShellConfig", () => {
if (isWin) {
it("uses PowerShell on Windows", () => {
const { shell } = getShellConfig();
const { shell, args } = getShellConfig();
const normalized = shell.toLowerCase();
expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true);
expect(args).toEqual(["-NoProfile", "-NonInteractive", "-Command"]);
});
return;
}
@@ -56,28 +57,48 @@ describe("getShellConfig", () => {
it("prefers bash when fish is default and bash is on PATH", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]);
process.env.PATH = binDir;
const { shell } = getShellConfig();
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 } = getShellConfig();
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 } = getShellConfig();
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 } = getShellConfig();
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", () => {
@@ -93,15 +114,26 @@ describe("getShellConfig", () => {
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
process.env.SHELL = "/sbin/nologin";
process.env.PATH = binDir;
const { shell } = getShellConfig();
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 } = getShellConfig();
const { shell, args } = getShellConfig();
expect(shell).toBe("sh");
expect(args).toEqual(["-c"]);
});
});

View File

@@ -51,6 +51,19 @@ function isNonInteractiveShell(shellPath: string): boolean {
return NON_INTERACTIVE_SHELLS.has(path.basename(shellPath));
}
function getPosixShellArgs(shellPath: string): string[] {
switch (path.basename(shellPath)) {
case "bash":
return ["--noprofile", "--norc", "-c"];
case "zsh":
return ["-f", "-c"];
case "fish":
return ["--no-config", "-c"];
default:
return ["-c"];
}
}
export function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
// Use PowerShell instead of cmd.exe on Windows.
@@ -71,20 +84,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
if (shellName === "fish") {
const bash = resolveShellFromPath("bash");
if (bash) {
return { shell: bash, args: ["-c"] };
return { shell: bash, args: getPosixShellArgs(bash) };
}
const sh = resolveShellFromPath("sh");
if (sh) {
return { shell: sh, args: ["-c"] };
return { shell: sh, args: getPosixShellArgs(sh) };
}
}
if (envShell) {
return { shell: envShell, args: ["-c"] };
return { shell: envShell, args: getPosixShellArgs(envShell) };
}
// 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"] };
const shell = resolveShellFromPath("sh") ?? resolveShellFromPath("bash") ?? "sh";
return { shell, args: getPosixShellArgs(shell) };
}
export function resolveShellFromPath(name: string): string | undefined {