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
79 changes: 79 additions & 0 deletions packages/runtime/src/acp-agents/node/child-process-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ChildAcpProcessHost, resolveChildProcessSpawnSpec } from './child-process-host';

const tempDirs: string[] = [];

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

describe('resolveChildProcessSpawnSpec', () => {
it('wraps Windows cmd shims without enabling shell mode', () => {
expect(
resolveChildProcessSpawnSpec(
{
command: 'C:\\Users\\Test User\\AppData\\Roaming\\npm\\opencode.cmd',
args: ['acp', 'hello world', 'A&B'],
env: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
cwd: 'C:\\workspace',
},
'win32'
)
).toEqual({
command: 'C:\\Windows\\System32\\cmd.exe',
args: [
'/d',
'/s',
'/c',
'""C:\\Users\\Test User\\AppData\\Roaming\\npm\\opencode.cmd" acp "hello world" "A^&B""',
],
});
});

it('leaves native executables unchanged', () => {
expect(
resolveChildProcessSpawnSpec(
{
command: 'C:\\tools\\codex.exe',
args: ['app-server'],
env: {},
cwd: 'C:\\workspace',
},
'win32'
)
).toEqual({ command: 'C:\\tools\\codex.exe', args: ['app-server'] });
});
});

describe.runIf(process.platform === 'win32')('ChildAcpProcessHost on Windows', () => {
it('spawns a cmd shim with piped stdio', async () => {
const dir = await mkdtemp(join(tmpdir(), 'emdash-acp-cmd-shim-'));
tempDirs.push(dir);
const shim = join(dir, 'agent.cmd');
await writeFile(shim, '@echo off\r\necho ready\r\n', 'utf8');

const handle = await new ChildAcpProcessHost().spawn({
command: shim,
args: [],
env: { PATH: process.env.PATH ?? '' },
cwd: dir,
});
const output = await new Promise<string>((resolve, reject) => {
let stdout = '';
handle.stdout.setEncoding('utf8');
handle.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
handle.onError(reject);
handle.onExit((code) => {
if (code === 0) resolve(stdout.trim());
else reject(new Error(`shim exited with ${String(code)}`));
});
});

expect(output).toBe('ready');
});
});
61 changes: 59 additions & 2 deletions packages/runtime/src/acp-agents/node/child-process-host.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { win32 } from 'node:path';
import type {
AcpFs,
AcpProcessHandle,
Expand All @@ -9,6 +10,60 @@ import type {
} from '@emdash/core/acp';
import type { AcpRuntimeProcessHost } from '../runtime/types';

type ChildProcessSpawnSpec = {
command: string;
args: string[];
env: Record<string, string>;
cwd: string;
};

function getEnvValue(env: Record<string, string | undefined>, key: string): string | undefined {
const normalizedKey = key.toLowerCase();
const entry = Object.entries(env).find(
([candidate]) => candidate.toLowerCase() === normalizedKey
);
return entry?.[1];
}

function quoteForCmdExe(input: string): string {
if (input.length === 0) return '""';
if (!/[\s"^&|<>()%!]/.test(input)) return input;
return `"${input
.replace(/%/g, '%%')
.replace(/!/g, '^!')
.replace(/(["^&|<>()])/g, '^$1')}"`;
}

function wrapCmdExeCommandLine(commandLine: string): string {
return commandLine.startsWith('"') ? `"${commandLine}"` : commandLine;
}

/**
* Node cannot spawn Windows .cmd/.bat shims directly. Route only those files through
* cmd.exe and quote each argv token explicitly instead of enabling `shell: true`.
*/
export function resolveChildProcessSpawnSpec(
spec: ChildProcessSpawnSpec,
platform: NodeJS.Platform = process.platform
): Pick<ChildProcessSpawnSpec, 'command' | 'args'> {
if (platform !== 'win32') return { command: spec.command, args: spec.args };

const extension = win32.extname(spec.command).toLowerCase();
if (extension !== '.cmd' && extension !== '.bat') {
return { command: spec.command, args: spec.args };
}

const commandLine = [spec.command, ...spec.args].map(quoteForCmdExe).join(' ');
const command =
getEnvValue(spec.env, 'ComSpec') ??
getEnvValue(process.env, 'ComSpec') ??
'C:\\Windows\\System32\\cmd.exe';
return {
command,
args: ['/d', '/s', '/c', wrapCmdExeCommandLine(commandLine)],
};
}

class ChildProcessHandle implements AcpProcessHandle {
constructor(private readonly child: ReturnType<typeof spawn>) {}

Expand Down Expand Up @@ -96,7 +151,8 @@ export class ChildAcpProcessHost implements AcpRuntimeProcessHost {
env: Record<string, string>;
cwd: string;
}): Promise<AcpProcessHandle> {
const child = spawn(spec.command, spec.args, {
const resolved = resolveChildProcessSpawnSpec(spec);
const child = spawn(resolved.command, resolved.args, {
cwd: spec.cwd,
env: spec.env,
stdio: ['pipe', 'pipe', 'pipe'],
Expand All @@ -113,7 +169,8 @@ export class ChildAcpProcessHost implements AcpRuntimeProcessHost {
env: Record<string, string>;
cwd: string;
}): Promise<AcpTerminalProcess> {
const child = spawn(spec.command, spec.args, {
const resolved = resolveChildProcessSpawnSpec(spec);
const child = spawn(resolved.command, resolved.args, {
cwd: spec.cwd,
env: spec.env,
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Loading