fix(codex): harden windows light install

This commit is contained in:
YeonGyu-Kim
2026-05-31 13:23:24 +09:00
parent 4e31d7df5d
commit 92bad87d84
42 changed files with 1675 additions and 23 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { runMcpStdioServer } from "./mcp";
async function main(): Promise<void> {
const [command = "mcp"] = argv.slice(2);
if (command === "mcp") {
await runMcpStdioServer(process.stdin, process.stdout);
return;
}
stderr.write("Usage: omo-git-bash [mcp]\n");
process.exitCode = 2;
}
main().catch((error: unknown) => {
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exitCode = 1;
});
@@ -0,0 +1,104 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
export const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH";
const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe";
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe";
export type GitBashSource = "not-required" | "env" | "program-files" | "program-files-x86" | "path";
export type GitBashResolution =
| {
readonly found: true;
readonly path: string | null;
readonly source: GitBashSource;
}
| {
readonly found: false;
readonly checkedPaths: readonly string[];
readonly installHint: string;
};
export interface GitBashResolverInput {
readonly platform: string;
readonly env: { readonly [key: string]: string | undefined };
readonly exists: (path: string) => boolean;
readonly where: (command: "bash") => readonly string[];
}
export function resolveGitBash(input: GitBashResolverInput): GitBashResolution {
if (input.platform !== "win32") return { found: true, path: null, source: "not-required" };
const checkedPaths: string[] = [];
const envPath = nonEmptyEnvValue(input.env, GIT_BASH_ENV_KEY);
if (envPath !== undefined) {
checkedPaths.push(envPath);
if (isBashExePath(envPath) && input.exists(envPath)) return { found: true, path: envPath, source: "env" };
return missingGitBash(checkedPaths);
}
for (const candidate of [
{ path: PROGRAM_FILES_GIT_BASH, source: "program-files" },
{ path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" },
] as const) {
checkedPaths.push(candidate.path);
if (input.exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source };
}
for (const pathCandidate of input.where("bash")) {
const candidate = pathCandidate.trim();
if (candidate.length === 0) continue;
checkedPaths.push(candidate);
if (isBashExePath(candidate) && input.exists(candidate)) return { found: true, path: candidate, source: "path" };
}
return missingGitBash(checkedPaths);
}
export function resolveGitBashForCurrentProcess(input: {
readonly platform?: string;
readonly env?: { readonly [key: string]: string | undefined };
} = {}): GitBashResolution {
return resolveGitBash({
platform: input.platform ?? process.platform,
env: input.env ?? process.env,
exists: existsSync,
where: whereCommand,
});
}
function missingGitBash(checkedPaths: readonly string[]): GitBashResolution {
return {
found: false,
checkedPaths,
installHint: [
"Git Bash is required before the git_bash MCP can run commands on native Windows.",
"Install it with: winget install --id Git.Git -e --source winget",
`For a custom install, set ${GIT_BASH_ENV_KEY}=C:\\path\\to\\bash.exe`,
].join("\n"),
};
}
function nonEmptyEnvValue(env: { readonly [key: string]: string | undefined }, key: string): string | undefined {
const value = env[key];
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
function isBashExePath(path: string): boolean {
return path.toLowerCase().endsWith("bash.exe");
}
function whereCommand(command: "bash"): readonly string[] {
try {
return execFileSync("where", [command], { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch (error) {
if (error instanceof Error) return [];
throw error;
}
}
+6
View File
@@ -0,0 +1,6 @@
export { handleGitBashMcpRequest, runMcpStdioServer } from "./mcp";
export { resolveGitBash, resolveGitBashForCurrentProcess } from "./git-bash-resolver";
export { runGitBashCommand } from "./runner";
export type { GitBashMcpOptions, JsonRpcResponse } from "./mcp";
export type { GitBashResolution, GitBashResolverInput, GitBashSource } from "./git-bash-resolver";
export type { GitBashRunInput, GitBashRunResult, RunGitBashCommand } from "./runner";
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from "bun:test";
import { handleGitBashMcpRequest } from "./mcp";
import type { JsonRpcResponse } from "./mcp";
import type { RunGitBashCommand } from "./runner";
describe("git_bash MCP", () => {
it("#given simulated Windows with env override #when which_bash is called #then returns path and source", async () => {
const response = await handleGitBashMcpRequest(
{
jsonrpc: "2.0",
id: "which",
method: "tools/call",
params: { name: "which_bash", arguments: {} },
},
{
platform: "win32",
env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Tools\\Git\\bin\\bash.exe" },
exists: (path) => path === "C:\\Tools\\Git\\bin\\bash.exe",
where: () => [],
},
);
const payload = JSON.parse(textFromResponse(response)) as { readonly source: string; readonly path: string };
expect(isErrorFromResponse(response)).toBe(false);
expect(payload.source).toBe("env");
expect(payload.path).toBe("C:\\Tools\\Git\\bin\\bash.exe");
});
it("#given non-Windows platform #when diagnose is called #then reports disabled state", async () => {
const response = await handleGitBashMcpRequest(
{
jsonrpc: "2.0",
id: "diagnose",
method: "tools/call",
params: { name: "diagnose", arguments: {} },
},
{ platform: "darwin", env: {}, exists: () => false, where: () => [] },
);
const payload = JSON.parse(textFromResponse(response)) as { readonly enabled: boolean; readonly status: string };
expect(isErrorFromResponse(response)).toBe(false);
expect(payload.enabled).toBe(false);
expect(payload.status).toContain("native Windows");
});
it("#given non-Windows platform #when tools are listed #then command-running tool is hidden", async () => {
const response = await handleGitBashMcpRequest(
{ jsonrpc: "2.0", id: "tools", method: "tools/list" },
{ platform: "linux", env: {}, exists: () => false, where: () => [] },
);
expect(toolNamesFromResponse(response)).toEqual(["which_bash", "diagnose"]);
});
it("#given run call on simulated Windows #when handled #then uses resolved Git Bash with command payload", async () => {
const captured: { bashPath?: string; command?: string; cwd?: string; timeoutMs?: number } = {};
const runGitBash: RunGitBashCommand = async (input) => {
captured.bashPath = input.bashPath;
captured.command = input.command;
captured.cwd = input.cwd;
captured.timeoutMs = input.timeoutMs;
return { exitCode: 0, stdout: "ok\n", stderr: "", timedOut: false };
};
const response = await handleGitBashMcpRequest(
{
jsonrpc: "2.0",
id: "run",
method: "tools/call",
params: { name: "run", arguments: { command: "printf ok", cwd: "C:\\repo", timeout_ms: 5000 } },
},
{
platform: "win32",
env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Program Files\\Git\\bin\\bash.exe" },
exists: (path) => path === "C:\\Program Files\\Git\\bin\\bash.exe",
where: () => [],
runGitBash,
},
);
const payload = JSON.parse(textFromResponse(response)) as { readonly stdout: string };
expect(isErrorFromResponse(response)).toBe(false);
expect(payload.stdout).toBe("ok\n");
expect(captured).toEqual({
bashPath: "C:\\Program Files\\Git\\bin\\bash.exe",
command: "printf ok",
cwd: "C:\\repo",
timeoutMs: 5000,
});
});
it("#given malformed run command #when handled #then rejects without spawning", async () => {
let didRun = false;
const response = await handleGitBashMcpRequest(
{
jsonrpc: "2.0",
id: "run",
method: "tools/call",
params: { name: "run", arguments: { command: " " } },
},
{
platform: "win32",
env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Program Files\\Git\\bin\\bash.exe" },
exists: () => true,
where: () => [],
runGitBash: async () => {
didRun = true;
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
},
},
);
expect(isErrorFromResponse(response)).toBe(true);
expect(textFromResponse(response)).toContain("non-empty string");
expect(didRun).toBe(false);
});
});
function textFromResponse(response: Awaited<ReturnType<typeof handleGitBashMcpRequest>>): string {
const result = resultFromResponse(response);
const content = result?.content;
if (!Array.isArray(content)) return "";
const first = content[0];
if (typeof first !== "object" || first === null || Array.isArray(first)) return "";
const text = first.text;
return typeof text === "string" ? text : "";
}
function toolNamesFromResponse(response: Awaited<ReturnType<typeof handleGitBashMcpRequest>>): readonly string[] {
const result = resultFromResponse(response);
const tools = result?.tools;
if (!Array.isArray(tools)) return [];
return tools.flatMap((tool) => {
if (typeof tool !== "object" || tool === null || Array.isArray(tool)) return [];
return typeof tool.name === "string" ? [tool.name] : [];
});
}
function isErrorFromResponse(response: Awaited<ReturnType<typeof handleGitBashMcpRequest>>): boolean | undefined {
return booleanField(resultFromResponse(response), "isError");
}
function resultFromResponse(response: JsonRpcResponse | undefined): Record<string, unknown> | undefined {
if (response === undefined || "error" in response) return undefined;
return response.result;
}
function booleanField(record: Record<string, unknown> | undefined, key: string): boolean | undefined {
const value = record?.[key];
return typeof value === "boolean" ? value : undefined;
}
+225
View File
@@ -0,0 +1,225 @@
import type { Readable, Writable } from "node:stream";
import { resolveGitBash, resolveGitBashForCurrentProcess, type GitBashResolution } from "./git-bash-resolver";
import { runGitBashCommand, type GitBashRunResult, type RunGitBashCommand } from "./runner";
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_TIMEOUT_MS = 30 * 60_000;
export interface GitBashMcpOptions {
readonly platform?: string;
readonly env?: { readonly [key: string]: string | undefined };
readonly exists?: (path: string) => boolean;
readonly where?: (command: "bash") => readonly string[];
readonly runGitBash?: RunGitBashCommand;
}
export type JsonRpcResponse =
| {
readonly jsonrpc: "2.0";
readonly id: string | number | null;
readonly result: Record<string, unknown>;
}
| {
readonly jsonrpc: "2.0";
readonly id: string | number | null;
readonly error: {
readonly code: number;
readonly message: string;
readonly data?: unknown;
};
};
interface ToolDefinition {
readonly name: string;
readonly description: string;
readonly inputSchema: Record<string, unknown>;
}
export async function handleGitBashMcpRequest(input: unknown, options: GitBashMcpOptions = {}): Promise<JsonRpcResponse | undefined> {
if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request");
const id = jsonRpcId(input.id);
const method = typeof input.method === "string" ? input.method : null;
if (method === "initialize") {
const protocolVersion = protocolVersionFromInput(input) ?? "2024-11-05";
return successResponse(id, {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "git_bash", version: "0.1.0" },
protocolVersion,
});
}
if (method === "tools/list") return successResponse(id, { tools: toolsForPlatform(platformFromOptions(options)) });
if (method === "tools/call") {
const params = isRecord(input.params) ? input.params : {};
const name = typeof params.name === "string" ? params.name : "";
const args = isRecord(params.arguments) ? params.arguments : {};
return await callTool(id, name, args, options);
}
if (method === "notifications/initialized") return undefined;
return errorResponse(id, -32601, "Method not found");
}
export async function runMcpStdioServer(input: Readable, output: Writable, options: GitBashMcpOptions = {}): Promise<void> {
let buffer = "";
for await (const chunk of input) {
buffer += String(chunk);
while (true) {
const lineEnd = buffer.indexOf("\n");
if (lineEnd === -1) break;
const line = buffer.slice(0, lineEnd).trim();
buffer = buffer.slice(lineEnd + 1);
if (line.length === 0) continue;
const response = await handleGitBashMcpRequest(parseJsonRpcLine(line), options);
if (response !== undefined) output.write(`${JSON.stringify(response)}\n`);
}
}
}
async function callTool(id: string | number | null, name: string, args: Record<string, unknown>, options: GitBashMcpOptions): Promise<JsonRpcResponse> {
if (name === "which_bash") return toolResponse(id, whichBashPayload(resolve(options)));
if (name === "diagnose") return toolResponse(id, diagnosePayload(resolve(options), platformFromOptions(options)));
if (name === "run") return await runToolResponse(id, args, options);
return toolResponse(id, `Unknown git_bash tool: ${name}`, true);
}
async function runToolResponse(id: string | number | null, args: Record<string, unknown>, options: GitBashMcpOptions): Promise<JsonRpcResponse> {
const platform = platformFromOptions(options);
if (platform !== "win32") return toolResponse(id, "git_bash run is only available on native Windows.", true);
const command = typeof args.command === "string" ? args.command.trim() : "";
if (command.length === 0) return toolResponse(id, "run.command must be a non-empty string.", true);
const cwd = args.cwd === undefined ? undefined : typeof args.cwd === "string" && args.cwd.trim().length > 0 ? args.cwd : null;
if (cwd === null) return toolResponse(id, "run.cwd must be a non-empty string when provided.", true);
const timeoutMs = parseTimeoutMs(args.timeout_ms);
if (timeoutMs === null) return toolResponse(id, `run.timeout_ms must be an integer between 1 and ${MAX_TIMEOUT_MS}.`, true);
const resolution = resolve(options);
if (!resolution.found || resolution.path === null) return toolResponse(id, whichBashPayload(resolution), true);
try {
const run = options.runGitBash ?? runGitBashCommand;
const result = await run({ bashPath: resolution.path, command, cwd, timeoutMs, env: process.env });
return toolResponse(id, runPayload(result));
} catch (error) {
return toolResponse(id, error instanceof Error ? error.message : String(error), true);
}
}
function toolsForPlatform(platform: string): readonly ToolDefinition[] {
const sharedTools: ToolDefinition[] = [
{
name: "which_bash",
description: "Resolve the Git Bash bash.exe path used by the git_bash MCP.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "diagnose",
description: "Report whether Git Bash command execution is available on this host.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
];
if (platform !== "win32") return sharedTools;
return [
{
name: "run",
description: "Run a shell command through Git Bash on native Windows.",
inputSchema: {
type: "object",
properties: {
command: { type: "string" },
cwd: { type: "string" },
timeout_ms: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS },
},
required: ["command"],
additionalProperties: false,
},
},
...sharedTools,
];
}
function resolve(options: GitBashMcpOptions): GitBashResolution {
if (options.exists === undefined && options.where === undefined) {
return resolveGitBashForCurrentProcess({
platform: options.platform,
env: options.env,
});
}
return resolveGitBash({
platform: platformFromOptions(options),
env: options.env ?? process.env,
exists: options.exists ?? (() => false),
where: options.where ?? (() => []),
});
}
function platformFromOptions(options: GitBashMcpOptions): string {
return options.platform ?? process.platform;
}
function whichBashPayload(resolution: GitBashResolution): string {
return JSON.stringify(resolution, null, 2);
}
function diagnosePayload(resolution: GitBashResolution, platform: string): string {
const enabled = platform === "win32" && resolution.found && resolution.path !== null;
const payload = {
platform,
enabled,
status: platform === "win32" ? (enabled ? "ready" : "missing-git-bash") : "disabled: git_bash command execution is only exposed on native Windows",
resolution,
};
return JSON.stringify(payload, null, 2);
}
function runPayload(result: GitBashRunResult): string {
return JSON.stringify(result, null, 2);
}
function toolResponse(id: string | number | null, text: string, isError = false): JsonRpcResponse {
return successResponse(id, { content: [{ type: "text", text }], isError });
}
function successResponse(id: string | number | null, result: Record<string, unknown>): JsonRpcResponse {
return { jsonrpc: "2.0", id, result };
}
function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse {
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
}
function parseTimeoutMs(value: unknown): number | null {
if (value === undefined) return DEFAULT_TIMEOUT_MS;
if (!Number.isInteger(value)) return null;
const timeoutMs = Number(value);
if (timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) return null;
return timeoutMs;
}
function protocolVersionFromInput(input: Record<string, unknown>): string | null {
if (!isRecord(input.params)) return null;
return typeof input.params.protocolVersion === "string" ? input.params.protocolVersion : null;
}
function parseJsonRpcLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch (error) {
return { jsonrpc: "2.0", id: null, method: null, parseError: error instanceof Error ? error.message : String(error) };
}
}
function jsonRpcId(value: unknown): string | number | null {
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from "bun:test";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runGitBashCommand } from "./runner";
const temporaryDirectories: string[] = [];
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("Git Bash runner", () => {
it("#given fake bash executable #when command runs #then invokes bash with -lc and command payload", async () => {
const directory = createTemporaryDirectory("omo-git-bash-runner-");
const argvPath = join(directory, "argv.txt");
const fakeBashPath = join(directory, "bash.exe");
writeFileSync(
fakeBashPath,
[
"#!/bin/sh",
"printf '%s\\n' \"$@\" > \"$FAKE_BASH_ARGV_PATH\"",
"printf 'fake stdout\\n'",
"printf 'fake stderr\\n' >&2",
"exit 7",
"",
].join("\n"),
);
chmodSync(fakeBashPath, 0o755);
const result = await runGitBashCommand({
bashPath: fakeBashPath,
command: "printf ok",
cwd: directory,
timeoutMs: 5000,
env: { ...process.env, FAKE_BASH_ARGV_PATH: argvPath },
});
expect(readFileSync(argvPath, "utf8")).toBe("-lc\nprintf ok\n");
expect(result).toEqual({ exitCode: 7, stdout: "fake stdout\n", stderr: "fake stderr\n", timedOut: false });
});
});
+55
View File
@@ -0,0 +1,55 @@
import { spawn } from "node:child_process";
export interface GitBashRunInput {
readonly bashPath: string;
readonly command: string;
readonly cwd?: string;
readonly timeoutMs: number;
readonly env?: NodeJS.ProcessEnv;
}
export interface GitBashRunResult {
readonly exitCode: number | null;
readonly stdout: string;
readonly stderr: string;
readonly timedOut: boolean;
}
export type RunGitBashCommand = (input: GitBashRunInput) => Promise<GitBashRunResult>;
export async function runGitBashCommand(input: GitBashRunInput): Promise<GitBashRunResult> {
return await new Promise<GitBashRunResult>((resolve, reject) => {
const child = spawn(input.bashPath, ["-lc", input.command], {
cwd: input.cwd,
env: input.env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill();
}, input.timeoutMs);
timeout.unref();
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode) => {
clearTimeout(timeout);
resolve({ exitCode, stdout, stderr, timedOut });
});
});
}