diff --git a/skills/pragmatic-orchestration/scripts/lib/backend_run.sh b/skills/pragmatic-orchestration/scripts/lib/backend_run.sh index 57808c2..897c9f9 100755 --- a/skills/pragmatic-orchestration/scripts/lib/backend_run.sh +++ b/skills/pragmatic-orchestration/scripts/lib/backend_run.sh @@ -293,9 +293,28 @@ if [[ -z "$BIN" ]]; then echo "Error: unknown backend '$BACKEND' for agent $AGENT_ID" >&2 exit $EXIT_CONFIG_ERROR fi -if ! command -v "$BIN" &>/dev/null && [[ ! -x "$BIN" ]]; then - echo "Error: backend CLI not found: $BIN (backend=$BACKEND)" >&2 - exit $EXIT_CONFIG_ERROR +bin_usable() { + command -v "$1" &>/dev/null || [[ -x "$1" ]] +} +if ! bin_usable "$BIN"; then + # Native Windows Python can resolve an npm .CMD shim that MSYS bash cannot + # execute. Use its extensionless sibling, keeping the resolved directory + # (and any explicit binary override) authoritative. + _bin_fallback="" + if [[ "$BIN" == *[/\\]* && "$BIN" == *.[cC][mM][dD] ]]; then + _bin_fallback="${BIN%.*}" + _bin_fallback="${_bin_fallback//\\//}" + if command -v cygpath &>/dev/null; then + _bin_fallback="$(cygpath -u "$_bin_fallback" 2>/dev/null)" || _bin_fallback="" + fi + fi + if [[ -n "$_bin_fallback" && -x "$_bin_fallback" ]]; then + BIN="$_bin_fallback" + else + echo "Error: backend CLI not found: $BIN (backend=$BACKEND)" >&2 + exit $EXIT_CONFIG_ERROR + fi + unset _bin_fallback fi # Codex model alias + default efforts are already applied by backend_contract diff --git a/skills/pragmatic-orchestration/scripts/tests/run.sh b/skills/pragmatic-orchestration/scripts/tests/run.sh index 9aed364..84905ae 100755 --- a/skills/pragmatic-orchestration/scripts/tests/run.sh +++ b/skills/pragmatic-orchestration/scripts/tests/run.sh @@ -915,6 +915,65 @@ xml=$(CLAUDE_MODEL="claude-runtime" CLAUDE_EFFORT="max" \ assert_contains "ask XML uses resolved model" "$xml" 'model="claude-runtime"' assert_contains "ask XML uses resolved effort" "$xml" 'effort="max"' +echo "=== Windows .cmd shim binary resolution ===" +# On Windows the contract resolves binaries with Python's shutil.which, which +# walks PATHEXT and returns the `foo.CMD` sibling spelled with backslashes. npm +# writes that shim without the executable bit, so bash can neither `command -v` +# the path nor exec-test it, and every npm-installed backend was rejected with a +# false "backend CLI not found". Reproduced here on any platform by pointing the +# override at such a path; the extensionless launcher is the usable one. +SHIM_DIR="$TMP/cmd-shim" +mkdir -p "$SHIM_DIR" +cp "$FAKES/fake-codex" "$SHIM_DIR/shimcodex" +chmod +x "$SHIM_DIR/shimcodex" +printf '@echo off\r\n' > "$SHIM_DIR/shimcodex.cmd" +chmod 644 "$SHIM_DIR/shimcodex.cmd" + +export PORCH_RUN_DIR="$TMP/run-ask-cmd-shim" +mkdir -p "$PORCH_RUN_DIR" +set +e +out=$(PATH="$SHIM_DIR:$PATH" PORCH_BIN_CODEX="$SHIM_DIR\\shimcodex.CMD" \ + "$PORCH" review ask --progress none -a codex "review this" \ + 2>"$TMP/ask-cmd-shim.err") +set -e +assert_contains "windows .cmd path falls back to extensionless launcher" "$out" "FAKE_CODEX_OK" +assert_not_contains "windows .cmd path is not reported missing" \ + "$(cat "$TMP/ask-cmd-shim.err")" "backend CLI not found" + +# A bare name that resolves to nothing must still fail; the fallback only +# applies to values that look like a resolved path. +set +e +PATH="$SHIM_DIR:$PATH" PORCH_BIN_CODEX="definitely-absent-cli" \ + "$PORCH" review ask --progress none -a codex "review this" \ + >"$TMP/ask-absent-bin.out" 2>"$TMP/ask-absent-bin.err" +absent_bin_rc=$? +set -e +assert_eq "missing bare binary still fails" "$absent_bin_rc" "3" + +# A missing .CMD path must not pick a different installation from PATH. +set +e +PATH="$SHIM_DIR:$PATH" PORCH_BIN_CODEX="$TMP/missing/shimcodex.CMD" \ + PORCH_RUN_DIR="$TMP/run-ask-missing-cmd" \ + "$PORCH" review ask --progress none -a codex "review this" \ + >"$TMP/ask-missing-cmd.out" 2>"$TMP/ask-missing-cmd.err" +missing_cmd_rc=$? +set -e +assert_eq "missing .CMD sibling fails instead of searching PATH" "$missing_cmd_rc" "3" +assert_not_contains "missing .CMD sibling does not run another CLI" \ + "$(cat "$TMP/ask-missing-cmd.out")" "FAKE_CODEX_OK" + +# Other extensions do not imply an npm shim, even when a sibling exists. +set +e +PATH="$SHIM_DIR:$PATH" PORCH_BIN_CODEX="$SHIM_DIR/shimcodex.exe" \ + PORCH_RUN_DIR="$TMP/run-ask-missing-exe" \ + "$PORCH" review ask --progress none -a codex "review this" \ + >"$TMP/ask-missing-exe.out" 2>"$TMP/ask-missing-exe.err" +missing_exe_rc=$? +set -e +assert_eq "missing non-.CMD binary still fails" "$missing_exe_rc" "3" +assert_not_contains "missing non-.CMD binary does not run sibling" \ + "$(cat "$TMP/ask-missing-exe.out")" "FAKE_CODEX_OK" + echo "=== Delegate with fake ===" export PORCH_RUN_DIR="$TMP/run-del" mkdir -p "$PORCH_RUN_DIR"