From f471c10d6ed0614dcd9d2d8211e14af650f68b6a Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Fri, 7 Aug 2026 07:36:09 -0400 Subject: [PATCH] fix(launcher): establish the user PATH for a fresh WSL session firstmate.bat launches `wsl.exe --exec /bin/bash` so the launch never depends on the login shell, but that session's PATH carries only the system directories plus Windows interop. The menu probes each harness with `command -v`, so every harness installed under the account's private bin directories rendered as "not installed" and the Enter default fell through to whatever happened to resolve. bin/fm-wsl-entry.sh now prepends ~/.local/bin and ~/bin when they exist, before handing off to the launcher. Those are exactly the two directories the stock ~/.profile prepends and the ones every supported harness installer targets, so this restores interactive parity as a filesystem fact rather than by evaluating a login profile in a non-interactive launch. Only existing directories are added, only once, and nothing already on the inherited PATH is removed or reordered. firstmate.bat's --exec choice is unchanged. Verified through the real bridge command: `wsl.exe --cd --exec /bin/bash ./bin/fm-wsl-entry.sh --print-menu` now renders a menu byte-identical to the interactive `bin/fm-launch.sh --print-menu`, for every harness the menu lists. --- bin/fm-wsl-entry.sh | 31 ++++++++++++++++++++ docs/windows-launcher.md | 3 ++ tests/fm-wsl-entry.test.sh | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/bin/fm-wsl-entry.sh b/bin/fm-wsl-entry.sh index 9fe640722c9..733a5172fad 100755 --- a/bin/fm-wsl-entry.sh +++ b/bin/fm-wsl-entry.sh @@ -10,6 +10,20 @@ # changes there, and replaces itself with bin/fm-launch.sh. It neither depends # on the Windows launch directory nor starts a second WSL process. Every # argument and the launcher's exit status pass through unchanged. +# +# Because that launch deliberately bypasses the login shell, this script owns the +# one piece of environment the launcher cannot do without: a PATH that resolves +# the harnesses the captain actually installed. WSL hands a --exec session only +# the system directories plus Windows interop, so the menu would otherwise probe +# a PATH no interactive session ever has and report every user-installed harness +# as missing. The fix stays a filesystem fact rather than a profile evaluation: +# ~/bin and ~/.local/bin are exactly what the distribution's stock ~/.profile +# prepends, and every harness installer Firstmate supports targets ~/.local/bin, +# so reproducing those two directories restores interactive parity without +# inheriting a login profile's arbitrary side effects, ordering, or failure +# modes in a non-interactive launch. Only existing directories are added, only +# once, ahead of the system tail exactly as a login shell orders them, and +# nothing already on the inherited PATH is removed or reordered. set -u fm_wsl_entry_error() { @@ -28,6 +42,23 @@ FM_ROOT=$(CDPATH='' cd "$SCRIPT_DIR/.." 2>/dev/null && pwd -P) || { } LAUNCHER="$FM_ROOT/bin/fm-launch.sh" +# fm_wsl_entry_prepend_bin : put an existing directory at the head of +# PATH unless it is already there. +fm_wsl_entry_prepend_bin() { + local directory=$1 + [ -d "$directory" ] || return 0 + case ":${PATH-}:" in + *":$directory:"*) return 0 ;; + esac + PATH="$directory${PATH:+:$PATH}" +} + +if [ -n "${HOME-}" ]; then + fm_wsl_entry_prepend_bin "$HOME/bin" + fm_wsl_entry_prepend_bin "$HOME/.local/bin" + export PATH +fi + if [ ! -r "$LAUNCHER" ]; then fm_wsl_entry_error "cannot read $LAUNCHER." fm_wsl_entry_error "Update this Firstmate copy so bin/fm-launch.sh is present, then retry firstmate.bat." diff --git a/docs/windows-launcher.md b/docs/windows-launcher.md index d0035f54151..dd3d0383a99 100644 --- a/docs/windows-launcher.md +++ b/docs/windows-launcher.md @@ -31,12 +31,15 @@ Use the launcher's own usage text for its supported arguments. The bridge starts one WSL process, sets its working directory to the batch file's repository, selects `/bin/bash` directly, and enters through `bin/fm-wsl-entry.sh`. The WSL helper resolves the repository from its own location and replaces itself with `bin/fm-launch.sh`, so the current Windows directory and shell profiles cannot redirect the launch. +Because that session never runs a login shell, the helper puts the distribution's standard private bin directories, `~/.local/bin` and `~/bin`, on `PATH` when they exist, so the menu sees the same installed harnesses an interactive shell sees. +A harness installed somewhere else stays visible but dim, exactly as it would in a shell whose `PATH` does not carry it. ## Troubleshooting If Windows says WSL is missing, run `wsl --install`, restart Windows if prompted, finish the distribution setup, and retry. If the helper reports that `bin/fm-launch.sh` is absent, update the repository before retrying. If the menu reports a Herdr or harness problem, install or configure that dependency inside the default WSL distribution rather than only on Windows. +If the menu calls a harness uninstalled that an interactive WSL shell can run, check where that binary lives: link or install it under `~/.local/bin` so a session that starts without a login shell resolves it too. ## Maintaining this file diff --git a/tests/fm-wsl-entry.test.sh b/tests/fm-wsl-entry.test.sh index 94586c03aec..5b86d86b655 100755 --- a/tests/fm-wsl-entry.test.sh +++ b/tests/fm-wsl-entry.test.sh @@ -34,6 +34,64 @@ SH chmod +x "$1/bin/fm-launch.sh" } +# The launcher probes each menu harness with `command -v`, so the only thing the +# entry owes it is a PATH that resolves the same binaries an interactive login +# shell resolves. This launcher stand-in reports exactly that. +make_probe_launcher() { # + cat > "$1/bin/fm-launch.sh" <<'SH' +#!/usr/bin/env bash +printf 'path=[%s]\n' "$PATH" +printf 'claude=[%s]\n' "$(command -v claude 2>/dev/null || printf 'NOT-ON-PATH')" +printf 'codex=[%s]\n' "$(command -v codex 2>/dev/null || printf 'NOT-ON-PATH')" +SH + chmod +x "$1/bin/fm-launch.sh" +} + +SYSTEM_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +test_wsl_entry_resolves_user_installed_harnesses() { + local fixture="$TMP_ROOT/user bins" fake_home="$TMP_ROOT/user bins home" out + make_entry_fixture "$fixture" + make_probe_launcher "$fixture" + mkdir -p "$fake_home/.local/bin" "$fake_home/bin" + printf '#!/bin/sh\nexit 0\n' > "$fake_home/.local/bin/claude" + printf '#!/bin/sh\nexit 0\n' > "$fake_home/bin/codex" + chmod +x "$fake_home/.local/bin/claude" "$fake_home/bin/codex" + + out=$(env -i HOME="$fake_home" PATH="$SYSTEM_PATH" \ + /bin/bash "$fixture/bin/fm-wsl-entry.sh" 2>&1) \ + || fail "the WSL entry failed while establishing the launcher PATH" + + assert_contains "$out" "claude=[$fake_home/.local/bin/claude]" \ + "a fresh WSL session must resolve a harness installed in the account's ~/.local/bin" + assert_contains "$out" "codex=[$fake_home/bin/codex]" \ + "a fresh WSL session must resolve a harness installed in the account's ~/bin" + assert_contains "$out" "/usr/bin" \ + "establishing the user PATH must keep the inherited system directories" + pass "fm-wsl-entry: a fresh session resolves harnesses installed under the account's private bin directories" +} + +test_wsl_entry_user_path_is_deterministic() { + local fixture="$TMP_ROOT/user bins order" fake_home="$TMP_ROOT/user bins order home" + local out path + make_entry_fixture "$fixture" + make_probe_launcher "$fixture" + mkdir -p "$fake_home/.local/bin" + + out=$(env -i HOME="$fake_home" PATH="$fake_home/.local/bin:$SYSTEM_PATH" \ + /bin/bash "$fixture/bin/fm-wsl-entry.sh" 2>&1) \ + || fail "the WSL entry failed while establishing the launcher PATH" + path=${out#*path=[} + path=${path%%]*} + + [ "$path" = "$fake_home/.local/bin:$SYSTEM_PATH" ] \ + || fail "an already-present user bin directory must not be added twice: [$path]" + case ":$path:" in + *":$fake_home/bin:"*) fail "an absent ~/bin must never be added to the PATH" ;; + esac + pass "fm-wsl-entry: the established PATH adds only existing directories and never duplicates one" +} + test_wsl_entry_preserves_root_arguments_and_status() { local fixture="$TMP_ROOT/Firstmate repo with spaces" out status=0 make_entry_fixture "$fixture" @@ -159,5 +217,7 @@ test_checkout_pins_bridge_line_endings() { test_wsl_entry_preserves_root_arguments_and_status test_wsl_entry_missing_launcher_is_actionable +test_wsl_entry_resolves_user_installed_harnesses +test_wsl_entry_user_path_is_deterministic test_batch_constructs_one_deterministic_wsl_command test_checkout_pins_bridge_line_endings