Files
orca/docs/reference/wsl-command-execution.md
T
OrcaWinandOrcaWin 3a9f40ed70 fix(wsl): read machine output from a fenced login shell (#15290)
* fix(wsl): read machine output from a fenced login shell

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.

* test(wsl): fence the login-shell path-lookup boundary test

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.

* docs(wsl): record the guest command-execution contract

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.

* fix(codex): fence the WSL Codex identity probe

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.

* fix(wsl): harden the capture fence after review

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

* fix(wsl): stop running a login shell for filesystem reads

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.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:30:02 -07:00

73 lines
3.3 KiB
Markdown

# Running commands inside WSL
Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent
when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes.
## 1. Always `--exec`, never `--`
`wsl.exe -d <distro> -- <argv>` expands `$name` in **every argument** against the guest environment
before the guest runs. This is `wsl.exe` itself, not the guest shell — it happens with no shell in
the command at all:
```
$ wsl.exe -d Ubuntu-24.04 -- /usr/bin/printf %s '$HOME'
/home/you
$ wsl.exe -d Ubuntu-24.04 --exec /usr/bin/printf %s '$HOME'
$HOME
```
So under `--`, a script means something other than what it says. `awk '{print $2}'` reaches the
guest as `awk '{print }'` and prints the whole line; a positional `"$1"`, a shell local, and a
`"\$literal"` are blanked or rewritten the same way. (Expansions with no `$` are unaffected — a
`sed` backreference like `s/(a)(b)/\2\1/` survives either way.) Escaping `$` on the Windows side
cannot fix this reliably — an earlier attempt skipped every `$` preceded by a backslash, which is
exactly the case a POSIX script uses to mean a literal dollar.
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
tree and fails if the `--` form reappears.
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
## 2. Machine-read output must be fenced
Orca runs guest commands through the distro user's **interactive** login shell (`-ilc` for
bash/zsh) because that is the only shell that reads `~/.bashrc`, where `nvm`, `mise` and `asdf`
install their PATH entries. Dropping `-i` would break tool detection for those users.
The cost is that an interactive shell also runs the distro's rc/motd, and that output goes to
**stdout** — the same stream the answer arrives on. Stock Ubuntu 24.04 needs no customization to
reproduce it:
```
$ 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
```
Any caller that parses stdout must use `buildWslCapturedLoginShellCommand()`, which fences the
payload and returns a matching `readStdout`. `.trim()` does not help: the banner is a prefix, not
surrounding whitespace, so a stat probe compared against `"directory"` simply never matches.
The fence carries a per-call nonce so that `cat`-ing a file whose contents happen to quote a marker
is not truncated, and it preserves the payload's exit status so `exit 2``ENOENT` mappings keep
working.
**Do not fence a command that `exec`s into a long-running program** (`codex app-server`, an
interactive terminal). It never reaches the closing fence, and there the shell's own output either
belongs to the program or is what the user wants to see.
## Prefer no shell at all
When a caller only needs a known binary with a known environment, skip the login shell entirely and
run the binary directly:
```
wsl.exe -d <distro> --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C <dir> status
```
This is what the direct-git read path does. It is immune to both problems above by construction and
avoids paying login-shell startup on every call, which also sidesteps profiles that block or print.
Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form.