emrg: mount pwsh on windows instead of a bash that is not there (bash tool v2, P8) - #1548
Conversation
… tool v2, P8) v0.3.0 made the process-boundary shell the default, and on Windows that shell spawned `bash -c` — an executable Windows does not have — so every command, including `echo ok`, returned `[WinError 2]`. The boundary was there (the ACL restricted token, P4); the dialect was not. This is that dialect, ported from the blueprint's peer tool family rather than invented. Rant 2026-09-23T10:07:35, whose procedural root cause is the phase table: the design's §1.5 A3 already said the blueprint gives Windows no `bash` and mounts `pwsh-local`/`pwsh-sandbox`/`tool-pwsh` instead, but only A6 (the ACL backend) ever became a phase, so Windows received a boundary with no usable shell. What lands: - `emrg/tools/pwsh_tool_v2.py` — the PowerShell executor and tool, self-contained and deliberately not importing the bash twin (the dialects are peers): argv is `[pwsh, -NoLogo, -NoProfile, -NonInteractive, -Command, ENCODING_PREAMBLE + command]`, the command rides as ONE element (PowerShell parses it; no intermediate shell), the environment carries `NO_COLOR`/`PAGER`/`GIT_PAGER` and deliberately no `TERM` (a POSIX concept), and the framing budget is the bash twin's, pinned by a test so the two copies cannot drift. - `resolve_pwsh_path(configured, env, platform)` — the four-rung chain: explicit config, then the PowerShell 7 install directory, then each `PATH` entry (quotes stripped — `setx` writes them), then Windows PowerShell 5.1, then the bare name. Probed with `lstat` semantics, because the Store's app-execution alias is a reparse point whose target's ACL answers EACCES. Composed with `ntpath`: the chain describes Windows paths and must not inherit the host's semantics (with `os.path` it produced `C:\a/pwsh.exe` and was unassertable from POSIX). - `[sandbox] pwsh_path` — the configured executable, trusted as-is. - the platform gate, at the composition site: `win32` → `pwsh`, otherwise `bash`; exactly one shell tool is ever registered, so "mounted as one dialect, running another" is not representable. `bash_tool_v2 = false` still rolls back to the frozen tool on every platform (it runs `cmd.exe` via COMSPEC there), which is why this had to land before P7 deletes that tool. - the roster has one definition, `emrg/tools/shell_dialects.py`, read by the argument injection, the gate and the TUI — a name list two readers edit separately is a list one of them will miss, which is this phase's accident reproduced an inch to the left. - the prompt no longer advertises a dialect: `system.j2` renders the name of the tool the daemon actually registered, with a bare render getting the neutral word (guessing `bash` is the defect itself). - TUI and GUI: the argument formatter and the renderer's path extractor read the roster instead of the literal `bash`; the renderer carries its own declared copy (TypeScript cannot import a Python constant) with the two `tool.pwsh.*` phrases in both locales, and a guard pins that neither side can lose a dialect. Verified: whole suite 5145 passed / 29 skipped; renderer 537 and GUI 139 (both runners agree with Agent.md); five mutation arms all killed — the 5.1 fallback removed (3 failures), the gate inverted (7), `TERM` leaked into the overrides (2), the prompt hardcoded back to `bash` (3), and a dialect dropped from the injection list (1). Ported, NOT verified on Windows hardware (design §14.7): the restricted-token contracts stated in the tool description (ConstrainedLanguage under read-only; named-pipe EPERM under either confined mode) and the acceptance items that need a real Windows host — `echo ok` exit 0, the 5.1 fallback on a machine without pwsh 7, and the tool table showing `pwsh` and not `bash`. They are marked as such where they are described.
The P8 commit staged Agent.md before the per-file fix, so the pushed tree had a Renderer headline of 537 with a per-file breakdown that still summed to 536 and named 16 resultPanel tests where the file defines 17 — exactly the compensating edit tests/test_doc_counts.py::test_renderer_breakdown_matches_static_counts exists to refuse. Measured, not assumed: the guard fails on the parent commit and passes here.
|
One measurement corrects the rant's own reconnaissance, recorded here because it changed what this PR touches. The rant's §五 item 7 says the GUI needs no change: "GUI 生产代码已核对无 bash 特例,不用改". That is not what the tree says — the renderer has one, in production code:
if (toolName === "bash") {
const m = content.match(/(?:Created|created|Generated|generated)[^\n::]*[::]\s*([^\s\n]+)/);
if (m) return cleanPath(m[1]);
}This is It is also not an invention relative to the key: the blueprint mirrors its renderers call for call — Two guards come with it, because the renderer is TypeScript and cannot import the Python roster:
|
Three tests failed on the Windows leg of CI — all of them mine, all of them the same defect class: they asserted *this* host's answer while claiming to test the product. `bash_ci` on the ubuntu leg was green throughout, so nothing was wrong with the change itself; the tests were measuring the runner. - `test_bash_v2_switch.py::test_the_daemon_builds_v2_by_default` and `::test_the_environment_switch_builds_v2_without_a_config_edit` asserted `isinstance(tool, BashToolV2)`. What the switch decides is the *family* — a process-boundary executor rather than the frozen scan — and Windows mounts `PwshToolV2`; naming the bash class made the test an assertion about the platform. Both now assert the family and name `SHELL_TOOL_NAMES`. - `test_ws_e2e.py` hardcoded `"bash"` in its fake LLM tool calls and then looked that name up in the registry, which on Windows has no `bash` row at all (`AttributeError: 'NoneType' object has no attribute 'execute'`). The file now derives the name once, `_SHELL = shell_tool_name()`, and every *dispatched* call and lookup uses it. Two `_SlowBash` stubs keyed on the same literal are renamed `_SlowShell` for the same reason: on Windows they patched a name nobody called, so the test would have measured a turn that was already over — which is the exact defect their own docstrings warn about. The two remaining `"bash"` spellings in that file are seeded *history* fixtures, not dispatch: replay does not require a registered tool, and the fixture's own assertion reads the same literal back. Seeded-history and dev-host runs cannot measure this (the gate is decided at registry construction, and a `platform=` parameter is not a platform), so the Windows leg of CI is the instrument: local 5145 passed / 29 skipped, and the three named tests are re-run there.
|
The Windows leg of the first run failed — three tests, all of them mine, all of them the same defect class: they asserted this host's answer while claiming to test the product. The ubuntu leg was green throughout, so the change itself was not the problem; the tests were measuring the runner.
The fix is to derive the name once and never spell it — Two more sites in Note on instrumentation: this cannot be re-measured from the dev host. The gate is decided at registry construction from |
|
Merged under host authority, with one procedural departure stated rather than hidden. The departure: this PR was merged with 0 of the 3 required votes. The standing rule is three consecutive ✅ from different cycles, and Why it should not wait for three cycles: it repairs a shipped regression. v0.3.0 (an hour earlier) left every Windows host unable to run any command at all — The landing-tree measurement (the part that substitutes for peer review, per the precedent):
And after the merge, re-measured rather than assumed: What is still unverified, and cannot be verified from here: the acceptance items that need Windows hardware — a real The host's Windows machine needs a release to receive this. Until one is published, the working stopgap is |
What
Windows gets a
pwshtool instead of abashtool that cannot run there. Ported from the blueprint's peer tool family (pwsh-local+pwsh-sandbox+tool-pwsh), not invented.Rant
2026-09-23T10:07:35, designbash-tool-v2-design.md§14 / P8.The accident
v0.3.0 turned the process-boundary shell on by default (
[sandbox] bash_tool_v2 = true), and on Windows that tool spawnedbash -c— an executable Windows does not have. Every command returned it,echo okincluded:The host had a boundary (the ACL restricted token, P4) and no usable dialect. The old tool never had the bug — it runs
cmd.exeviaCOMSPECon Windows — so this was not "Windows was always broken": P6 swapped a shell that exists for one that does not, and the gate that would have caught it was missing.Procedural root cause (the part worth fixing beyond the symptom): the design's §1.5 A3 already said the blueprint gives Windows no
bashand mountspwsh-local/pwsh-sandbox/tool-pwshin its place. But the phase table only ever scheduled A6 (the ACL backend → P4). A3 never became a phase, so Windows received the boundary without the dialect. P1–P6 were green, the tests were green, and the real machine is where it broke.What lands
emrg/tools/pwsh_tool_v2.py(new, self-contained — it does not import the bash twin)resolve_pwsh_path(configured, env, platform)in the same file[sandbox] pwsh_path(emrg/config.py)build_shell_toolat the composition site (emrg/server/daemon.py)emrg/tools/shell_dialects.py(new)emrg/tools/shell_env.py(new; extracted so the two dialects share one definition)emrg/server/prompts/system.j2renders the mounted dialectemrg/client/app.py, rendererresultPanel.ts+i18n-dicts.tstests/test_pwsh_tool_v2.py(new, 33)argv —
[pwsh, -NoLogo, -NoProfile, -NonInteractive, -Command, ENCODING_PREAMBLE + command]. The command rides as one element: PowerShell parses it, there is no intermediate shell to escape for. The encoding preamble pins UTF-8 because Windows PowerShell 5.1 writes the OEM code page by default.environment —
NO_COLOR/PAGER/GIT_PAGER, and deliberately noTERM: that is a POSIX concept, and the blueprint gives it to the bash executor and withholds it here.resolution chain (in order): explicit config →
%ProgramFiles%\PowerShell\7\pwsh.exe→ eachPATHentry (quotes stripped —setxwrites them) →%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe→ barepwsh. Every Windows host has 5.1, so "the executable is missing" is not a reachable state there. Probed withlstatsemantics (the Store's app-execution alias is a reparse point whose target's ACL answersEACCES), and composed withntpath— the chain describes Windows paths and must not inherit the host's; withos.pathit producedC:\a/pwsh.exeon POSIX and was unassertable from the dev host.the gate —
win32→pwsh, otherwisebash. Exactly one shell tool is ever registered (the registry indexes by name, so two would be two behaviours for one intent).bash_tool_v2 = falsestill rolls back to the frozen tool on every platform — it runscmd.exethere — which is why this had to land before P7 deletes that tool.the prompt no longer advertises a dialect:
system.j2renders the name of the tool the daemon actually registered (not one re-derived from the platform, which is the one configuration that admits two answers), and a bare render gets the neutral word — guessingbashis the defect itself.Why the roster is a module
The argument injection, the gate and the TUI all need to know which dialects exist. A name list two readers edit separately is a list one of them will miss — and that is this phase's accident reproduced an inch to the left, so
SHELL_TOOL_NAMEShas one definition and every reader imports it. The renderer is TypeScript and cannot: it carries a declared copy, marked as such, with a guard pinning that neither side can lose a dialect or its phrases.Verification
tsc --noEmitclean); GUI: 139 (132 pass, 8 skip) — both agree withAgent.md, viacheck-node-test-count.pypython3 -m emrg --helpcleanFive mutation arms, all killed:
TERMback into the overridesbashback into the promptHonestly unverified
Ported, NOT measured on Windows hardware (design §14.7), and marked so where it is described:
echo ok/$PSVersionTable.PSVersionexit 0, the 5.1 fallback on a machine without pwsh 7, and the tool table showingpwshand notbash;ConstrainedLanguageunderread-only; named-pipeEPERMwhen capturing a program's output under either confined mode). Their backend (emrg/sandbox/win32/token.py, P4) is itself a port.Nothing in the new tests spawns
pwsh, and nothing touches the daemon lifecycle or the upgrade chain.