diff --git a/src/shell-node.test.ts b/src/shell-node.test.ts new file mode 100644 index 0000000..4180a6a --- /dev/null +++ b/src/shell-node.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { buildArgv, createNodeShell, quoteWin32 } from './shell-node.js'; + +const argv = (strings: TemplateStringsArray, ...values: unknown[]) => + buildArgv(strings as unknown as string[], values); + +describe('buildArgv', () => { + it('splits plain text', () => { + expect(buildArgv(['git status --porcelain'], [])).toEqual(['git', 'status', '--porcelain']); + }); + + it('keeps mid-token interpolation as one argv element', () => { + expect(argv`git rev-list --max-count=${5} origin/${'main'}..HEAD`).toEqual([ + 'git', + 'rev-list', + '--max-count=5', + 'origin/main..HEAD', + ]); + }); + + it('treats a value with spaces as a single argv element', () => { + expect(argv`git commit -m ${'fix: a b c'}`).toEqual(['git', 'commit', '-m', 'fix: a b c']); + }); + + it('keeps an empty value as an argv slot', () => { + expect(argv`git commit -m ${''}`).toEqual(['git', 'commit', '-m', '']); + }); + + it('glues adjacent values', () => { + expect(argv`gh repo create ${'me'}/${'repo'} ${'public'}`).toEqual([ + 'gh', + 'repo', + 'create', + 'me/repo', + 'public', + ]); + }); + + it('tolerates leading/trailing/multiple whitespace', () => { + expect(buildArgv([' git -C ', 'x', ' '], [])).toEqual(['git', '-C', 'x']); + }); + + it('preserves newlines and percent signs inside values', () => { + expect(argv`git commit -m ${'line1\nline2 50% done'}`).toEqual([ + 'git', + 'commit', + '-m', + 'line1\nline2 50% done', + ]); + }); +}); + +describe('quoteWin32', () => { + it('quotes cmd.exe metacharacters instead of short-circuiting', () => { + expect(quoteWin32('a&&b')).toBe('"a&&b"'); + expect(quoteWin32('x|y')).toBe('"x|y"'); + }); + + it('escapes an embedded quote in MSVCRT shape', () => { + expect(quoteWin32('a"b')).toBe('"a\\"b"'); + }); + + it('doubles a backslash run before a closing quote', () => { + expect(quoteWin32('a\\"b')).toBe('"a\\\\"b"'); + }); + + it('doubles trailing backslashes before the closing quote', () => { + expect(quoteWin32('a b\\')).toBe('"a b\\\\"'); + }); +}); + +describe.skipIf(process.platform === 'win32')('createNodeShell execution', () => { + it('runs a real command and resolves .text()', async () => { + const $ = createNodeShell(); + const out = await $`echo ${'hello world'}`.text(); + expect(out.trim()).toBe('hello world'); + }); +}); diff --git a/src/shell-node.ts b/src/shell-node.ts index b32d664..bbbf3bf 100644 --- a/src/shell-node.ts +++ b/src/shell-node.ts @@ -1,43 +1,129 @@ -import { exec } from 'node:child_process'; +import { exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { PluginInput } from '@opencode-ai/plugin'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); -function shellQuote(value: unknown): string { - return `'${String(value).replaceAll("'", `'\\''`)}'`; -} +const MAX_BUFFER = 32 * 1024 * 1024; interface NodeShellCommand extends Promise<{ stdout: string; stderr: string }> { quiet: () => NodeShellCommand; text: () => Promise; } +/** + * Tokenize a `$`-tagged template into an argv array with Bun `$` semantics: + * static text splits on whitespace, and each `${}` glues into the current token + * as one argument. An empty value keeps its slot so `-m ${''}` does not + * collapse to a bare `-m` (which opens an interactive editor and hangs). + */ +export function buildArgv(strings: readonly string[], values: readonly unknown[]): string[] { + const argv: string[] = []; + let buf = ''; + let pending = false; + const flush = () => { + if (pending) { + argv.push(buf); + pending = false; + buf = ''; + } + }; + + strings.forEach((segment, i) => { + for (const chunk of segment.split(/(\s+)/)) { + if (chunk === '') continue; + if (/^\s+$/.test(chunk)) { + flush(); + continue; + } + buf += chunk; + pending = true; + } + if (i < values.length) { + buf += String(values[i]); + pending = true; + } + }); + flush(); + return argv; +} + +/** + * Canonical Windows argv quoting (MSVCRT/list2cmdline rules) for the fallback path. + * + * The short-circuit char class also covers cmd.exe execution primitives + * (`&|<>()^!`) so they are always wrapped in quotes on the `exec` path. + * `%` is left out: cmd expands `%…%` even inside double quotes, a documented + * ceiling of the shell fallback that quoting cannot close. + */ +export function quoteWin32(arg: string): string { + if (arg === '') return '""'; + if (!/[\s"&|<>()^!]/.test(arg)) return arg; + let out = '"'; + let backslashes = 0; + for (const ch of arg) { + if (ch === '\\') { + backslashes++; + continue; + } + if (ch === '"') { + out += `${'\\'.repeat(backslashes + 1)}"`; + backslashes = 0; + continue; + } + out += '\\'.repeat(backslashes) + ch; + backslashes = 0; + } + if (backslashes > 0) out += '\\'.repeat(backslashes * 2); + return `${out}"`; +} + +const quotePosix = (arg: string) => `'${arg.replaceAll("'", `'\\''`)}'`; + /** * Node `child_process` implementation of the opencode `$` template-tag shell. * * Opencode v2 plugins have no `$` helper, so the v2 entrypoint runs git/gh/op - * commands through this shim. Interpolated values are single-quote escaped, - * matching v1 behavior. `.quiet()` is a no-op kept for call-site compatibility, + * commands through this shim. Templates are tokenized by `buildArgv` (Bun `$` + * token semantics) and spawned via `execFile` with no shell, so no platform + * quoting rules apply. `.quiet()` is a no-op kept for call-site compatibility, * `.text()` resolves stdout, and awaiting the command throws on non-zero exit. * + * `.cmd`/`.bat` shims: Node refuses to spawn these shell-less (EINVAL since + * CVE-2024-27980), and bare names that only resolve via PATHEXT reject with + * ENOENT. Both cases fall back to a platform-quoted one-liner through `exec`; + * both codes surface as rejections, so a single `await`/catch covers them. The + * fallback re-enters a shell and may resolve a different binary from PATH than + * the direct spawn would. + * * Differences from Bun `$` to be aware of when debugging service failures: - * - Runs via `child_process.exec` (`/bin/sh`), not bash; keep commands POSIX. + * - No shell: `&&`, pipes, redirects, and globs in template text are passed as + * literal argv elements, not executed as operators. + * - Spawns are hidden (`windowsHide: true`) so console children do not flash + * a conhost window on Windows when the host has no attached console. * - No `cwd`/`env` options; inherits the plugin host process env. * - `maxBuffer` is 32 MiB; larger git output throws `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`. - * - Rejection is an `exec` Error (with `stdout`/`stderr` props), not a Bun ShellError. + * - Rejection is a `child_process` Error (with `stdout`/`stderr` props), not a Bun ShellError. */ export function createNodeShell(): PluginInput['$'] { const shell = (strings: TemplateStringsArray, ...values: unknown[]): NodeShellCommand => { - const command = strings.reduce( - (result, segment, index) => - result + segment + (index < values.length ? shellQuote(values[index]) : ''), - '' - ); - const execution = execAsync(command, { - maxBuffer: 32 * 1024 * 1024, - }) as unknown as NodeShellCommand; + const argv = buildArgv(strings, values); + const run = async (): Promise<{ stdout: string; stderr: string }> => { + try { + return await execFileAsync(argv[0], argv.slice(1), { + maxBuffer: MAX_BUFFER, + windowsHide: true, + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code !== 'EINVAL' && code !== 'ENOENT') throw error; + const line = argv.map(process.platform === 'win32' ? quoteWin32 : quotePosix).join(' '); + return await execAsync(line, { maxBuffer: MAX_BUFFER, windowsHide: true }); + } + }; + const execution = run() as unknown as NodeShellCommand; execution.quiet = () => execution; execution.text = async () => (await execution).stdout; return execution;