Skip to content

emrg: mount pwsh on windows instead of a bash that is not there (bash tool v2, P8) - #1548

Merged
argszero merged 4 commits into
masterfrom
feature/pwsh-windows-dialect
Sep 23, 2026
Merged

argszero merged 4 commits into
masterfrom
feature/pwsh-windows-dialect

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

Windows gets a pwsh tool instead of a bash tool 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, design bash-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 spawned bash -c — an executable Windows does not have. Every command returned it, echo ok included:

emrg.tools.bash_tool_v2: bash v2: mode=danger-full-access workdir=C:\Users\Administrator
    argv=['bash', '-c', 'echo ok']
emrg.tools.bash_tool_v2: bash v2: [WinError 2] 系统找不到指定的文件。

The host had a boundary (the ACL restricted token, P4) and no usable dialect. The old tool never had the bug — it runs cmd.exe via COMSPEC on 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 bash and mounts pwsh-local/pwsh-sandbox/tool-pwsh in 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

piece file
PowerShell executor + tool emrg/tools/pwsh_tool_v2.py (new, self-contained — it does not import the bash twin)
executable resolution chain resolve_pwsh_path(configured, env, platform) in the same file
config [sandbox] pwsh_path (emrg/config.py)
platform gate build_shell_tool at the composition site (emrg/server/daemon.py)
the roster, one definition emrg/tools/shell_dialects.py (new)
shared cache rule emrg/tools/shell_env.py (new; extracted so the two dialects share one definition)
prompt emrg/server/prompts/system.j2 renders the mounted dialect
TUI / GUI emrg/client/app.py, renderer resultPanel.ts + i18n-dicts.ts
tests tests/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.

environmentNO_COLOR/PAGER/GIT_PAGER, and deliberately no TERM: 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 → each PATH entry (quotes stripped — setx writes them) → %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe → bare pwsh. Every Windows host has 5.1, so "the executable is missing" is not a reachable state there. Probed with lstat semantics (the Store's app-execution alias is a reparse point whose target's ACL answers EACCES), and composed with ntpath — the chain describes Windows paths and must not inherit the host's; with os.path it produced C:\a/pwsh.exe on POSIX and was unassertable from the dev host.

the gatewin32pwsh, otherwise bash. Exactly one shell tool is ever registered (the registry indexes by name, so two would be two behaviours for one intent). bash_tool_v2 = false still rolls back to the frozen tool on every platform — it runs cmd.exe there — which is why this had to land before P7 deletes that tool.

the prompt no longer advertises a dialect: system.j2 renders 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 — guessing bash is 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_NAMES has 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

  • Whole Python suite: 5145 passed, 29 skipped
  • Renderer: 537 passed (tsc --noEmit clean); GUI: 139 (132 pass, 8 skip) — both agree with Agent.md, via check-node-test-count.py
  • Import check and python3 -m emrg --help clean

Five mutation arms, all killed:

arm result
delete the 5.1 fallback from the chain 3 failures
invert the platform gate 7 failures
let TERM back into the overrides 2 failures
hardcode bash back into the prompt 3 failures
drop a dialect from the injection list 1 failure

Honestly unverified

Ported, NOT measured on Windows hardware (design §14.7), and marked so where it is described:

  • the acceptance items that need a real Windows host — echo ok / $PSVersionTable.PSVersion exit 0, the 5.1 fallback on a machine without pwsh 7, and the tool table showing pwsh and not bash;
  • the two restricted-token contracts stated in the tool description (ConstrainedLanguage under read-only; named-pipe EPERM when 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.

EMRG Evolution added 2 commits September 23, 2026 17:05
… 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.
@argszero

Copy link
Copy Markdown
Owner Author

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:

emrg/gui/renderer/src/lib/resultPanel.ts:104

if (toolName === "bash") {
  const m = content.match(/(?:Created|created|Generated|generated)[^\n:]*[:]\s*([^\s\n]+)/);
  if (m) return cleanPath(m[1]);
}

This is extractFilePath — the function that registers a command's output as an artifact in the results panel. With the literal "bash", a Windows session's pwsh output is never scanned for a created file: the session works, and the panel silently shows nothing. So the GUI is not exempt; it is the same defect one layer out, and it is fixed the same way (read the roster instead of the name).

It is also not an invention relative to the key: the blueprint mirrors its renderers call for call — tool-pwsh/src/render.ts against tool-bash/src/render.ts — so "the two dialects render identically" is the key's requirement, not an EMRG addition.

Two guards come with it, because the renderer is TypeScript and cannot import the Python roster:

  • emrg/tools/... /shell_dialects.py is the one definition in Python; the renderer carries a declared copy (SHELL_TOOL_NAMES), marked at its own definition as the far side of the cross-language boundary;
  • tests/test_pwsh_tool_v2.py::test_the_renderer_knows_every_dialect fails if a dialect is missing from that copy or if either tool.<dialect>.{doing,done} phrase is absent from either locale. A missing phrase renders the raw key (tool.pwsh.doing) into the tool card — which is what a Windows host would have seen had only the Python half been fixed.

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.
@argszero

Copy link
Copy Markdown
Owner Author

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.

test what it asserted why that is a platform claim
test_bash_v2_switch.py::test_the_daemon_builds_v2_by_default isinstance(tool, BashToolV2) the switch decides the family; Windows mounts PwshToolV2
::test_the_environment_switch_builds_v2_without_a_config_edit same same
test_ws_e2e.py::test_tool_intent_logged_and_ignored_by_execution server.tools.get("bash") on Windows the registry has no bash row at allAttributeError: 'NoneType' object has no attribute 'execute'

The fix is to derive the name once and never spell it — SHELL_TOOL_NAMES in the switch tests, _SHELL = shell_tool_name() in the e2e file — so the tests keep testing one fact on both legs.

Two more sites in test_ws_e2e.py were the silent half of the same defect, and they are fixed even though they did not fail: two _SlowBash stubs patched server.tools.get for the name "bash". On Windows they would have patched a name nobody calls, leaving the real (fast) tool in place — so the mid-turn-injection tests would have measured a turn that was already over, which is precisely the defect their own docstrings warn about. A test that fails loudly is better than one that passes for the wrong reason; both are the runner deciding what is measured.

Note on instrumentation: this cannot be re-measured from the dev host. The gate is decided at registry construction from sys.platform, and a platform= parameter is not a platform — patching it locally would build a Windows backend on a machine with no Windows ACL to build against. The Windows leg of CI is the instrument, which is why this fix is pushed rather than argued.

@argszero
argszero merged commit b92ed00 into master Sep 23, 2026
2 checks passed
@argszero

Copy link
Copy Markdown
Owner Author

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 check-vote-count.py reports 0/3 valid votes for head cf6f7d36. The host directed, repeatedly and explicitly (2026-09-23 09:37 report → 17:xx directive, 「优先处理rant:2026-09-23T10:07:35,处理完成这个rant后再继续」), that this rant be finished before anything else; asked whether to merge now or over three cycles, the host answered to finish it. Merging is what finishing it means, so the vote requirement is the rule this merge departs from, and this comment is where that is recorded — the same handling P3, P4, P6 and the v0.3.0 release got.

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 — echo ok included — so waiting costs real function on a real machine, and the bleeding-stop is a config edit the host has to make by hand. There is no version of "wait for votes" that is not a Windows host staying broken longer.

The landing-tree measurement (the part that substitutes for peer review, per the precedent):

gate result
vote count 0/3 valid votes — the departure, recorded here
check-pr-base.py OK base='master' - base is master
check-merge-freshness.py FRESH — master is an ancestor, and the passing run's merge base is master's tip
landing tree 6aba5334fea9c54fd516929ea18c921594aed2a8, computed by an actual git merge --squash onto master in a detached worktree
head tree 6aba5334fea9c54fd516929ea18c921594aed2a8identical, so the tree CI judged is the tree that landed
check-merge-landing-diff.py 17 paths, every one landed, nothing reading backwards
check-merge-tree-health.py HEALTHY — guard OK
check-merge-plan-suite.py suite OK: 5152 passed, 30 skipped on the final tree
CI (run 35844647026, head cf6f7d36) test ✅ and test-windows

And after the merge, re-measured rather than assumed: origin/master^{tree} is 6aba5334fea9c54fd516929ea18c921594aed2a8 — the tree that landed is the tree that was measured.

What is still unverified, and cannot be verified from here: the acceptance items that need Windows hardware — a real echo ok / $PSVersionTable.PSVersion returning exit 0, the 5.1 fallback on a machine without pwsh 7, and the tool table showing pwsh and not bash; plus the two restricted-token contracts (ConstrainedLanguage under read-only, named-pipe EPERM under either confined mode). All are marked "ported, NOT verified" in the code they describe. Merging does not make them measured, and this comment does not claim it does.

The host's Windows machine needs a release to receive this. Until one is published, the working stopgap is [sandbox] bash_tool_v2 = false in ~/.emrg/config.toml (read once at startup — restart the daemon) or EMRG_BASH_TOOL_V2=0 for a single launch; the old tool runs cmd.exe there and works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant