Skip to content

Port: respect $SHELL on Windows when spawning child processes #46

@JohnnyVicious

Description

@JohnnyVicious

Port of openai/codex-plugin-cc#178 (merged)

Problem

On Windows, Node's child_process.spawn with shell: true uses COMSPEC (cmd.exe) by default. When the user runs under Git Bash or another POSIX-ish shell, commands that work in their interactive shell fail inside the plugin because the plugin spawned via cmd.exe instead.

plugins/opencode/scripts/lib/process.mjs::runCommand and resolveOpencodeBinary both use spawn("opencode"|"which", ...) without a shell: option at all today. This is different from the upstream issue shape — upstream has shell: process.platform === "win32" which picks cmd. Opencode has no shell, which means on Windows it can't find opencode.cmd, git.cmd, gh.cmd, etc. at all.

Both sides of the issue apply to us:

  1. On Windows we need shell: true so .cmd shims resolve.
  2. When we do pass shell: true, we should honor $SHELL for Git Bash users.

Fix (adapted from upstream)

lib/process.mjs::runCommand:

export function runCommand(cmd, args, opts = {}) {
  return new Promise((resolve) => {
    const shellOpt =
      process.platform === "win32"
        ? (process.env.SHELL || true)  // respect Git Bash if present, else cmd
        : false;

    const proc = spawn(cmd, args, {
      stdio: ["ignore", "pipe", "pipe"],
      cwd: opts.cwd,
      env: { ...process.env, ...opts.env },
      shell: shellOpt,
      windowsHide: true,
    });
    // ... rest unchanged ...
  });
}

lib/process.mjs::resolveOpencodeBinary:

which opencode doesn't work on Windows (no which binary). Use where on win32 and handle the .cmd shim:

export async function resolveOpencodeBinary() {
  return new Promise((resolve) => {
    const isWin = process.platform === "win32";
    const locator = isWin ? "where" : "which";
    const proc = spawn(locator, ["opencode"], {
      stdio: ["ignore", "pipe", "ignore"],
      shell: isWin ? (process.env.SHELL || true) : false,
      windowsHide: true,
    });
    let out = "";
    proc.stdout.on("data", (d) => (out += d));
    proc.on("close", (code) => {
      if (code !== 0) return resolve(null);
      // `where` returns all matches; take the first.
      resolve(out.trim().split(/\r?\n/)[0] ?? null);
    });
  });
}

lib/opencode-server.mjs::ensureServer already uses stdio: "ignore" + detached: true for opencode serve, but it still uses spawn("opencode", ...) without a shell option. Apply the same shellOpt fix there so .cmd shims resolve on Windows.

lib/process.mjs::spawnDetached — used by the background-task worker path — needs the same treatment:

export function spawnDetached(cmd, args, opts = {}) {
  const shellOpt =
    process.platform === "win32" ? (process.env.SHELL || true) : false;
  const child = spawn(cmd, args, {
    stdio: "ignore",
    detached: true,
    cwd: opts.cwd,
    env: { ...process.env, ...opts.env },
    shell: shellOpt,
    windowsHide: true,
  });
  child.unref();
  return child;
}

Test plan

  1. On Windows with Git Bash installed, set SHELL=C:\Program Files\Git\bin\bash.exe, run /opencode:setup. Assert it locates opencode.cmd and reports the version.
  2. On Windows without Git Bash, unset SHELL, run /opencode:setup. Assert cmd-shell fallback still works.
  3. On POSIX (Linux/macOS), assert behavior is unchanged — the shell: false branch kicks in.
  4. Confirm git and gh calls from lib/git.mjs (via runCommand) also find their .cmd shims on Windows.

Upstream reference

openai/codex-plugin-cc#178 (merged 2026-04-08). Related upstream context: #55 / #13 / #70 (Windows ENOENT cluster).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions