run_command 在 macOS 上写完测完,我觉得这活儿没什么难度。spawn(shell, ['-c', cmd]),收 stdout,收 exit code,收工。
拿到 Windows 上,一条命令炸了三次,三次原因都不一样。
shell
> cd packages/core && pnpm test
标记"&&"不是此版本中的有效语句分隔符。
lua
> git status
锟斤拷锟斤拷锟斤拷锟斤拷
javascript
> pnpm -v
Error: spawn powershell.exe ENOENT
前两个是 PowerShell 5.1 的语法和编码,第三个是这台机器上根本没有 PowerShell。
第一层:&& 在 5.1 里不存在
我最初的适配器思路很直:Windows 上用 PowerShell,Linux 和 Mac 用 $SHELL。
typescript
if (platform === 'win32') {
return {
platform: 'win32',
bin: 'powershell.exe',
args: (c) => [
'-NoProfile',
'-Command',
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ${c}`,
],
label: 'powershell',
};
}
powershell.exe 是 Windows PowerShell 5.1,系统自带,任何 Windows 都有,看起来是安全的选择。
问题是 5.1 不认 &&。这个运算符是 PowerShell 7 才加的(5.1 里也有 -and,但那是逻辑与,不是命令串联)。5.1 遇到 && 直接语法错误,整条命令一个字都不执行。
这个坑有多严重,取决于谁在写命令。如果是我写,我会避开 &&。但真正写命令的是模型,它训练语料里绝大多数 shell 命令都是 bash 风格,cd x && npm test、git add . && git commit -m "..." 这种写法出现的频率极高。
所以这不是"提醒用户别用 &&"能解决的问题。要么让 shell 支持它,要么在命令进 shell 之前改写。
我选了前者:有 pwsh 7 就用 pwsh 7。
探一次就够了
typescript
let win32ShellPromise: Promise<ShellAdapter> | undefined;
export function resolveWin32Shell(): Promise<ShellAdapter> {
win32ShellPromise ??= new Promise<boolean>((resolve) => {
const probe = spawn('where.exe', ['pwsh'], { windowsHide: true, stdio: 'ignore' });
probe.on('error', () => resolve(false));
probe.on('close', (code) => resolve(code === 0));
}).then((hasPwsh) => (hasPwsh ? pwshAdapter() : detectShell('win32')));
return win32ShellPromise;
}
where.exe 就是 Windows 的 which,返回 0 表示 PATH 里有。用 spawn 不用 exec,因为不需要输出;stdio: 'ignore',因为不想让它的输出混进任何地方。
win32ShellPromise ??= 是进程级缓存。第一次调用发起探测,后续所有调用复用同一个 Promise。一个会话里 run_command 可能被叫几十次,每次都探一遍纯属浪费,而且 where.exe 自己也是个进程,几十次探测就是几十次进程创建。
跟 5.1 那个适配器比,差别只有 bin:
typescript
/** pwsh(PowerShell 7+)适配器:原生支持 && / || 管道链;UTF-8 预置同 PS 5.1。 */
export function pwshAdapter(): ShellAdapter {
return {
platform: 'win32',
bin: 'pwsh.exe',
args: (c) => [
'-NoProfile',
'-Command',
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ${c}`,
],
label: 'pwsh',
};
}
-NoProfile 是必须的。PowerShell 启动时会加载用户 profile,那里面可能有自定义函数、别名、Set-Location,甚至交互式提示符。这些东西都会污染执行环境,还拖慢启动速度,-NoProfile 全部跳过。
第二层:cp936 是双层乱码
编码这事我一开始理解错了。我以为乱码就是"输出是 GBK,我按 UTF-8 解了",改一下解码就完事。
实际是两层。
第一层,原生命令(git、node、pnpm)在中文 Windows 上按控制台代码页输出,默认 cp936,也就是 GBK。这些字节被 PowerShell 捕获时,PowerShell 按 [Console]::OutputEncoding 指定的编码去解码。那个值如果是默认的 cp936,解出来是对的;如果被设成 UTF-8,而字节实际是 GBK,解出来就是乱码。
第二层,PowerShell 自己的 stdout 也要编码。它把解出来的字符串按 [Console]::OutputEncoding 再编一遍发给管道。这个值设错,就是"乱码的乱码"。
所以适配器里那句预置不是随手加的:
ini
[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ${c}
它同时修两层:先说"进来的字节按 UTF-8 解",再说"出去的字符串按 UTF-8 编"。用分号拼在命令前面,因为 -Command 接受一整串脚本,分号是 PowerShell 的语句分隔符。
这个设置有个前提:原生命令得真的输出 UTF-8。git 有 core.quotepath 和 i18n.logOutputEncoding 这些配置,默认不一定吐 UTF-8。所以解码这端还得兜一层:
typescript
export function decodeShellOutput(buf: Buffer, platform: ShellAdapter['platform']): string {
if (buf.length === 0) return '';
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
if (platform === 'win32') {
try {
return new TextDecoder('gb18030').decode(buf);
} catch {
/* fall through to lossy utf-8 */
}
}
return buf.toString('utf8');
}
}
关键是 { fatal: true }。默认的 TextDecoder 遇到非法字节用 U+FFFD 替换然后继续,永远不抛异常,你也就永远不知道这次解码是错的。加上 fatal,只要有一个字节非法就 throw,这样 try-catch 才有意义,才能用它来判断"这次到底是不是 UTF-8"。
用 gb18030 不用 gbk:前者是后者的超集,能解更多生僻字和四字节序列,Node 的 ICU 两个都支持,没理由选小的那个。
这个 fallback 有代价。如果一段输出恰好是合法 UTF-8 但原意是 GBK,就会解错而且不报错。概率不高(GBK 的双字节序列要恰好构成合法 UTF-8 多字节序列),但存在。所以预置 OutputEncoding 才是主方案,解码回退只是兜底。
fatal: true 是整个分支的分水岭。没有它,TextDecoder 永远成功,永远走不到 E,GBK 回退那条路就是死代码。
还有个副作用:对非 win32 平台也会用 fatal: true 试一遍,失败就 buf.toString('utf8') 宽松解码。所以 macOS 上 cat 了一个 PNG 不会抛错,只是得到一堆替换字符。这个行为我刻意留着,因为 Agent 确实会误读二进制文件,不能让它把整个工具调用搞崩。
第三层:shell 本身不存在
这次最意外。有台测试机上 powershell.exe 不在 PATH 里,可能是精简版系统,也可能是 PATH 被裁过的容器。
spawn 对不存在的可执行文件不抛异常,它在子进程对象上 emit 一个 error 事件:
typescript
child.on('error', (e) => {
finish(-1, String(e.message));
});
我把 code 设成 -1,把错误信息塞进 stderr 的后缀。这样上层看两个字段就能判断"这不是命令执行失败,是 shell 根本没起来"。
降级逻辑:
typescript
const run = (a: ShellAdapter) =>
spawnOnce(command, cwd, timeoutMs, signal, a, killGraceMs).then((r) => {
const fallbackNeeded =
a.platform === 'win32' &&
r.code === -1 &&
/ENOENT|EACCES/i.test(r.stderr);
if (!fallbackNeeded) return r;
return spawnOnce(command, cwd, timeoutMs, signal, cmdAdapter(), killGraceMs);
});
三个条件全满足才降级。
code === -1 是我自己约定的"spawn 失败"信号,真实进程不会返回 -1。
platform === 'win32' 限定只在 Windows 降到 cmd。Linux 上 /bin/sh 要是不存在,降到 cmd 毫无意义,直接报错更好。
| `ENOENT | EACCES 是白名单。EACCES` 严格说不是"不存在",是"没权限执行",降到 cmd 一般也没用。但试一次的成本就是一次 spawn,比误判成"命令执行失败、exit code -1"然后让模型在那儿猜要好。 |
cmdAdapter 的参数值得单独看一眼:
typescript
export function cmdAdapter(): ShellAdapter {
return {
platform: 'win32',
bin: 'cmd.exe',
args: (c) => ['/d', '/s', '/c', c],
label: 'cmd',
};
}
/d 关掉 AutoRun,也就是注册表里那个每次启动 cmd 都会先跑的命令。装过什么工具往 AutoRun 里写了 cd /d xxx,不加 /d 的话每条命令都在那个目录下执行。
/c 是执行完就退出,不留在交互模式。
/s 管 /c 后面那串的引号处理。cmd 默认有一套自己的引号剥离启发式,遇到嵌套引号经常猜错。传 /s 等于告诉它别猜,按最简单的规则来。Node 自己在 Windows 上起 shell 用的也是 /d /s /c 这组,我照着抄的。
这也意味着 cmd 的引号规则和 PowerShell 完全不同。同一条命令从 pwsh 降到 cmd,行为可能变,尤其是带空格的路径、嵌套引号、环境变量语法。这是降级的固有代价,没有解法,只能接受"降级之后可能还是失败"。
三级降级串起来是这样:
G 那个节点就是这篇开头第一个报错。没有 pwsh 7 的机器上,模型写 cd x && npm test 就死在这儿,而且死因以"语法错误"的形式出现,看起来像是模型自己写错了命令。
两个超时,差 180 倍
这块容易混,我一开始也混了。
第一个在 spawnOnce 里面,timeoutMs 到点之后:
typescript
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
armForceResolve();
}, timeoutMs);
kill 之后不一定有结果。Windows 上 SIGKILL 只杀直接子进程,孙进程(比如 npm 拉起来的 node)还活着,还攥着 stdout 管道,close 事件永远不触发,Promise 永远不 resolve。
所以有了 armForceResolve:
typescript
const armForceResolve = () => {
clearTimeout(forceTimer);
forceTimer = setTimeout(
() => finish(timedOut ? null : (child.exitCode ?? null)),
killGraceMs,
);
forceTimer.unref?.();
};
五秒宽限,到点用已经收到的输出强制收尾。
typescript
export const KILL_FORCE_RESOLVE_MS = 5_000;
第二个在工具注册表里,是并发槽的看门狗:
typescript
const CMD_SLOT_WATCHDOG_MS = 15 * 60_000;
两个数字差 180 倍,因为管的事完全不同。
5 秒那个管"这一次 spawn 能不能收尾"。命令已经杀了,剩下的事只是收集残骸,超过 5 秒说明确实收不到。
15 分钟那个管"并发槽会不会永久泄漏"。槽位是进程级共享的,泄漏两个之后所有会话的 run_command 都会静默排队,表现出来就是"整个 Agent 卡住但不报错"。看门狗是最后一道保险,必须在任何正常路径之外,所以给了 15 分钟这么宽松的值。
一个被丢掉的字段
写这篇的时候我回看了眼代码,发现一处疏漏。
ShellSpawnResult 里有这么个字段:
typescript
export interface ShellSpawnResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
/** 实际使用的 shell(降级诊断用) */
shellUsed: string;
}
注释写着"降级诊断用"。再看调用方:
typescript
export interface ShellResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
export function runShell(
command: string,
cwd: string,
timeoutMs: number,
signal: AbortSignal,
): Promise<ShellResult> {
return runWithShell(command, cwd, timeoutMs, signal);
}
runWithShell 返回的对象里有 shellUsed,赋给 ShellResult 的时候没了。类型不兼容本该报错,但返回类型标注是 ShellResult,结构上兼容,TypeScript 允许多余的属性被丢掉。
结果是:降级确实发生了,模型和用户都不知道。
模型看到 [exit code: 1],会以为是命令本身失败,换个写法再试,大概率还失败,因为它不知道真正的原因是 && 在这个 shell 上不合法。stderr 里要是有行 [shell: cmd],它至少能推断出环境不是它以为的那个。
这是那种"功能写完了但没接上"的典型:字段加了,注释写了,用处想好了,最后一步忘了。修起来只要一行,trimOutput 里把 shellUsed 拼进去。
但我没改,因为那个输出格式被单测逐字符锁着:
typescript
expect(r.content).toBe('yz-shell-ok\n\n[exit code: 0]');
expect(r.content).toBe('\n[exit code: 1]');
toBe 加完整字符串,多一个字符就红。这些断言存在的目的就是防止我随手改格式,所以我不该为了一个诊断字段去动它们。真要加,得先改一批断言,再确认没有别的地方依赖这个格式。一件事变成三件,就搁下了。
所以现在的状态是:代码知道,日志不知道,模型更不知道。
&& 这个问题,我只是让它"在装了 pwsh 7 的机器上不出现"。没装的机器上它还在。
真正的解法是在命令进 shell 之前改写掉:&& 拆成两条命令依次执行,或者转成 PowerShell 的 ; 加 if ($?)。但改写 shell 语法风险很大,引号里的 &&、&& 出现在字符串中间、` |
和&&` 混用、管道的位置,全得考虑。写一个正确的 shell 语法改写器,比写这个 Agent 还难。 |
我选择不做。代价是 Windows 用户得装 pwsh 7,或者接受 && 用不了。这个取舍我写在文档里了,但工具的 description 里没提,模型不知道,所以它还是会写 &&。
另外还有两处我留着没动。一个是 cmdAdapter 没有 -NoProfile 的等价物,AutoRun 用 /d 关了,但 cmd 还有别的注入点(%CMDCMDLINE% 之类),我没处理,因为没遇到过实际问题。另一个就是上面那个 shellUsed,卡在单测上。
三件事都不难,都不影响主流程,所以都排在后面。写出来是想说:跨平台适配的成本不在写三份适配器,在于每一份都有自己的引号规则、编码规则和报错语法,而你只能真的在三个平台上跑一遍才知道。
代码在 github.com/lwly/delta-...。这篇涉及三块:
- shell 探测、三级降级、编码解码、kill 后强制收尾
packages/core/src/engine/shell.ts run_command的调用方与输出截断packages/core/src/tools/builtin/exec.ts(trimOutput里丢掉了shellUsed)- 并发槽与 15 分钟看门狗
packages/core/src/tools/registry.ts(CMD_SLOT_WATCHDOG_MS)
上面三件没解决的事都是代码的当前状态,不是待办清单里的设想。