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:
parent
36049beeea
commit
f843a45a84
2 changed files with 64 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue