fix: resolve claude.exe for acpx on windows to dodge spawn einval (#554)

This commit is contained in:
gagan 2026-05-12 00:57:53 +05:30 committed by GitHub
parent 47d7100368
commit e594b667bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 1 deletions

View file

@ -1,7 +1,7 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import { createReadStream } from "fs";
import { createReadStream, existsSync, readFileSync } from "fs";
import { createInterface } from "readline";
import { execSync } from "child_process";
import { glob } from "glob";
@ -67,6 +67,44 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
'.tiff': 'image/tiff',
};
// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE
// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL).
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
// from the npm-shim layout and inject it via env so the bridge can spawn it.
function resolveClaudeExeOnWindows(): string | undefined {
const pathDirs = (process.env.PATH ?? '').split(';');
for (const dir of pathDirs) {
const trimmed = dir.trim();
if (!trimmed) continue;
const cmdPath = path.join(trimmed, 'claude.cmd');
if (!existsSync(cmdPath)) continue;
const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
if (existsSync(exeFromLayout)) return exeFromLayout;
try {
const content = readFileSync(cmdPath, 'utf-8');
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
if (relMatch) {
const resolved = path.join(trimmed, relMatch[1]);
if (existsSync(resolved)) return resolved;
}
} catch {
// ignore shim parse failures
}
}
return undefined;
}
function envForCommand(command: string): NodeJS.ProcessEnv | undefined {
if (process.platform !== 'win32') return undefined;
if (!/\bacpx\b/.test(command)) return undefined;
if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined;
const exe = resolveClaudeExeOnWindows();
if (!exe) return undefined;
return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe };
}
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
@ -963,11 +1001,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// };
// }
const envOverride = envForCommand(command);
// Use abortable version when we have a signal
if (ctx?.signal) {
const { promise, process: proc } = executeCommandAbortable(command, {
cwd: workingDir,
signal: ctx.signal,
env: envOverride,
onData: (chunk: string) => {
ctx.publish({
runId: ctx.runId,

View file

@ -144,6 +144,7 @@ export function executeCommandAbortable(
maxBuffer?: number;
signal?: AbortSignal;
onData?: (chunk: string) => void;
env?: NodeJS.ProcessEnv;
}
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
// Check if already aborted before spawning
@ -166,6 +167,7 @@ export function executeCommandAbortable(
const proc = spawn(command, [], {
shell,
cwd: options?.cwd,
env: options?.env,
detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'],
});