diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bde1c3cb0f..873e82c1a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 28909f66a8e..6a1926016b6 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -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"]); }); }); diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 511b8044ecf..b35eb63ccd2 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -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 {