diff --git a/README.md b/README.md index 3605847c..c424d0ec 100644 --- a/README.md +++ b/README.md @@ -151,15 +151,17 @@ For a normal ChatGPT coding session: ## Platform Support -DevSpace supports Linux, macOS, and Windows environments with a Bash-compatible -shell. +DevSpace supports Linux, macOS, and Windows. On Windows, the default shell mode +uses native PowerShell so commands do not pass through Git Bash, MSYS, or WSL +before reaching PowerShell. | Platform | Status | Notes | | ------------------------------------------------- | ----------------- | ---------------------------------------------- | | Linux | Supported | Requires Node, npm, Git, and Bash. | | macOS | Supported | Requires Node, npm, Git, and Bash. | -| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Git Bash is the simplest native Windows setup. | -| Windows PowerShell or `cmd.exe` only | Not supported yet | Install Git Bash or use WSL. | +| Windows PowerShell | Supported | Default on Windows through `DEVSPACE_SHELL=auto`. | +| Windows `cmd.exe` | Supported | Set `DEVSPACE_SHELL=cmd`. | +| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Set `DEVSPACE_SHELL=bash`. | Run this to inspect your local setup: diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..27c2146c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_SHELL` | Shell backend for `bash`/`run_shell`. Defaults to `auto`. | ## OAuth @@ -73,6 +74,15 @@ MCP clients discover metadata from: | `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. | | `full` | Enables dedicated `grep`, `glob`, and `ls` tools. | +`DEVSPACE_SHELL` controls how shell commands are executed. + +| Value | Behavior | +| --- | --- | +| `auto` | Default. Uses native PowerShell on Windows and Bash on Linux/macOS. | +| `bash` | Uses Pi's Bash backend. On Windows this requires Git Bash, WSL, MSYS2, or Cygwin Bash. | +| `powershell` | Uses native PowerShell directly, without a Bash/MSYS layer. | +| `cmd` | Uses native `cmd.exe /d /s /c`. | + ## Widgets `DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. diff --git a/docs/gotchas.md b/docs/gotchas.md index 33823b5a..d01b4ffa 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -170,12 +170,11 @@ Uncommitted source checkout changes are not copied into the managed worktree. Commit, stash, or ask the model to work in checkout mode if those changes are needed. -## Windows Shell Commands Fail +## Windows Shell Behavior -DevSpace shell execution requires Bash. Native PowerShell and `cmd.exe` command -execution are not supported yet. - -Install Git for Windows and use Git Bash, or use WSL, MSYS2, or Cygwin Bash. +On Windows, DevSpace uses native PowerShell by default. This avoids routing +commands through Bash/MSYS before PowerShell receives them, which can otherwise +expand PowerShell variables such as `$_` too early. Run: @@ -183,7 +182,45 @@ Run: npx @waishnav/devspace doctor ``` -Confirm Bash is detected. +Confirm `Shell mode` and `Shell command`. + +To force a specific shell: + +```powershell +$env:DEVSPACE_SHELL="powershell"; npx @waishnav/devspace serve +$env:DEVSPACE_SHELL="cmd"; npx @waishnav/devspace serve +$env:DEVSPACE_SHELL="bash"; npx @waishnav/devspace serve +``` + +When writing PowerShell commands that inspect Windows paths, prefer `-like` or +`.Contains()` for literal path fragments. `-match` uses regex, so a path segment +like `\profiles` can be parsed as the regex escape `\p`. If you need regex, wrap +literal paths with `[regex]::Escape($path)`. + +DevSpace blocks fragile PowerShell commands that look like literal Windows paths +being passed to `-match`: + +```powershell +$_.CommandLine -match "pydoll-mcp-server\profiles\chatgpt-linkedin-check" +$_.Path -match 'C:\Users\Yuri\Documents' +``` + +Use literal matching instead: + +```powershell +$_.CommandLine.Contains("pydoll-mcp-server\profiles\chatgpt-linkedin-check") +$_.CommandLine -like "*pydoll-mcp-server*profiles*chatgpt-linkedin-check*" +``` + +If you really need `-match`, escape the literal first: + +```powershell +$pattern = [regex]::Escape("pydoll-mcp-server\profiles\chatgpt-linkedin-check") +$_.CommandLine -match $pattern +``` + +Regex patterns that do not look like Windows paths, such as +`'remote-debugging-port=\d+'`, are allowed. ## Skills Do Not Appear diff --git a/package.json b/package.json index 7962ae98..6bab8014 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/pi-tools.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 87ba662d..c43bb1ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,8 @@ import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; -import { loadConfig } from "./config.js"; +import { loadConfig, type ShellMode } from "./config.js"; +import { resolveShellCommand } from "./pi-tools.js"; import { generateOwnerToken, loadDevspaceFiles, @@ -214,11 +215,12 @@ async function runDoctor(): Promise { console.log(`Node ABI: ${process.versions.modules}`); console.log(`Platform: ${process.platform} ${process.arch}`); console.log(`Git: ${checkGitAvailable()}`); - console.log(`Bash shell: ${checkBashShell()}`); console.log(`SQLite native dependency: ${checkSqliteNative()}`); try { const config = loadConfig(); + console.log(`Shell mode: ${config.shell}`); + console.log(`Shell command: ${checkShellCommand(config.shell)}`); console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`); console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); @@ -393,6 +395,15 @@ function checkBashShell(): string { } } +function checkShellCommand(mode: ShellMode): string { + if (mode === "bash" || (mode === "auto" && process.platform !== "win32")) { + return checkBashShell(); + } + + const { command, args } = resolveShellCommand(mode); + return `${command} ${args.join(" ")}`; +} + main(process.argv.slice(2)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4ed..1fc53686 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,6 +18,11 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off") assert.equal(loadConfig(baseEnv).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "short" }).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "legacy" }).toolNaming, "legacy"); +assert.equal(loadConfig(baseEnv).shell, "auto"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "auto" }).shell, "auto"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "bash" }).shell, "bash"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "powershell" }).shell, "powershell"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "cmd" }).shell, "cmd"); assert.equal(loadConfig(baseEnv).minimalTools, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTools, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); @@ -47,6 +52,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "invalid" }), /Invalid DEVSPACE_TOOL_NAMING: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SHELL: "invalid" }), + /Invalid DEVSPACE_SHELL: invalid/, +); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", diff --git a/src/config.ts b/src/config.ts index bb0526c4..a839e9b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ import { loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type WidgetMode = "off" | "changes" | "full"; +export type ShellMode = "auto" | "bash" | "powershell" | "cmd"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -19,6 +20,7 @@ export interface ServerConfig { publicBaseUrl: string; minimalTools: boolean; toolNaming: ToolNamingMode; + shell: ShellMode; widgets: WidgetMode; stateDir: string; worktreeRoot: string; @@ -140,6 +142,13 @@ function parseToolNaming(value: string | undefined): ToolNamingMode { throw new Error(`Invalid DEVSPACE_TOOL_NAMING: ${value}`); } +function parseShellMode(value: string | undefined): ShellMode { + if (!value || value === "auto") return "auto"; + if (value === "bash" || value === "powershell" || value === "cmd") return value; + + throw new Error(`Invalid DEVSPACE_SHELL: ${value}`); +} + function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { return { level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), @@ -229,6 +238,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { publicBaseUrl, minimalTools: parseMinimalTools(env), toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), + shell: parseShellMode(env.DEVSPACE_SHELL), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), diff --git a/src/pi-tools.test.ts b/src/pi-tools.test.ts new file mode 100644 index 00000000..87c46f45 --- /dev/null +++ b/src/pi-tools.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runShellTool } from "./pi-tools.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-pi-tools-test-")); +writeFileSync(join(root, "marker.txt"), "marker\n"); + +if (process.platform === "win32") { + const simple = await runShellTool( + { command: "Write-Output 'native-powershell-ok'" }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(simple.isError, undefined); + assert.match(simple.content[0]?.type === "text" ? simple.content[0].text : "", /native-powershell-ok/); + + const blockedMatch = await runShellTool( + { command: "$_.Path -match 'C:\\Users\\Yuri\\Documents'" }, + { cwd: root, root, shell: "powershell" }, + ); + const blockedMatchText = blockedMatch.content[0]?.type === "text" ? blockedMatch.content[0].text : ""; + assert.equal(blockedMatch.isError, true); + assert.match(blockedMatchText, /Blocked fragile PowerShell command/); + assert.match(blockedMatchText, /\.Contains/); + assert.match(blockedMatchText, /-like/); + assert.match(blockedMatchText, /\[regex\]::Escape/); + + const blockedVariableMatch = await runShellTool( + { + command: "$path = 'pydoll-mcp-server\\profiles\\chatgpt-linkedin-check'; $_.CommandLine -match $path", + }, + { cwd: root, root, shell: "powershell" }, + ); + const blockedVariableMatchText = blockedVariableMatch.content[0]?.type === "text" + ? blockedVariableMatch.content[0].text + : ""; + assert.equal(blockedVariableMatch.isError, true); + assert.match(blockedVariableMatchText, /pydoll-mcp-server\\profiles\\chatgpt-linkedin-check/); + assert.match(blockedVariableMatchText, /\[regex\]::Escape/); + + const regexDigitMatch = await runShellTool( + { command: "'chrome.exe --remote-debugging-port=9224' -match 'remote-debugging-port=\\d+'" }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(regexDigitMatch.isError, undefined); + assert.match(regexDigitMatch.content[0]?.type === "text" ? regexDigitMatch.content[0].text : "", /True/); + + const regexDigitVariableMatch = await runShellTool( + { + command: "$pattern = 'remote-debugging-port=\\d+'; 'chrome.exe --remote-debugging-port=9224' -match $pattern", + }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(regexDigitVariableMatch.isError, undefined); + assert.match( + regexDigitVariableMatch.content[0]?.type === "text" ? regexDigitVariableMatch.content[0].text : "", + /True/, + ); + + const contains = await runShellTool( + { command: "'C:\\Users\\Yuri\\Documents'.Contains('C:\\Users\\Yuri')" }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(contains.isError, undefined); + assert.match(contains.content[0]?.type === "text" ? contains.content[0].text : "", /True/); + + const escapedMatch = await runShellTool( + { + command: "$pattern = [regex]::Escape('C:\\Users\\Yuri'); 'C:\\Users\\Yuri\\Documents' -match $pattern", + }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(escapedMatch.isError, undefined); + assert.match(escapedMatch.content[0]?.type === "text" ? escapedMatch.content[0].text : "", /True/); + + const pipeline = await runShellTool( + { + command: "@('alpha','beta') | Where-Object { $_ -like 'b*' } | ForEach-Object { \"item=$_\" }", + }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(pipeline.isError, undefined); + assert.match(pipeline.content[0]?.type === "text" ? pipeline.content[0].text : "", /item=beta/); + + const cwd = await runShellTool( + { command: "(Get-Location).Path" }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(cwd.isError, undefined); + assert.equal(cwd.content[0]?.type === "text" ? cwd.content[0].text : "", root); + + const failed = await runShellTool( + { command: "Write-Error 'expected failure'; exit 9" }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(failed.isError, true); + assert.match(failed.content[0]?.type === "text" ? failed.content[0].text : "", /Command exited with code 9/); + + const timedOut = await runShellTool( + { command: "Start-Sleep -Seconds 5", timeout: 1 }, + { cwd: root, root, shell: "powershell" }, + ); + assert.equal(timedOut.isError, true); + assert.match(timedOut.content[0]?.type === "text" ? timedOut.content[0].text : "", /timed out after 1 seconds/); + + const cmd = await runShellTool( + { command: "echo native-cmd-ok" }, + { cwd: root, root, shell: "cmd" }, + ); + assert.equal(cmd.isError, undefined); + assert.match(cmd.content[0]?.type === "text" ? cmd.content[0].text : "", /native-cmd-ok/); +} else { + const bash = await runShellTool( + { command: "printf 'native-bash-ok\\n'" }, + { cwd: root, root, shell: "bash" }, + ); + assert.equal(bash.isError, undefined); + assert.match(bash.content[0]?.type === "text" ? bash.content[0].text : "", /native-bash-ok/); +} diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c54..5ba2296f 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -1,3 +1,8 @@ +import { + spawn, + spawnSync, + type ChildProcess, +} from "node:child_process"; import { createBashTool, createEditTool, @@ -16,6 +21,7 @@ import { type WriteToolInput, type AgentToolResult, } from "@earendil-works/pi-coding-agent"; +import type { ShellMode } from "./config.js"; import { resolveAllowedPath } from "./roots.js"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; @@ -29,6 +35,15 @@ interface ToolContext { cwd: string; root: string; readRoots?: string[]; + shell?: ShellMode; +} + +export type ResolvedShellMode = "bash" | "powershell" | "cmd"; + +export interface ResolvedShellCommand { + mode: ResolvedShellMode; + command: string; + args: string[]; } function toMcpContent(result: AgentToolResult): McpContent[] { @@ -50,6 +65,214 @@ function formatToolError(error: unknown): McpContent[] { return [{ type: "text", text: message }]; } +function resolveShellMode(mode: ShellMode | undefined): ResolvedShellMode { + if (mode && mode !== "auto") return mode; + return process.platform === "win32" ? "powershell" : "bash"; +} + +export function resolveShellCommand(mode: ShellMode | undefined): ResolvedShellCommand { + const resolvedMode = resolveShellMode(mode); + + if (resolvedMode === "powershell") { + return process.platform === "win32" + ? { + mode: "powershell", + command: "powershell.exe", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"], + } + : { + mode: "powershell", + command: "pwsh", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + }; + } + + if (resolvedMode === "cmd") { + return { mode: "cmd", command: "cmd.exe", args: ["/d", "/s", "/c"] }; + } + + return { mode: "bash", command: "bash", args: ["-c"] }; +} + +function killChildProcess(child: ChildProcess): void { + if (!child.pid) return; + + if (process.platform === "win32") { + spawn("taskkill", ["/F", "/T", "/PID", String(child.pid)], { + stdio: "ignore", + detached: true, + windowsHide: true, + }).unref(); + return; + } + + child.kill("SIGTERM"); +} + +function commandExists(command: string): boolean { + const lookup = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(lookup, [command], { + encoding: "utf8", + timeout: 5000, + windowsHide: true, + }); + + return result.status === 0; +} + +function resolveRunnableShellCommand(mode: ShellMode | undefined): ResolvedShellCommand { + const shellCommand = resolveShellCommand(mode); + if ( + shellCommand.mode === "powershell" && + process.platform === "win32" && + !commandExists(shellCommand.command) && + commandExists("pwsh") + ) { + return { + mode: "powershell", + command: "pwsh", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + }; + } + + return shellCommand; +} + +function findUnsafePowerShellRegexLiteral(command: string): string | undefined { + if (/\[regex\]::Escape\s*\(/i.test(command)) return undefined; + + const unsafeMatchPattern = /(?:^|[\s|;&({])-match\s*(["'])(?(?:\\.|(?!\1)[\s\S])*?\\(?:\\.|(?!\1)[\s\S])*?)\1/i; + const match = unsafeMatchPattern.exec(command); + if (match?.groups?.literal && looksLikeWindowsPathRegexLiteral(match.groups.literal)) { + return match.groups.literal; + } + + const unsafeAssignments = unsafePowerShellRegexVariableAssignments(command); + for (const [name, literal] of unsafeAssignments) { + const variableMatchPattern = new RegExp(`(?:^|[\\s|;&({])-match\\s*\\$${escapeRegExp(name)}\\b`, "i"); + if (variableMatchPattern.test(command)) return literal; + } + + return undefined; +} + +function unsafePowerShellRegexVariableAssignments(command: string): Map { + const assignments = new Map(); + const assignmentPattern = /(?:^|[\s;{(])\$(?[A-Za-z_][\w]*)\s*=\s*(["'])(?(?:\\.|(?!\2)[\s\S])*?\\(?:\\.|(?!\2)[\s\S])*?)\2/g; + let match: RegExpExecArray | null; + + while ((match = assignmentPattern.exec(command)) !== null) { + const name = match.groups?.name; + const literal = match.groups?.literal; + if (name && literal && looksLikeWindowsPathRegexLiteral(literal)) assignments.set(name, literal); + } + + return assignments; +} + +function looksLikeWindowsPathRegexLiteral(literal: string): boolean { + if (/[A-Za-z]:\\/.test(literal)) return true; + if (/^\\\\[^\\]+\\[^\\]+/.test(literal)) return true; + if (/\\(?:users|appdata|profiles?|documents|downloads|desktop|program files|programdata|windows|system32|node_modules)(?:\\|$)/i.test(literal)) { + return true; + } + if (/\\[pP](?!\{)/.test(literal)) return true; + if (/\\[uU](?![0-9a-fA-F]{4})/.test(literal)) return true; + return /\\[A-Za-z]{2,}(?:\\|$)/.test(literal); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function unsafePowerShellRegexLiteralMessage(literal: string): string { + return [ + "Blocked fragile PowerShell command before execution.", + "", + "PowerShell -match treats the right-hand string as regex, not a literal substring.", + `The pattern contains a backslash: ${literal}`, + "Windows path fragments such as \\p or \\U can break regex parsing or match the wrong text.", + "", + "Use one of these safer forms for literal matching:", + ' $_.CommandLine.Contains("literal\\path")', + ' $_.CommandLine -like "*literal*path*"', + ' $pattern = [regex]::Escape("literal\\path"); $_.CommandLine -match $pattern', + ].join("\n"); +} + +async function runNativeShell( + input: BashToolInput, + context: ToolContext, + timeout: number, +): Promise { + const shellCommand = resolveRunnableShellCommand(context.shell); + const unsafeLiteral = shellCommand.mode === "powershell" + ? findUnsafePowerShellRegexLiteral(input.command) + : undefined; + + if (unsafeLiteral !== undefined) { + return { + content: [{ type: "text", text: unsafePowerShellRegexLiteralMessage(unsafeLiteral) }], + isError: true, + }; + } + + return new Promise((resolve) => { + const output: Buffer[] = []; + let settled = false; + let timedOut = false; + const child = spawn(shellCommand.command, [...shellCommand.args, input.command], { + cwd: context.cwd, + env: process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + const timeoutHandle = timeout > 0 + ? setTimeout(() => { + timedOut = true; + killChildProcess(child); + }, timeout * 1000) + : undefined; + + const finish = (response: ToolResponse) => { + if (settled) return; + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + resolve(response); + }; + + child.stdout.on("data", (data: Buffer) => output.push(data)); + child.stderr.on("data", (data: Buffer) => output.push(data)); + + child.on("error", (error) => { + finish({ content: formatToolError(error), isError: true }); + }); + + child.on("close", (exitCode) => { + const text = Buffer.concat(output).toString("utf8").trimEnd(); + const outputText = text || "(no output)"; + if (timedOut) { + finish({ + content: [{ type: "text", text: `${text ? `${text}\n\n` : ""}Command timed out after ${timeout} seconds` }], + isError: true, + }); + return; + } + + if (exitCode !== 0 && exitCode !== null) { + finish({ + content: [{ type: "text", text: `${outputText}\n\nCommand exited with code ${exitCode}` }], + isError: true, + }); + return; + } + + finish({ content: [{ type: "text", text: outputText }] }); + }); + }); +} + async function runTool( execute: (input: TInput) => Promise>, input: TInput, @@ -119,8 +342,14 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext } export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { - const tool = createBashTool(context.cwd); const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300); + const shellMode = resolveShellMode(context.shell); + + if (shellMode !== "bash") { + return runNativeShell(input, context, timeout); + } + + const tool = createBashTool(context.cwd); return runTool((params) => tool.execute("run_shell", params), { command: input.command, diff --git a/src/server.ts b/src/server.ts index bfcd7afc..bd71967a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -185,8 +185,9 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + const shellLabel = process.platform === "win32" && config.shell !== "bash" ? "shell" : "Bash"; const inspection = config.minimalTools - ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` + ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with ${shellLabel} command-line tools for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; const skills = config.skillsEnabled @@ -1176,10 +1177,12 @@ function createMcpServer( server, toolNames.shell, { - title: config.toolNaming === "short" ? "Bash" : "Run shell", + title: config.toolNaming === "short" && !(process.platform === "win32" && config.shell !== "bash") + ? "Bash" + : "Run shell", description: config.minimalTools - ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` - : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, + ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools appropriate for the configured shell for those read-only inspection actions. In PowerShell, use .Contains() or -like for literal Windows path matching; use -match only for real regex or with [regex]::Escape() for literal text. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` + : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. In PowerShell, use .Contains() or -like for literal Windows path matching; use -match only for real regex or with [regex]::Escape() for literal text. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, inputSchema: { workspaceId: z .string() @@ -1187,7 +1190,7 @@ function createMcpServer( command: z .string() .describe( - `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes. In PowerShell, do not use -match with raw Windows path literals; use .Contains(), -like, or [regex]::Escape().`, ), workingDirectory: z .string() @@ -1216,6 +1219,7 @@ function createMcpServer( const response = await runShellTool(input, { cwd, root: workspace.root, + shell: config.shell, }); if (response.isError) {