Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions bin/fm-wsl-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 <directory>: 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."
Expand Down
3 changes: 3 additions & 0 deletions docs/windows-launcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions tests/fm-wsl-entry.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() { # <root>
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"
Expand Down Expand Up @@ -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
Loading