Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 3 additions & 74 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import {
Expand Down Expand Up @@ -46,6 +45,7 @@ import { classifyPermissionDecision, resolvePermissionRequest } from "./permissi
import { textPrompt } from "./prompt-content.js";
import { extractRuntimeSessionId } from "./runtime-session-id.js";
import { TimeoutError, withTimeout } from "./session-runtime-helpers.js";
import { buildSpawnCommandOptions } from "./spawn-command-options.js";
import { TerminalManager } from "./terminal.js";
import type {
AcpClientOptions,
Expand All @@ -55,6 +55,8 @@ import type {
PromptInput,
} from "./types.js";

export { buildSpawnCommandOptions };

type CommandParts = {
command: string;
args: string[];
Expand Down Expand Up @@ -301,79 +303,6 @@ function isCopilotAcpCommand(command: string, args: readonly string[]): boolean
return basenameToken(command) === "copilot" && args.includes("--acp");
}

function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
return matchedKey ? env[matchedKey] : undefined;
}

function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const extensions = (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
const commandExtension = path.extname(command);
const candidates =
commandExtension.length > 0
? [command]
: extensions.map((extension) => `${command}${extension}`);
const hasPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command);

if (hasPath) {
return candidates.find((candidate) => fs.existsSync(candidate));
}

const pathValue = readWindowsEnvValue(env, "PATH");
if (!pathValue) {
return undefined;
}

for (const directory of pathValue.split(";")) {
const trimmedDirectory = directory.trim();
if (trimmedDirectory.length === 0) {
continue;
}
for (const candidate of candidates) {
const resolved = path.join(trimmedDirectory, candidate);
if (fs.existsSync(resolved)) {
return resolved;
}
}
}

return undefined;
}

function shouldUseWindowsBatchShell(
command: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (platform !== "win32") {
return false;
}
const resolvedCommand = resolveWindowsCommand(command, env) ?? command;
const ext = path.extname(resolvedCommand).toLowerCase();
return ext === ".cmd" || ext === ".bat";
}

export function buildSpawnCommandOptions(
command: string,
options: Parameters<typeof spawn>[2],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Parameters<typeof spawn>[2] {
if (!shouldUseWindowsBatchShell(command, platform, env)) {
return options;
}
return {
...options,
shell: true,
};
}

function resolveGeminiAcpStartupTimeoutMs(): number {
const raw = process.env.ACPX_GEMINI_ACP_STARTUP_TIMEOUT_MS;
if (typeof raw === "string" && raw.trim().length > 0) {
Expand Down
76 changes: 76 additions & 0 deletions src/spawn-command-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
return matchedKey ? env[matchedKey] : undefined;
}

function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const extensions = (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
const commandExtension = path.extname(command);
const candidates =
commandExtension.length > 0
? [command]
: extensions.map((extension) => `${command}${extension}`);
const hasPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command);

if (hasPath) {
return candidates.find((candidate) => fs.existsSync(candidate));
}

const pathValue = readWindowsEnvValue(env, "PATH");
if (!pathValue) {
return undefined;
}

for (const directory of pathValue.split(";")) {
const trimmedDirectory = directory.trim();
if (trimmedDirectory.length === 0) {
continue;
}
for (const candidate of candidates) {
const resolved = path.join(trimmedDirectory, candidate);
if (fs.existsSync(resolved)) {
return resolved;
}
}
}

return undefined;
}

function shouldUseWindowsBatchShell(
command: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (platform !== "win32") {
return false;
}
const resolvedCommand = resolveWindowsCommand(command, env) ?? command;
const ext = path.extname(resolvedCommand).toLowerCase();
return ext === ".cmd" || ext === ".bat";
}

export function buildSpawnCommandOptions(
command: string,
options: Parameters<typeof spawn>[2],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Parameters<typeof spawn>[2] {
if (!shouldUseWindowsBatchShell(command, platform, env)) {
return options;
}
return {
...options,
shell: true,
};
}
31 changes: 22 additions & 9 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
} from "@agentclientprotocol/sdk";
import { PermissionDeniedError, PermissionPromptUnavailableError } from "./errors.js";
import { promptForPermission } from "./permission-prompt.js";
import { buildSpawnCommandOptions } from "./spawn-command-options.js";
import type { ClientOperation, NonInteractivePermissionPolicy, PermissionMode } from "./types.js";

const DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024;
Expand All @@ -40,6 +41,14 @@ export type TerminalManagerOptions = {
killGraceMs?: number;
};

type TerminalSpawnOptions = {
cwd: string;
env: NodeJS.ProcessEnv | undefined;
stdio: ["ignore", "pipe", "pipe"];
shell?: true;
windowsHide: true;
};

function nowIso(): string {
return new Date().toISOString();
}
Expand All @@ -62,20 +71,24 @@ function toEnvObject(env: CreateTerminalRequest["env"]): NodeJS.ProcessEnv | und
}

export function buildTerminalSpawnOptions(
command: string,
cwd: string,
env: CreateTerminalRequest["env"],
): {
cwd: string;
env: NodeJS.ProcessEnv | undefined;
stdio: ["ignore", "pipe", "pipe"];
windowsHide: true;
} {
return {
platform: NodeJS.Platform = process.platform,
): TerminalSpawnOptions {
const resolvedEnv = toEnvObject(env);
const options: TerminalSpawnOptions = {
cwd,
env: toEnvObject(env),
env: resolvedEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
};
return buildSpawnCommandOptions(
command,
options,
platform,
resolvedEnv ?? process.env,
) as TerminalSpawnOptions;
}

function trimToUtf8Boundary(buffer: Buffer, limit: number): Buffer {
Expand Down Expand Up @@ -180,7 +193,7 @@ export class TerminalManager {
const proc = spawn(
params.command,
params.args ?? [],
buildTerminalSpawnOptions(params.cwd ?? this.cwd, params.env),
buildTerminalSpawnOptions(params.command, params.cwd ?? this.cwd, params.env),
);
await waitForSpawn(proc);

Expand Down
50 changes: 49 additions & 1 deletion test/spawn-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test("buildAgentSpawnOptions hides Windows console windows and preserves auth en
});

test("buildTerminalSpawnOptions hides Windows console windows and maps env entries", () => {
const options = buildTerminalSpawnOptions("/tmp/acpx-terminal", [
const options = buildTerminalSpawnOptions("node", "/tmp/acpx-terminal", [
{ name: "TMUX", value: "/tmp/tmux-1000/default,123,0" },
{ name: "TERM", value: "screen-256color" },
]);
Expand Down Expand Up @@ -101,3 +101,51 @@ test("buildSpawnCommandOptions keeps shell disabled for non-batch commands", asy
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test("buildTerminalSpawnOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));

try {
await fs.writeFile(path.join(tempDir, "npx.cmd"), "@echo off\r\n");

const options = buildTerminalSpawnOptions(
"npx",
"/tmp/acpx-terminal",
[
{ name: "PATH", value: tempDir },
{ name: "PATHEXT", value: ".COM;.EXE;.BAT;.CMD" },
],
"win32",
);

assert.equal(options.shell, true);
assert.deepEqual(options.stdio, ["ignore", "pipe", "pipe"]);
assert.equal(options.windowsHide, true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test("buildTerminalSpawnOptions keeps shell disabled for non-batch commands", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));

try {
await fs.writeFile(path.join(tempDir, "node.exe"), "");

const options = buildTerminalSpawnOptions(
"node",
"/tmp/acpx-terminal",
[
{ name: "PATH", value: tempDir },
{ name: "PATHEXT", value: ".COM;.EXE;.BAT;.CMD" },
],
"win32",
);

assert.equal(options.shell, undefined);
assert.deepEqual(options.stdio, ["ignore", "pipe", "pipe"]);
assert.equal(options.windowsHide, true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
Loading