Skip to content

fix(wsl): read machine output from a fenced login shell - #15040

Closed
OrcaWin wants to merge 6 commits into
OrcaWin/wsl-exec-verbatim-argvfrom
OrcaWin/wsl-clean-machine-read-output
Closed

OrcaWin wants to merge 6 commits into
OrcaWin/wsl-exec-verbatim-argvfrom
OrcaWin/wsl-clean-machine-read-output

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​246 $\color{#cf222e}{\Huge{\mathbf{−}}}$​21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​225
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​209 $\color{#cf222e}{\Huge{\mathbf{−}}}$​36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​173

Stack

Merge bottom-up; each PR is based on the one above it.

# PR Base
1 #15039 main fix(wsl): pass guest argv verbatim through --exec
2 ▶ #15040 #15039 fix(wsl): read machine output from a fenced login shell ← you are here
3 #15060 #15040 fix(git): fence buffered WSL login-shell reads
4 #15257 #15060 fix(git): run WSL git reads without a shell

ELI5

To find the user's tools, Orca runs WSL commands through the interactive login shell — the same one you get in a terminal — because that's the only shell that reads ~/.bashrc, where nvm/mise/asdf put things on PATH.

The catch: an interactive shell also prints the welcome banner. On a stock Ubuntu 24.04, with nothing customized, that's:

To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

That text goes to stdout — the same pipe Orca reads answers from. So when Orca asked "is this a file or a directory?", it got back the banner and then the answer, and the code compared the whole blob to "directory". It never matched.

This PR puts a marker around the real answer so Orca can find it, while keeping the PATH benefit.

Root cause

Reproduced on a stock distro, no user config:

$ wsl.exe -d Ubuntu-24.04 --exec bash -ilc 'git --version'
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

git version 2.43.0

buildWslLoginShellCommand selects -ilc for bash/zsh (correctly — -lc alone misses ~/.bashrc). Running the actual shipped code paths against a real distro:

caller returned today
local-worktree-filesystem.statPath "To run a command as administrator…\n\ndirectory"
local-worktree-filesystem.readPath banner prepended to the file contents it reads
runPreflightCommandInWsl banner prepended to gh --version / auth output
codex-trust-grant-host binary stamp path = the banner text (see below)

statPath does stdout.trim(), which cannot recover this, so the type never equals file/directory. These two back the worktree-removal orphan-gitdir proof (getLocalWorktreePathAccess), so the proof reads a banner instead of a .git file — they are not the file-explorer listing path.

That three call sites had each grown a different private workaround is the tell that this belongs in one place:

  • preflight-wsl-agent-detection.ts — __ORCA_AGENT_PATH__ line prefix
  • wsl-git-read-environment.ts — ORCA_WSL_GIT_READ_ENV_V1 + lastIndexOf
  • rate-limits/codex-fetcher.ts — an fd dance commented "before shell startup can … print banners"

The ones that lacked a workaround are exactly the broken ones.

The silent one

codex-trust-grant-host.buildWslCodexBinaryStamp reads the identity probe positionally — path before the first newline, version after. With the banner ahead of it the first newline lands inside the banner, so the stamp becomes path = "To run a command as administrator…" with the rest as version. Both halves are non-empty, so nothing throws. The stamp exists to detect "the Codex binary changed", so a garbage stamp reads as a changed binary and reissues the trust grant. This is the only case where the corruption is silent rather than a loud failure.

What changed

buildWslCapturedLoginShellCommand(command) returns the script plus a matching readStdout. The payload is fenced:

printf %s '<BEGIN>'
<command>
_orca_capture_status=$?
printf %s '<END>'
exit $_orca_capture_status
  • rc/motd lands before <BEGIN> and is dropped; ~/.bash_logout noise lands after <END>.
  • Exit status is preserved, so statPath's exit 2 → ENOENT mapping still works.
  • The fence carries a per-call nonce. A fixed marker would truncate cat of any file that happens to quote it — an early build did exactly that, which is why the nonce exists.
  • A payload that execs (the Codex identity probe) never writes a closing fence; the reader returns everything after the opening one.

wsl-git-read-environment drops its bespoke marker and parsing. buildWslCodexIdentityArgs becomes buildWslCodexIdentityProbe and returns the reader alongside the argv so the two cannot drift apart.

Deliberately not fenced: paths that exec into a long-running program (codex app-server, codex login, terminal shells) — stdout belongs to that program there; and availability checks that are exit-code only. The git runner's login-shell mode also feeds streaming consumers, and git already has a shell-free --exec /usr/bin/env … git fast path that is immune by construction.

Testing

New real-WSL E2E (local-worktree-filesystem-wsl-banner.wsl.test.ts, gated behind ORCA_REAL_WSL_BANNER_TEST=1, following the existing .wsl.test.ts convention). Against a live Ubuntu-24.04:

  • 5/5 pass with the fix.
  • 4/5 fail with fence-stripping disabled, with exactly the reported symptom:
    AssertionError: expected 'To run a command as administrator (us…' to be 'directory'
    

It deliberately does not modify the developer's ~/.bashrc; it compares the fenced read against a raw login-shell read, so it is meaningful on both chatty and silent distros.

The trust-grant fix is mutation-checked the same way — reverting it reproduces path: "To run a command as administrator…".

Unit tests cover banner stripping, exit-status preservation, payload-contains-a-fence, missing fence, early exit, and fence uniqueness.

This PR also fixes two tests that are currently red on main. wsl-login-shell-command.test.ts and posix-command-path-lookup.test.ts both assert a raw interactive login-shell read matches an expected value; on any machine with a stock Ubuntu they get the banner instead. The second sits in the shell contracts CI gate but skips on Linux, which is why the breakage stayed invisible.

Known remaining (follow-up, not this PR)

Five call sites hand-write bash -lc / sh -lc directly, bypassing both the shell detection and the fence — chiefly the Claude/Codex managed-auth path probes and the WSL CLI installer, which parse a path out of stdout and feed it onward as trusted.

I measured this tier rather than assuming: on stock Ubuntu-24.04 -lc is clean — the banner is -i-gated. But that cleanliness is coincidental (01-locale-fix.sh happens to sort before Z99-cloud-locale-test.sh, which has no interactive guard and writes to stdout), and login shells read ~/.bash_profile/~/.profile unconditionally — so an nvm/conda/direnv installer line or a corporate golden-image banner contaminates -lc exactly as much as -ilc:

$ env HOME=$tmp wsl.exe -d Ubuntu-24.04 --exec env HOME=$tmp bash -lc 'printf READY'
Welcome to Contoso Corp WSL image v3.2
READY

That is a real latent risk on ordinary developer machines, but it is a different tier from what this PR proves, and those sites share a common helper (buildEncodedWslBashCommand) that makes them a clean separate change. Left as a follow-up rather than widening this diff.

@OrcaWin
OrcaWin force-pushed the OrcaWin/wsl-clean-machine-read-output branch 3 times, most recently from 9c4b437 to 18b0115 Compare August 17, 2026 07:41
Orca runs WSL reads through the distro's *interactive* login shell so
PATH matches the user's own terminal (nvm, mise and asdf only install
into rc files interactive shells read). An interactive shell also runs
the distro's rc/motd, and stock Ubuntu 24.04 writes its "run a command
as administrator" hint to stdout -- no user customization required.

Every caller parsing that stream was reading the banner as data:

  statPath  -> "To run a command as administrator...\n\ndirectory"
  readPath  -> banner prepended to the contents of every file read
  preflight -> banner prepended to `gh --version` / auth output

`.trim()` cannot recover any of these, so a WSL worktree's file
explorer sees no valid entry types and file reads return junk.

Three call sites had independently grown their own marker to survive
this (`__ORCA_AGENT_PATH__`, `ORCA_WSL_GIT_READ_ENV_V1`, and a
`>/dev/null` fd dance), which is the tell that it belongs in one place.

Fence the payload once, in the shared builder, and hand callers a
reader that returns just their bytes. The fence carries a per-call
nonce so `cat`-ing a file that happens to quote a marker is not
truncated. Exit status is preserved, so the ENOENT mapping still works.

wsl-git-read-environment drops its bespoke marker and parsing.
It asserted a raw interactive login-shell read matched an absolute path,
so the distro rc banner made it fail on any stock Ubuntu. It is part of
the shell-contracts CI gate, where it skips on Linux and hid the break.
Both failure modes are silent - the command runs, exits 0, and returns
the wrong bytes - so the rules need to live somewhere a reader will
find them before writing the next wsl.exe call site.
buildWslCodexBinaryStamp reads the login shell's stdout positionally --
path before the first newline, version after -- through an interactive
login shell. On a stock Ubuntu the rc banner lands ahead of the payload,
so the first newline falls inside the banner and the stamp becomes
path="To run a command as administrator..." with the rest as version.
Both halves are non-empty, so nothing throws: the stamp is silently
wrong, and an unstable stamp reads as "the Codex binary changed" and
reissues the trust grant.

The identity script ends in `exec`, so it never writes a closing fence;
the reader returns everything after the opening one, which is exactly
this case.

buildWslCodexIdentityArgs becomes buildWslCodexIdentityProbe and returns
the reader with the argv so the two cannot drift apart. The other three
WSL Codex commands are deliberately left unfenced: availability is
exit-code only, and app-server/login hand stdout to a long-running
program.
- readStdout now takes the LAST opening fence, matching the lastIndexOf
  the wsl-git-read-environment marker used deliberately: a login shell
  can echo the command text before running it, repeating the fence.
- local-worktree-filesystem throws instead of falling back to raw stdout
  when the fence is missing. The fallback silently reinstated the bug
  being fixed -- statPath would return the banner as a file type and
  readPath would return banner+contents, with no signal. Preflight keeps
  its fallback; its matchers scan the whole blob and tolerate a prefix.
- The exit-status test asserted only that the script CONTAINS `exit $?`,
  which is true for any input and never executed those lines. It now
  runs a real distro and asserts status 2 reaches the caller, which is
  what statPath's ENOENT mapping depends on.
- Corrected the doc: a sed backreference has no `$`, so `--` never
  rewrote it. Replaced with the positional and shell-local cases that
  were measured to differ.
@OrcaWin
OrcaWin force-pushed the OrcaWin/wsl-clean-machine-read-output branch from 0e82843 to fd6b9bf Compare August 17, 2026 08:11
statPath/readPath/rm run coreutils at standard paths and shell builtins.
They need nothing from the user's PATH, so there was never a reason to
start a login shell -- and starting one is what put the distro's rc/motd
on the stdout these callers parse.

Fencing that output treated the symptom. Using a plain `sh -c` removes
the cause: no profile, no rc, no banner, by construction. The fence and
its missing-fence error go away with it.

The fence stays where it is actually needed: the three places that must
run the user's shell to resolve their PATH (the preflight CLI probe, the
WSL git environment probe, and the Codex identity probe).

Net -12 lines.
@OrcaWin

OrcaWin commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #15290. GitHub auto-closed this PR when its base branch OrcaWin/wsl-exec-verbatim-argv was deleted on merge of #15039, and it cannot be reopened. #15290 has the same head branch and the same six commits, replayed onto main.

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