同一条命令,在 Windows 上炸了三种方式

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 才是主方案,解码回退只是兜底。

graph TD A[&#34;子进程 stdout 字节&#34;] --> B[&#34;TextDecoder utf-8<br/>fatal: true&#34;] B --> C{&#34;抛出异常&#34;} C -->|否| D[&#34;按 UTF-8 直接返回<br/>正常路径&#34;] C -->|是| E{&#34;platform 是 win32&#34;} E -->|否| F[&#34;buf.toString utf-8<br/>宽松解码,产生替换字符&#34;] E -->|是| G[&#34;TextDecoder gb18030<br/>GBK 超集&#34;] G --> H[&#34;返回 GBK 解码结果&#34;] G -.->|也失败| F

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,行为可能变,尤其是带空格的路径、嵌套引号、环境变量语法。这是降级的固有代价,没有解法,只能接受"降级之后可能还是失败"。

三级降级串起来是这样:

graph TD A[&#34;run_command 进来&#34;] --> B{&#34;process.platform&#34;} B -->|win32| C[&#34;resolveWin32Shell<br/>进程级缓存&#34;] C --> D[&#34;spawn where.exe pwsh&#34;] D --> E{&#34;找到 pwsh 7&#34;} E -->|是| F[&#34;pwsh.exe -NoProfile -Command&#34;] E -->|否| G[&#34;powershell.exe 5.1<br/>不支持 &&&#34;] F --> H[&#34;spawnOnce 执行&#34;] G --> H H --> I{&#34;code 为 -1<br/>且 ENOENT 或 EACCES&#34;} I -->|是| J[&#34;降级 cmd.exe /d /s /c<br/>重试一次&#34;] I -->|否| K[&#34;返回结果&#34;] J --> K B -->|darwin| L[&#34;env.SHELL 或 /bin/zsh<br/>-c&#34;] B -->|linux 或其他| M[&#34;env.SHELL 或 /bin/sh<br/>-c&#34;] L --> K M --> K

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)

上面三件没解决的事都是代码的当前状态,不是待办清单里的设想。

相关推荐
德尔塔A1 小时前
给 Agent 写终端界面,我一个 UI 库都没装
typescript
德尔塔A1 小时前
同一个会话,终端和浏览器同时开着会发生什么
typescript
咚咚咚小柒4 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
螺丝钉tina9 小时前
[Playwright][01] 从 Puppeteer 到 Playwright:为什么选择 Playwright + TypeScript?
前端·javascript·typescript
miofly1 天前
GitHub 今日推荐|rea:用 MCP 把反编译器和 AI agent 接到一起的逆向工具
typescript·开源·github
ZoeLandia1 天前
React11:数据请求与 TanStack Query
前端·react.js·typescript·react
康小旺2 天前
AI 写 Vue 总写成同一个味道:给它装 Skills,装的不是知识是边界
vue.js·typescript
ZoeLandia2 天前
React08:自定义 Hook
开发语言·前端·react.js·typescript
aloha_7893 天前
HTML、JavaScript 与 TypeScript 基础速查
javascript·typescript·html