fix: use sh instead of /bin/sh as shell fallback on Windows (#891)

## 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) <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@devinfoley.com>
This commit is contained in:
Octasoft Ltd 2026-04-03 01:34:26 +01:00 committed by GitHub
parent 36049beeea
commit f843a45a84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 64 additions and 3 deletions

View file

@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;

View file

@ -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,