Skip to content

Commit ce0273b

Browse files
openclaw-clownfish[bot]vincentkoc
authored andcommitted
fix(exec): prevent shell startup files from overriding daemon env
1 parent 68ba1e7 commit ce0273b

3 files changed

Lines changed: 58 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333
- Docs/Hetzner: clarify that SSH tunnel access requires `AllowTcpForwarding local` before running `ssh -L`, so hardened VPS sshd configs do not block loopback Gateway access. Fixes #54557; carries forward #54564; refs #54954. Thanks @satishkc7, @blackstrype, and @Aftabbs.
3434
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
3535
- 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.
36+
- 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.
3637
- 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.
3738
- 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.
3839
- 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.

src/agents/shell-utils.test.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,38 +46,59 @@ describe("getShellConfig", () => {
4646

4747
if (isWin) {
4848
it("uses PowerShell on Windows", () => {
49-
const { shell } = getShellConfig();
49+
const { shell, args } = getShellConfig();
5050
const normalized = shell.toLowerCase();
5151
expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true);
52+
expect(args).toEqual(["-NoProfile", "-NonInteractive", "-Command"]);
5253
});
5354
return;
5455
}
5556

5657
it("prefers bash when fish is default and bash is on PATH", () => {
5758
const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]);
5859
process.env.PATH = binDir;
59-
const { shell } = getShellConfig();
60+
const { shell, args } = getShellConfig();
6061
expect(shell).toBe(path.join(binDir, "bash"));
62+
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
6163
});
6264

6365
it("falls back to sh when fish is default and bash is missing", () => {
6466
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
6567
process.env.PATH = binDir;
66-
const { shell } = getShellConfig();
68+
const { shell, args } = getShellConfig();
6769
expect(shell).toBe(path.join(binDir, "sh"));
70+
expect(args).toEqual(["-c"]);
6871
});
6972

7073
it("falls back to env shell when fish is default and no sh is available", () => {
7174
process.env.PATH = "";
72-
const { shell } = getShellConfig();
75+
const { shell, args } = getShellConfig();
7376
expect(shell).toBe("/usr/bin/fish");
77+
expect(args).toEqual(["--no-config", "-c"]);
78+
});
79+
80+
it("uses startup-suppressed args for zsh env shells", () => {
81+
process.env.SHELL = "/bin/zsh";
82+
process.env.PATH = "";
83+
const { shell, args } = getShellConfig();
84+
expect(shell).toBe("/bin/zsh");
85+
expect(args).toEqual(["-f", "-c"]);
86+
});
87+
88+
it("uses startup-suppressed args for bash env shells", () => {
89+
process.env.SHELL = "/bin/bash";
90+
process.env.PATH = "";
91+
const { shell, args } = getShellConfig();
92+
expect(shell).toBe("/bin/bash");
93+
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
7494
});
7595

7696
it("uses sh when SHELL is unset", () => {
7797
delete process.env.SHELL;
7898
process.env.PATH = "";
79-
const { shell } = getShellConfig();
99+
const { shell, args } = getShellConfig();
80100
expect(shell).toBe("sh");
101+
expect(args).toEqual(["-c"]);
81102
});
82103

83104
it("falls back to sh on PATH when SHELL is /usr/bin/false", () => {
@@ -93,15 +114,26 @@ describe("getShellConfig", () => {
93114
const binDir = createTempCommandDir(tempDirs, [{ name: "sh" }]);
94115
process.env.SHELL = "/sbin/nologin";
95116
process.env.PATH = binDir;
96-
const { shell } = getShellConfig();
117+
const { shell, args } = getShellConfig();
97118
expect(shell).toBe(path.join(binDir, "sh"));
119+
expect(args).toEqual(["-c"]);
120+
});
121+
122+
it("falls back to startup-suppressed bash on PATH when SHELL is a placeholder", () => {
123+
const binDir = createTempCommandDir(tempDirs, [{ name: "bash" }]);
124+
process.env.SHELL = "/usr/bin/false";
125+
process.env.PATH = binDir;
126+
const { shell, args } = getShellConfig();
127+
expect(shell).toBe(path.join(binDir, "bash"));
128+
expect(args).toEqual(["--noprofile", "--norc", "-c"]);
98129
});
99130

100131
it("falls back to bare sh when SHELL is a placeholder and no sh is on PATH", () => {
101132
process.env.SHELL = "/usr/bin/false";
102133
process.env.PATH = "";
103-
const { shell } = getShellConfig();
134+
const { shell, args } = getShellConfig();
104135
expect(shell).toBe("sh");
136+
expect(args).toEqual(["-c"]);
105137
});
106138
});
107139

src/agents/shell-utils.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ function isNonInteractiveShell(shellPath: string): boolean {
5151
return NON_INTERACTIVE_SHELLS.has(path.basename(shellPath));
5252
}
5353

54+
function getPosixShellArgs(shellPath: string): string[] {
55+
switch (path.basename(shellPath)) {
56+
case "bash":
57+
return ["--noprofile", "--norc", "-c"];
58+
case "zsh":
59+
return ["-f", "-c"];
60+
case "fish":
61+
return ["--no-config", "-c"];
62+
default:
63+
return ["-c"];
64+
}
65+
}
66+
5467
export function getShellConfig(): { shell: string; args: string[] } {
5568
if (process.platform === "win32") {
5669
// Use PowerShell instead of cmd.exe on Windows.
@@ -71,20 +84,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
7184
if (shellName === "fish") {
7285
const bash = resolveShellFromPath("bash");
7386
if (bash) {
74-
return { shell: bash, args: ["-c"] };
87+
return { shell: bash, args: getPosixShellArgs(bash) };
7588
}
7689
const sh = resolveShellFromPath("sh");
7790
if (sh) {
78-
return { shell: sh, args: ["-c"] };
91+
return { shell: sh, args: getPosixShellArgs(sh) };
7992
}
8093
}
8194
if (envShell) {
82-
return { shell: envShell, args: ["-c"] };
95+
return { shell: envShell, args: getPosixShellArgs(envShell) };
8396
}
8497
// Placeholder SHELL (or unset): prefer a resolved sh/bash on PATH so we do not
8598
// re-invoke the placeholder and get a spurious exitCode=1.
86-
const sh = resolveShellFromPath("sh") ?? resolveShellFromPath("bash");
87-
return { shell: sh ?? "sh", args: ["-c"] };
99+
const shell = resolveShellFromPath("sh") ?? resolveShellFromPath("bash") ?? "sh";
100+
return { shell, args: getPosixShellArgs(shell) };
88101
}
89102

90103
export function resolveShellFromPath(name: string): string | undefined {

0 commit comments

Comments
 (0)