From 8dc560eeddb724f40e63f1ae13851b1c428210e9 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Wed, 29 Jul 2026 16:49:40 +0800 Subject: [PATCH] feat(core,tooling): bundle MinGit in the Windows package so exec_command always has bash (#95) Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 25 ++++++ THIRD-PARTY-NOTICES.md | 47 ++++++++++++ install.ps1 | 7 +- .../src/environment/tools/command/shell.ts | 37 +++++++-- packages/core/test/shell-resolver.test.ts | 76 +++++++++++++++++++ packages/docs/content/installation.en.md | 2 +- packages/docs/content/installation.zh.md | 2 +- 7 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 THIRD-PARTY-NOTICES.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e59d192..e12dfe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,12 @@ on: env: # Bundled Node runtime version (official nodejs.org dist, aligned with engines >=24). NODE_RUNTIME_VERSION: v24.18.0 + # Bundled POSIX shell for the Windows package: Git for Windows' MinGit, whose usr/bin/sh.exe + # IS GNU bash (installed under the name `sh`), plus ~60 coreutils and git.exe. Pinned to an + # exact release so the shipped bytes are reproducible and the GPLv2 source offer in + # THIRD-PARTY-NOTICES.md names one version. Bump deliberately, not automatically. + MINGIT_VERSION: 2.55.0.3 + MINGIT_TAG: v2.55.0.windows.3 jobs: # Skip the build/upload when the tag's Release already exists (immutable releases forbid @@ -161,6 +167,13 @@ jobs: # to the sibling web\, prefer the bundled node\node.exe, fall back to system node). # install.ps1 verifies and unpacks this zip; in PowerShell the .ps1 shim wins over .cmd, # in cmd.exe only the .cmd is found — both forward all args and the exit code. + # + # It also bundles MinGit under git\, so exec_command has a POSIX shell even on a machine + # with no Git for Windows: the shims advertise git\usr\bin\sh.exe as PENGUIN_BUNDLED_SHELL + # and the resolver (core's shell.ts) uses it only when the user has no bash of their own. + # MinGit is unpacked with its tree intact — MSYS binaries locate /etc relative to the + # directory holding msys-2.0.dll, so `sh -lc` finds git\etc\profile and gets the usual + # /mingw64/bin:/usr/bin:, keeping System32's curl/tar reachable. - name: Package win-x64 zip run: | name="node-$NODE_RUNTIME_VERSION-win-x64" @@ -169,12 +182,22 @@ jobs: mkdir -p /tmp/node-runtime unzip -q "/tmp/$name.zip" -d /tmp/node-runtime mv "/tmp/node-runtime/$name" out/penguin/node + # MinGit unzips flat (no top-level directory), so give it its own destination. + mingit="MinGit-$MINGIT_VERSION-64-bit.zip" + curl -fsSL "https://github.com/git-for-windows/git/releases/download/$MINGIT_TAG/$mingit" -o "/tmp/$mingit" + rm -rf out/penguin/git + mkdir -p out/penguin/git + unzip -q "/tmp/$mingit" -d out/penguin/git + # Fail loudly here rather than shipping a package whose shell silently does not exist. + test -f out/penguin/git/usr/bin/sh.exe + test -f out/penguin/git/etc/profile rm -f out/penguin/bin/penguin cat > out/penguin/bin/penguin.cmd <<'EOF' @echo off setlocal set "DIR=%~dp0.." if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web" + if exist "%DIR%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe" if exist "%DIR%\node\node.exe" ( "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %* ) else ( @@ -187,6 +210,8 @@ jobs: cat > out/penguin/bin/penguin.ps1 <<'EOF' $dir = Split-Path -Parent $PSScriptRoot if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" } + $sh = Join-Path $dir "git\usr\bin\sh.exe" + if (Test-Path $sh) { $env:PENGUIN_BUNDLED_SHELL = $sh } $node = Join-Path $dir "node\node.exe" if (-not (Test-Path $node)) { $node = "node" } & $node (Join-Path $dir "lib\dist\index.js") @args diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..40d0c41 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,47 @@ +# Third-party notices + +PenguinHarness itself is licensed under Apache-2.0 (see [LICENSE](LICENSE)). Some **distributed +release artifacts** additionally bundle third-party programs, which keep their own licenses. This +file records those, and how to obtain their source. + +Nothing listed here is part of this repository — the components are downloaded by the release +workflow (`.github/workflows/release.yml`) and placed alongside the application inside the +release archives. Installing from npm (`@prismshadow/penguin-cli`) bundles none of them. + +## Node.js runtime — `node/` + +Present in every archive except `penguin-universal.tar.gz`. Downloaded unmodified from the +official distribution at . Node.js is MIT-licensed with additional +notices for its dependencies; the full text ships inside the bundle +(`node/LICENSE`, and on Windows `node/LICENSE`). + +Source: — the tag matching the bundled version, which is pinned +as `NODE_RUNTIME_VERSION` in the release workflow. + +## MinGit (Git for Windows) — `git/` + +Present in `penguin-win32-x64.zip` only. + +The Windows package bundles **MinGit**, the minimal redistributable build of Git for Windows, +unmodified, as published by the Git for Windows project. It supplies the POSIX shell that the +agent's `exec_command` runs (`git/usr/bin/sh.exe`, which is GNU bash), roughly sixty core +utilities, and `git.exe`. It is used only when the machine has no Git for Windows installation of +its own; a user-installed one always takes precedence. + +**License: GNU General Public License version 2** (with the additional per-component licenses +that Git for Windows ships). The complete license texts are included inside the bundle at +`git/LICENSE.txt` and `git/mingw64/share/licenses/`. + +Version bundled: the release attached to the Git for Windows tag pinned as `MINGIT_TAG` in the +release workflow. + +**Written offer / source availability.** The complete corresponding source code for the bundled +MinGit is published by the Git for Windows project at: + +- — repository, tagged per release +- — release assets, including the source + archives for each tag + +The bundled binaries are byte-identical to the `MinGit--64-bit.zip` asset of that tag; +no patches are applied. If you need the corresponding source and cannot obtain it from the URLs +above, open an issue on this repository and we will provide it. diff --git a/install.ps1 b/install.ps1 index dfaa1ba..d412725 100644 --- a/install.ps1 +++ b/install.ps1 @@ -10,7 +10,7 @@ # `npm install -g @prismshadow/penguin-cli` instead. # # The data dir (%USERPROFILE%\.penguin\data) sits under the install home but is never touched by -# reinstall/upgrade (which only replace bin/lib/web/node). Upgrading = re-running this installer. +# reinstall/upgrade (which only replace bin/lib/web/node/git). Upgrading = re-running this installer. # # Docs: https://penguin.ooo/docs/installation param( @@ -112,7 +112,7 @@ try { if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." } if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." } - $Dirs = @("bin", "lib", "web", "node") + $Dirs = @("bin", "lib", "web", "node", "git") $Moved = @() New-Item -ItemType Directory -Path $OldDir | Out-Null try { @@ -153,6 +153,7 @@ if (-not (Test-Path $CmdShim)) { 'setlocal' 'set "DIR=%~dp0.."' 'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"' + 'if exist "%DIR%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"' 'if exist "%DIR%\node\node.exe" (' ' "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*' ') else (' @@ -166,6 +167,8 @@ if (-not (Test-Path $Ps1Shim)) { @( '$dir = Split-Path -Parent $PSScriptRoot' 'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }' + '$sh = Join-Path $dir "git\usr\bin\sh.exe"' + 'if (Test-Path $sh) { $env:PENGUIN_BUNDLED_SHELL = $sh }' '$node = Join-Path $dir "node\node.exe"' 'if (-not (Test-Path $node)) { $node = "node" }' '& $node (Join-Path $dir "lib\dist\index.js") @args' diff --git a/packages/core/src/environment/tools/command/shell.ts b/packages/core/src/environment/tools/command/shell.ts index 0a87054..82bdd44 100644 --- a/packages/core/src/environment/tools/command/shell.ts +++ b/packages/core/src/environment/tools/command/shell.ts @@ -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` -> `/d /s /c ` * - anything else -> `-lc ` (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:` — 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 \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" }; } diff --git a/packages/core/test/shell-resolver.test.ts b/packages/core/test/shell-resolver.test.ts index 2a0bdbb..b45cdcd 100644 --- a/packages/core/test/shell-resolver.test.ts +++ b/packages/core/test/shell-resolver.test.ts @@ -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"); + }); +}); diff --git a/packages/docs/content/installation.en.md b/packages/docs/content/installation.en.md index 371b457..311da0e 100644 --- a/packages/docs/content/installation.en.md +++ b/packages/docs/content/installation.en.md @@ -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. diff --git a/packages/docs/content/installation.zh.md b/packages/docs/content/installation.zh.md index 502c5de..53195ac 100644 --- a/packages/docs/content/installation.zh.md +++ b/packages/docs/content/installation.zh.md @@ -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。