fix(executor): hide Windows console + drop .sh extension for shell exec (#384)

On Windows, ctx_execute(language: 'shell', ...) had two problems:

1. Silent output - child_process.spawn without windowsHide:true creates
   a visible console window that intercepts stdout, leaving the MCP
   response empty.

2. Git Bash popup - temp script written as 'script.sh' triggers Windows
   file association for .sh files. bash.exe opens a visible window over
   the user's IDE.

Fix (minimal, two surgical changes):
- spawn(..., { windowsHide: isWin }) via buildSpawnOptions(platform)
- isWin && language === 'shell' ? 'script' : 'script.{ext}' via
  buildScriptFilename(language, platform)

Both changes are Windows-gated. Linux/macOS behavior unchanged.

Does NOT change shell invocation semantics (no bash -c wrapper).
Does NOT add SHELL env override. Both deferred - separate features.

Helpers exposed as pure functions for unit testing without mocking
spawn or filesystem.

Closes #384.
Supersedes #385 with smaller surface area.
This commit is contained in:
Mert Koseoglu
2026-05-02 17:28:29 +03:00
parent 259077c443
commit 9d1f44fba3
2 changed files with 75 additions and 16 deletions
+40 -15
View File
@@ -13,6 +13,40 @@ import type { ExecResult } from "./types.js";
const isWin = process.platform === "win32";
/**
* Pure helper: extension map for temp script files per language.
* On Windows, shell scripts get NO extension to avoid Windows file-association
* for `.sh` (which spawns a visible Git Bash window over the user's IDE).
*/
const SCRIPT_EXT: Record<Language, string> = {
javascript: "js",
typescript: "ts",
python: "py",
shell: "sh",
ruby: "rb",
go: "go",
rust: "rs",
php: "php",
perl: "pl",
r: "R",
elixir: "exs",
};
/** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
export function buildScriptFilename(language: Language, platform: NodeJS.Platform): string {
if (platform === "win32" && language === "shell") return "script";
return `script.${SCRIPT_EXT[language]}`;
}
/**
* Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
* to prevent the spawned shell from creating a visible console window that
* intercepts stdout (issue #384).
*/
export function buildSpawnOptions(platform: NodeJS.Platform): { windowsHide: boolean } {
return { windowsHide: platform === "win32" };
}
/**
* Resolve the real OS temp directory, bypassing any TMPDIR env override.
* os.tmpdir() reads TMPDIR from the environment, which some shells/tools
@@ -138,20 +172,6 @@ export class PolyglotExecutor {
}
#writeScript(tmpDir: string, code: string, language: Language): string {
const extMap: Record<Language, string> = {
javascript: "js",
typescript: "ts",
python: "py",
shell: "sh",
ruby: "rb",
go: "go",
rust: "rs",
php: "php",
perl: "pl",
r: "R",
elixir: "exs",
};
// Go needs a main package wrapper if not present
if (language === "go" && !code.includes("package ")) {
code = `package main\n\nimport "fmt"\n\nfunc main() {\n${code}\n}\n`;
@@ -168,7 +188,7 @@ export class PolyglotExecutor {
code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
}
const fp = join(tmpDir, `script.${extMap[language]}`);
const fp = join(tmpDir, buildScriptFilename(language, process.platform));
if (language === "shell") {
writeFileSync(fp, code, { encoding: "utf-8", mode: 0o700 });
} else {
@@ -240,6 +260,11 @@ export class PolyglotExecutor {
shell: needsShell,
// On Unix, create a new process group so killTree can kill all children
detached: !isWin,
// Hide the spawned-process console window on Windows. Without this,
// child_process.spawn creates a visible window that intercepts stdout,
// leaving the MCP response empty and popping a Git Bash terminal over
// the user's IDE. Issue #384.
...buildSpawnOptions(process.platform),
});
let timedOut = false;
+35 -1
View File
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PolyglotExecutor } from "../src/executor.js";
import { PolyglotExecutor, buildScriptFilename, buildSpawnOptions } from "../src/executor.js";
import {
detectRuntimes,
buildCommand,
@@ -1541,5 +1541,39 @@ describe("Windows Shell Support", () => {
assert.ok(Array.isArray(cmd) && cmd.length === 2, `Expected [shell, path], got: ${cmd}`);
assert.equal(cmd[1], "/tmp/script.sh");
});
// --- Issue #384: hide Windows console + drop .sh extension for shell ---
test("buildSpawnOptions: windowsHide=true on Windows", async () => {
assert.equal(buildSpawnOptions("win32").windowsHide, true);
});
test("buildSpawnOptions: windowsHide=false on macOS/Linux", async () => {
assert.equal(buildSpawnOptions("darwin").windowsHide, false);
assert.equal(buildSpawnOptions("linux").windowsHide, false);
});
test("buildScriptFilename: shell on Windows has NO extension (avoid .sh file association)", async () => {
assert.equal(buildScriptFilename("shell", "win32"), "script");
});
test("buildScriptFilename: shell on Unix keeps .sh extension", async () => {
assert.equal(buildScriptFilename("shell", "darwin"), "script.sh");
assert.equal(buildScriptFilename("shell", "linux"), "script.sh");
});
test("buildScriptFilename: non-shell languages keep their extension on Windows", async () => {
assert.equal(buildScriptFilename("python", "win32"), "script.py");
assert.equal(buildScriptFilename("javascript", "win32"), "script.js");
assert.equal(buildScriptFilename("typescript", "win32"), "script.ts");
assert.equal(buildScriptFilename("ruby", "win32"), "script.rb");
assert.equal(buildScriptFilename("go", "win32"), "script.go");
assert.equal(buildScriptFilename("rust", "win32"), "script.rs");
});
test("buildScriptFilename: non-shell languages keep their extension on Unix", async () => {
assert.equal(buildScriptFilename("python", "linux"), "script.py");
assert.equal(buildScriptFilename("javascript", "darwin"), "script.js");
});
});