feat(core,tooling): bundle MinGit in the Windows package so exec_command always has bash (#95)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e9f032c965
commit
8dc560eedd
7 files changed
+186
-10
No files matched your search
@@ -7,22 +7,37 @@
|
||||
* 1. `PENGUIN_SHELL` (explicit executable name or path) always wins, on every platform;
|
||||
* the argument shape is inferred from its basename (see below).
|
||||
* 2. Otherwise, non-Windows uses `bash -lc` (today's behavior, bit for bit).
|
||||
* 3. On Windows, probe PATH for `bash` (Git for Windows — best compatibility with the
|
||||
* skill/prompt ecosystem, which is written for a POSIX shell), then `pwsh`
|
||||
* (PowerShell 7+), then fall back to `powershell` (Windows PowerShell 5.1, always
|
||||
* present). A `bash` that resolves into the Windows system directory is ignored: that
|
||||
* is the WSL launcher, which runs commands inside a Linux distro with a different
|
||||
* filesystem view (and fails outright when no distro is configured).
|
||||
* 3. On Windows, probe PATH for `bash` (a full Git for Windows install — best compatibility
|
||||
* with the skill/prompt ecosystem, which is written for a POSIX shell). A `bash` that
|
||||
* resolves into the Windows system directory is ignored: that is the WSL launcher, which
|
||||
* runs commands inside a Linux distro with a different filesystem view (and fails outright
|
||||
* when no distro is configured).
|
||||
* 4. Then `PENGUIN_BUNDLED_SHELL` — the MinGit bash the Windows package ships (see the
|
||||
* release workflow), advertised by the launcher shims as an absolute path. It comes
|
||||
* *after* the PATH probe on purpose: a user's own Git for Windows carries the full MSYS
|
||||
* userland (curl, tar, less, perl …), while MinGit carries ~60 core tools, so when both
|
||||
* exist theirs is the better shell. This step is what makes the shell deterministic —
|
||||
* without it, the same Agent and the same Skill behave differently on two Windows
|
||||
* machines depending on what happens to be installed.
|
||||
* 5. Only then `pwsh` (PowerShell 7+), and finally `powershell` (Windows PowerShell 5.1,
|
||||
* always present). These remain reachable for npm installs, which ship no bundle.
|
||||
*
|
||||
* Argument shapes by basename (also applied to `PENGUIN_SHELL` values):
|
||||
* - `pwsh` / `powershell` -> `-NoLogo -NoProfile -Command <cmd>`
|
||||
* - `cmd` -> `/d /s /c <cmd>`
|
||||
* - anything else -> `-lc <cmd>` (bash/zsh/sh-style login shell)
|
||||
*
|
||||
* `-lc` matters for the bundled shell: as a login shell it sources MinGit's `etc/profile`,
|
||||
* which defaults to `MSYS2_PATH_TYPE=inherit` and yields
|
||||
* `/mingw64/bin:/usr/local/bin:/usr/bin:/bin:<inherited Windows PATH>` — the bundled coreutils
|
||||
* and git first, the inherited Windows PATH still behind them, so System32's `curl.exe` and
|
||||
* `tar.exe` (which MinGit does not carry) keep resolving. Nothing has to plumb PATH by hand.
|
||||
*
|
||||
* The resolved shell's name is surfaced to the model via the session environment (the
|
||||
* `Shell:` line in the system prompt), so it knows which syntax the exec tool speaks.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** A resolved shell invocation: `spawn(command, [...args, cmd])` runs `cmd` in that shell. */
|
||||
@@ -43,6 +58,8 @@ export interface ResolveShellOptions {
|
||||
whichAll?: (cmd: string) => string[];
|
||||
/** The Windows system root (to recognize the WSL bash launcher); default `env.SystemRoot` or C:\Windows. */
|
||||
systemRoot?: string;
|
||||
/** Existence probe for the bundled shell path (injected in tests); default `fs.existsSync`. */
|
||||
exists?: (filePath: string) => boolean;
|
||||
}
|
||||
|
||||
/** Basename without a trailing .exe/.cmd/.bat/.ps1 extension, lowercased ("C:\...\pwsh.EXE" -> "pwsh"). */
|
||||
@@ -98,6 +115,7 @@ export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation {
|
||||
}
|
||||
|
||||
const whichAll = opts.whichAll ?? defaultWhichAll;
|
||||
const exists = opts.exists ?? existsSync;
|
||||
const systemRoot = opts.systemRoot ?? env.SystemRoot ?? "C:\\Windows";
|
||||
// The WSL launcher lives in <SystemRoot>\System32 (or Sysnative under WOW64); a Git for
|
||||
// Windows bash lives under the Git install dir. Only the first PATH match counts — that
|
||||
@@ -106,6 +124,13 @@ export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation {
|
||||
if (bash && !bash.toLowerCase().startsWith(systemRoot.toLowerCase() + path.win32.sep)) {
|
||||
return { command: "bash", args: ["-lc"], name: "bash" };
|
||||
}
|
||||
// The bundled MinGit bash (installed-package layout only; absent for npm installs). Reported
|
||||
// to the model as "bash" rather than its filename: MinGit installs GNU bash under the name
|
||||
// `sh`, and the Skill ecosystem targets bash, so "sh" would understate what it can run.
|
||||
const bundled = env.PENGUIN_BUNDLED_SHELL?.trim();
|
||||
if (bundled && exists(bundled)) {
|
||||
return { command: bundled, args: ["-lc"], name: "bash" };
|
||||
}
|
||||
if (whichAll("pwsh").length > 0) {
|
||||
return { command: "pwsh", args: argsForShell("pwsh"), name: "pwsh" };
|
||||
}
|
||||
|
||||
@@ -110,3 +110,79 @@ describe("resolveShell — PENGUIN_SHELL override", () => {
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell — the bundled MinGit bash (PENGUIN_BUNDLED_SHELL)", () => {
|
||||
const BUNDLED = "C:\\Users\\u\\.penguin\\git\\usr\\bin\\sh.exe";
|
||||
/** An exists() stub answering true only for the bundled path. */
|
||||
const bundledExists = (p: string) => p === BUNDLED;
|
||||
|
||||
it("is used when the machine has no bash of its own, and reports itself as bash", () => {
|
||||
// MinGit installs GNU bash under the name `sh`; the model is told "bash" because that is
|
||||
// what it is and what the Skill ecosystem targets — "sh" would understate it.
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
whichAll: which({ pwsh: ["C:\\pwsh.exe"] }),
|
||||
exists: bundledExists,
|
||||
});
|
||||
expect(shell).toEqual({ command: BUNDLED, args: ["-lc"], name: "bash" });
|
||||
});
|
||||
|
||||
it("yields to a real Git for Windows on PATH (its MSYS userland is the fuller one)", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
whichAll: which({ bash: ["C:\\Program Files\\Git\\bin\\bash.exe"] }),
|
||||
exists: bundledExists,
|
||||
});
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
|
||||
it("beats pwsh and powershell — the point of bundling is that neither is reached", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
whichAll: which({ pwsh: ["C:\\pwsh.exe"], powershell: ["C:\\powershell.exe"] }),
|
||||
exists: bundledExists,
|
||||
});
|
||||
expect(shell.command).toBe(BUNDLED);
|
||||
});
|
||||
|
||||
it("still loses to an explicit PENGUIN_SHELL", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_SHELL: "pwsh", PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
exists: bundledExists,
|
||||
});
|
||||
expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
|
||||
});
|
||||
|
||||
it("a stale path (dir deleted) falls through to pwsh rather than spawning a missing exe", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
whichAll: which({ pwsh: ["C:\\pwsh.exe"] }),
|
||||
exists: () => false,
|
||||
});
|
||||
expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
|
||||
});
|
||||
|
||||
it("is ignored on POSIX (npm installs and source checkouts never set it anyway)", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "linux",
|
||||
env: { PENGUIN_BUNDLED_SHELL: BUNDLED },
|
||||
exists: bundledExists,
|
||||
});
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
|
||||
it("a blank value is ignored (unset-but-defined shims must not win)", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_BUNDLED_SHELL: " " },
|
||||
whichAll: which({ powershell: ["C:\\powershell.exe"] }),
|
||||
exists: () => true,
|
||||
});
|
||||
expect(shell.name).toBe("powershell");
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,7 @@ Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.s
|
||||
| Integrity check | Downloads are sha256-verified when the Release ships checksum assets |
|
||||
| Upgrade | Re-run the installer; it swaps `bin`/`lib`/`web`/`node` and never touches `data` |
|
||||
|
||||
- **Agent shell**: on Windows, the agent's `exec_command` prefers Git-Bash (`bash` on PATH, e.g. from [Git for Windows](https://gitforwindows.org/)) for the best compatibility with skills written for a POSIX shell, and falls back to PowerShell (`pwsh`, then `powershell`) when bash is absent. The `PENGUIN_SHELL` env var overrides the pick; the session's system prompt tells the model which shell is active.
|
||||
- **Agent shell**: on Windows, the agent's `exec_command` runs in a POSIX shell, for compatibility with skills written for one. It picks, in order: `bash` on PATH (your own [Git for Windows](https://gitforwindows.org/), preferred because it carries the full MSYS userland); then the **bundled bash** — the Windows zip ships MinGit under `git\`, so a machine with no Git for Windows still gets a POSIX shell, about sixty core utilities and `git.exe`; then PowerShell (`pwsh`, then `powershell`). The PowerShell fallback is only reached by npm installs, which bundle nothing. The `PENGUIN_SHELL` env var overrides the pick; the session's system prompt tells the model which shell is active. The bundled shell's licensing is recorded in [THIRD-PARTY-NOTICES.md](https://github.com/Prism-Shadow/penguin-harness/blob/main/THIRD-PARTY-NOTICES.md).
|
||||
- **Ctrl-C semantics**: on Windows, sending Ctrl-C to a running command session (`input_command` with `"\u0003"`) terminates the whole command session tree instead of interrupting the foreground command — Windows cannot deliver a console Ctrl-C to a piped child process, so the interrupt degrades to a hard tree kill.
|
||||
- **In-place update**: `penguin update` is not yet supported on Windows — upgrade by re-running the installer above.
|
||||
- **Config file permissions**: on POSIX, config/credential files are written with `0600` (owner-only) permissions; Windows has no such mode bits, so files fall under your profile's default NTFS ACLs.
|
||||
|
||||
@@ -59,7 +59,7 @@ penguin -v
|
||||
| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 |
|
||||
| 升级 | 重新运行安装器;只替换 `bin`/`lib`/`web`/`node`,绝不触碰 `data` |
|
||||
|
||||
- **Agent shell**:Windows 上 `exec_command` 优先使用 Git-Bash(PATH 上的 `bash`,如 [Git for Windows](https://gitforwindows.org/)),与面向 POSIX shell 编写的技能生态兼容性最好;没有 bash 时回退到 PowerShell(先 `pwsh` 后 `powershell`)。环境变量 `PENGUIN_SHELL` 可强制指定;会话的系统提示词会告知模型当前 shell。
|
||||
- **Agent shell**:Windows 上 `exec_command` 在 POSIX shell 中执行,以兼容面向 POSIX 编写的技能生态。选择顺序为:PATH 上的 `bash`(你自己安装的 [Git for Windows](https://gitforwindows.org/),优先,因为它带完整的 MSYS 工具集);其次是**内置 bash**——Windows zip 在 `git\` 下自带 MinGit,因此未安装 Git for Windows 的机器同样有 POSIX shell、约六十个核心工具和 `git.exe`;最后才是 PowerShell(先 `pwsh` 后 `powershell`)。只有经 npm 安装(不含内置包)才会走到 PowerShell。环境变量 `PENGUIN_SHELL` 可强制指定;会话的系统提示词会告知模型当前 shell。内置 shell 的许可信息见 [THIRD-PARTY-NOTICES.md](https://github.com/Prism-Shadow/penguin-harness/blob/main/THIRD-PARTY-NOTICES.md)。
|
||||
- **Ctrl-C 语义**:Windows 上向运行中的命令会话发送 Ctrl-C(`input_command` 传 `"\u0003"`)会终止整棵命令会话进程树,而不是中断前台命令——Windows 无法向管道子进程投递控制台 Ctrl-C,中断因此退化为整树强杀。
|
||||
- **就地更新**:`penguin update` 暂不支持 Windows——升级请重新运行上面的安装器。
|
||||
- **配置文件权限**:POSIX 上配置/凭据文件以 `0600`(仅属主可读写)写入;Windows 没有对应的权限位,文件遵循你用户目录的默认 NTFS ACL。
|
||||
|
||||
Reference in new issue
Block a user