diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad08d0330..c121279ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
+- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
## 2026.1.19-3
diff --git a/README.md b/README.md
index b8e0be195..e00703956 100644
--- a/README.md
+++ b/README.md
@@ -475,21 +475,22 @@ Thanks to all clawtributors:
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index 1a65618e2..3cf2ada1b 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -32,6 +32,8 @@ Notes:
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
+- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
+ from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
## Config
diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json
index d6ce6e27e..7ad1f926c 100644
--- a/scripts/clawtributors-map.json
+++ b/scripts/clawtributors-map.json
@@ -10,7 +10,8 @@
"longmaba",
"manmal",
"thesash",
- "rhjoh"
+ "rhjoh",
+ "ysqander"
],
"seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg",
diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts
new file mode 100644
index 000000000..5f781b001
--- /dev/null
+++ b/src/agents/shell-utils.test.ts
@@ -0,0 +1,82 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { getShellConfig } from "./shell-utils.js";
+
+const isWin = process.platform === "win32";
+
+describe("getShellConfig", () => {
+ const originalShell = process.env.SHELL;
+ const originalPath = process.env.PATH;
+ const tempDirs: string[] = [];
+
+ const createTempBin = (files: string[]) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-shell-"));
+ tempDirs.push(dir);
+ for (const name of files) {
+ const filePath = path.join(dir, name);
+ fs.writeFileSync(filePath, "");
+ fs.chmodSync(filePath, 0o755);
+ }
+ return dir;
+ };
+
+ beforeEach(() => {
+ if (!isWin) {
+ process.env.SHELL = "/usr/bin/fish";
+ }
+ });
+
+ afterEach(() => {
+ if (originalShell == null) {
+ delete process.env.SHELL;
+ } else {
+ process.env.SHELL = originalShell;
+ }
+ if (originalPath == null) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ if (isWin) {
+ it("uses PowerShell on Windows", () => {
+ const { shell } = getShellConfig();
+ expect(shell.toLowerCase()).toContain("powershell");
+ });
+ return;
+ }
+
+ it("prefers bash when fish is default and bash is on PATH", () => {
+ const binDir = createTempBin(["bash"]);
+ process.env.PATH = binDir;
+ const { shell } = getShellConfig();
+ expect(shell).toBe(path.join(binDir, "bash"));
+ });
+
+ it("falls back to sh when fish is default and bash is missing", () => {
+ const binDir = createTempBin(["sh"]);
+ process.env.PATH = binDir;
+ const { shell } = getShellConfig();
+ expect(shell).toBe(path.join(binDir, "sh"));
+ });
+
+ it("falls back to env shell when fish is default and no sh is available", () => {
+ process.env.PATH = "";
+ const { shell } = getShellConfig();
+ expect(shell).toBe("/usr/bin/fish");
+ });
+
+ it("uses sh when SHELL is unset", () => {
+ delete process.env.SHELL;
+ process.env.PATH = "";
+ const { shell } = getShellConfig();
+ expect(shell).toBe("sh");
+ });
+});
diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts
index bad3e7d80..6974d9a2a 100644
--- a/src/agents/shell-utils.ts
+++ b/src/agents/shell-utils.ts
@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
-import path from "node:path";
function resolvePowerShellPath(): string {
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
@@ -35,12 +34,31 @@ export function getShellConfig(): { shell: string; args: string[] } {
const shellName = envShell ? path.basename(envShell) : "";
// Fish rejects common bashisms used by tools, so prefer bash when detected.
if (shellName === "fish") {
- return { shell: "/bin/bash", args: ["-c"] };
+ const bash = resolveShellFromPath("bash");
+ if (bash) return { shell: bash, args: ["-c"] };
+ const sh = resolveShellFromPath("sh");
+ if (sh) return { shell: sh, args: ["-c"] };
}
- const shell = envShell || "sh";
+ const shell = envShell && envShell.length > 0 ? envShell : "sh";
return { shell, args: ["-c"] };
}
+function resolveShellFromPath(name: string): string | undefined {
+ const envPath = process.env.PATH ?? "";
+ if (!envPath) return undefined;
+ const entries = envPath.split(path.delimiter).filter(Boolean);
+ for (const entry of entries) {
+ const candidate = path.join(entry, name);
+ try {
+ fs.accessSync(candidate, fs.constants.X_OK);
+ return candidate;
+ } catch {
+ // ignore missing or non-executable entries
+ }
+ }
+ return undefined;
+}
+
export function sanitizeBinaryOutput(text: string): string {
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
if (!scrubbed) return scrubbed;
diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts
index 51b21881c..8e4e7377d 100644
--- a/src/process/command-queue.ts
+++ b/src/process/command-queue.ts
@@ -111,7 +111,7 @@ export function enqueueCommand(
return enqueueCommandInLane(CommandLane.Main, task, opts);
}
-export function getQueueSize(lane = CommandLane.Main) {
+export function getQueueSize(lane: string = CommandLane.Main) {
const state = lanes.get(lane);
if (!state) return 0;
return state.queue.length + state.active;
@@ -125,7 +125,7 @@ export function getTotalQueueSize() {
return total;
}
-export function clearCommandLane(lane = CommandLane.Main) {
+export function clearCommandLane(lane: string = CommandLane.Main) {
const cleaned = lane.trim() || CommandLane.Main;
const state = lanes.get(cleaned);
if (!state) return 0;