From f843a45a84953a6691b919ed701c2180632f29fe Mon Sep 17 00:00:00 2001 From: Octasoft Ltd Date: Fri, 3 Apr 2026 01:34:26 +0100 Subject: [PATCH] fix: use sh instead of /bin/sh as shell fallback on Windows (#891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run shell commands during workspace provisioning (git worktree creation, runtime services) > - When `process.env.SHELL` is unset, the code falls back to `/bin/sh` > - But on Windows with Git Bash, `/bin/sh` doesn't exist as an absolute path — Git Bash provides `sh` on PATH instead > - This causes `child_process.spawn` to throw `ENOENT`, crashing workspace provisioning on Windows > - This PR extracts a `resolveShell()` helper that uses `$SHELL` when set, falls back to `sh` (bare) on Windows or `/bin/sh` on Unix > - The benefit is that agents running on Windows via Git Bash can provision workspaces without shell resolution errors ## Summary - `workspace-runtime.ts` falls back to `/bin/sh` when `process.env.SHELL` is unset - On Windows, `/bin/sh` doesn't exist → `spawn /bin/sh ENOENT` - Fix: extract `resolveShell()` helper that uses `$SHELL` when set, falls back to `sh` on Windows (Git Bash PATH lookup) or `/bin/sh` on Unix Three call sites updated to use the new helper. Fixes #892 ## Root cause When Paperclip spawns shell commands in workspace operations (e.g., git worktree creation), it uses `process.env.SHELL` if set, otherwise defaults to `/bin/sh`. On Windows with Git Bash, `$SHELL` is typically unset and `/bin/sh` is not a valid path — Git Bash provides `sh` on PATH but not at the absolute `/bin/sh` location. This causes `child_process.spawn` to throw `ENOENT`. ## Approach Rather than hard-coding a Windows-specific absolute path (e.g., `C:\Program Files\Git\bin\sh.exe`), we use the bare `"sh"` command which relies on PATH resolution. This works because: 1. Git Bash adds its `usr/bin` directory to PATH, making `sh` resolvable 2. On Unix/macOS, `/bin/sh` remains the correct default (it's the POSIX standard location) 3. `process.env.SHELL` takes priority when set, so this only affects the fallback ## Test plan - [x] 7 unit tests for `resolveShell()`: SHELL set, trimmed, empty, whitespace-only, linux/darwin/win32 fallbacks - [x] Run a workspace provision command on Windows with `git_worktree` strategy - [x] Verify Unix/macOS is unaffected 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Paperclip Co-authored-by: Devin Foley --- .../src/__tests__/workspace-runtime.test.ts | 55 +++++++++++++++++++ server/src/services/workspace-runtime.ts | 12 +++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 3f93ccf6..8bf008bd 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -24,6 +24,7 @@ import { realizeExecutionWorkspace, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + resolveShell, sanitizeRuntimeServiceBaseEnv, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, @@ -1345,6 +1346,60 @@ describe("ensureRuntimeServicesForRun", () => { }); }); +describe("resolveShell (shell fallback)", () => { + const originalShell = process.env.SHELL; + const originalPlatform = process.platform; + + afterEach(() => { + if (originalShell !== undefined) { + process.env.SHELL = originalShell; + } else { + delete process.env.SHELL; + } + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("returns process.env.SHELL when set", () => { + process.env.SHELL = "/usr/bin/zsh"; + expect(resolveShell()).toBe("/usr/bin/zsh"); + }); + + it("trims whitespace from SHELL env var", () => { + process.env.SHELL = " /usr/bin/fish "; + expect(resolveShell()).toBe("/usr/bin/fish"); + }); + + it("falls back to /bin/sh on non-Windows when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("falls back to sh (bare) on Windows when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "win32" }); + expect(resolveShell()).toBe("sh"); + }); + + it("falls back to /bin/sh on darwin when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "darwin" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("treats empty SHELL as unset and uses platform fallback", () => { + process.env.SHELL = ""; + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("treats whitespace-only SHELL as unset and uses platform fallback", () => { + process.env.SHELL = " "; + Object.defineProperty(process, "platform", { value: "win32" }); + expect(resolveShell()).toBe("sh"); + }); +}); + describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index f191ece4..a100242e 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -24,6 +24,10 @@ import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; +export function resolveShell(): string { + return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh"); +} + export interface ExecutionWorkspaceInput { baseCwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -379,7 +383,7 @@ async function runWorkspaceCommand(input: { env: NodeJS.ProcessEnv; label: string; }) { - const shell = process.env.SHELL?.trim() || "/bin/sh"; + const shell = resolveShell(); const proc = await executeProcess({ command: shell, args: ["-c", input.command], @@ -475,7 +479,7 @@ async function recordWorkspaceCommandOperation( cwd: input.cwd, metadata: input.metadata ?? null, run: async () => { - const shell = process.env.SHELL?.trim() || "/bin/sh"; + const shell = resolveShell(); const result = await executeProcess({ command: shell, args: ["-c", input.command], @@ -1285,6 +1289,7 @@ async function startLocalRuntimeService(input: { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); } + const expose = parseObject(input.service.expose); const readiness = parseObject(input.service.readiness); const urlTemplate = @@ -1359,7 +1364,8 @@ async function startLocalRuntimeService(input: { ); } } - const shell = process.env.SHELL?.trim() || "/bin/sh"; + + const shell = resolveShell(); const child = spawn(shell, ["-lc", command], { cwd: serviceCwd, env,