diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4361b05..61d0f88 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,13 +7,13 @@ }, "metadata": { "description": "Pragmatic multi-agent orchestration: independent review and steerable delegation across Codex, Claude Code, OpenCode, Grok Build, Gemini, and Devin CLI \u2014 for any agent that supports the Agent Skills standard.", - "version": "2.0.0-beta.2" + "version": "2.0.0-beta.3" }, "plugins": [ { "name": "pragmatic-orchestration", "description": "Multi-agent orchestration for coding agents: run external agent CLIs (Codex, Claude Code, OpenCode, Grok Build, Gemini, Devin CLI) as independent reviewers, stateful repository researchers, or steerable single-agent implementers via the `porch` CLI Also ships remote-agents: dedicated remote Linux/Windows agent hosts (SSH-over-SSM, headless desktop, work bridge).", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "author": { "name": "CodeAlive-AI", "email": "contact@codealive.ai" @@ -61,5 +61,5 @@ ] } ], - "version": "2.0.0-beta.2" + "version": "2.0.0-beta.3" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 59ab830..66a338d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pragmatic-orchestration", "description": "Multi-agent orchestration for coding agents: run external agent CLIs (Codex, Claude Code, OpenCode, Grok Build, Gemini, Devin CLI) as independent reviewers, stateful repository researchers, or steerable single-agent implementers via the `porch` CLI. Review ask/code modes (read-only), delegate mode (full-access, steerable, detachable), session-history search, and subscription quota checks Ships a second skill, remote-agents, for provisioning and operating dedicated remote Linux/Windows agent hosts.", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "author": { "name": "CodeAlive-AI", "email": "contact@codealive.ai" diff --git a/skills/pragmatic-orchestration/scripts/lib/backend_run.sh b/skills/pragmatic-orchestration/scripts/lib/backend_run.sh index 57808c2..896294c 100755 --- a/skills/pragmatic-orchestration/scripts/lib/backend_run.sh +++ b/skills/pragmatic-orchestration/scripts/lib/backend_run.sh @@ -125,7 +125,9 @@ unset _RESOLVE_ARGS if [[ -n "$RESOLVED_JSON" ]]; then eval "$( RESOLVED_JSON="$RESOLVED_JSON" python3 - <<'PY' -import json, os, shlex +import json, os, shlex, sys +# Native Windows Python must not add CR characters to shell assignments. +sys.stdout.reconfigure(newline='\n') d = json.loads(os.environ["RESOLVED_JSON"]) def emit(k, v): print(f"{k}={shlex.quote('' if v is None else str(v))}") @@ -293,9 +295,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/platform_test.py b/skills/pragmatic-orchestration/scripts/tests/platform_test.py index ad0c6f9..d42a1df 100644 --- a/skills/pragmatic-orchestration/scripts/tests/platform_test.py +++ b/skills/pragmatic-orchestration/scripts/tests/platform_test.py @@ -5,6 +5,7 @@ import json import os import pkgutil +import shutil import subprocess import sys import tempfile @@ -46,6 +47,92 @@ def test_all_modules_import(self): importlib.import_module(module.name) importlib.import_module("terminal_guard") + def test_backend_shell_assignments_are_lf_only_and_preserve_values(self): + bash = shutil.which("bash") + if bash is None: + self.skipTest("backend shell assignments require Bash") + # Exercise the runner's actual emitter with native Windows-style + # stdout on every platform; text=True would hide the CRLF regression. + source = (LIB / "backend_run.sh").read_text(encoding="utf-8") + marker = 'RESOLVED_JSON="$RESOLVED_JSON" python3 - <<\'PY\'\n' + self.assertEqual(source.count(marker), 1) + emitter = source.split(marker, 1)[1].split("\nPY\n", 1)[0] + resolved = { + "backend": "codex-cli", + "model": "model 'quoted' $HOME ü", + "effort": "high", + "label": "Windows test", + "role": "analyst", + "binary": r"C:\Users\Test User ü\npm\codex.CMD", + "review_instructions": "Use 'quotes' and $HOME literally.\nSecond line.", + "access_class": "readonly", + } + result = subprocess.run( + [sys.executable, "-c", + "import sys\nsys.stdout.reconfigure(newline='\\r\\n')\n" + emitter], + env=dict(self.env, RESOLVED_JSON=json.dumps(resolved)), + capture_output=True, timeout=15, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn(b"\r", result.stdout) + shell = subprocess.run( + [bash, "--noprofile", "--norc", "-c", + 'set -eu; eval "$(cat)"; printf "%s\\0" ' + '"$BACKEND" "$MODEL" "$EFFORT" "$LABEL" "$ROLE_ID" ' + '"$BIN" "$REVIEW_INSTRUCTIONS" "$ACCESS_POLICY"'], + input=result.stdout, env=self.env, capture_output=True, timeout=15, + ) + self.assertEqual(shell.returncode, 0, shell.stderr) + self.assertEqual(shell.stdout.decode("utf-8").split("\0"), + [*resolved.values(), ""]) + + def test_backend_cmd_shim_uses_only_its_extensionless_sibling(self): + bash = shutil.which("bash") + if bash is None: + self.skipTest("backend shim resolution requires Bash") + source = (LIB / "backend_run.sh").read_text(encoding="utf-8") + marker = "bin_usable() {\n" + self.assertEqual(source.count(marker), 1) + resolver = marker + source.split(marker, 1)[1].split( + "\n# Codex model alias", 1)[0] + launcher = self.root / "shimcodex" + launcher.write_bytes(b"#!/bin/sh\nprintf '%s\\n' 'CMD_SHIM_OK'\n") + launcher.chmod(0o755) + shim = launcher.with_suffix(".CMD") + shim.write_bytes(b"@echo off\r\n") + shim.chmod(0o644) + missing = self.root / "missing" / shim.name + missing.parent.mkdir() + missing.write_bytes(b"@echo off\r\n") + missing.chmod(0o644) + script = 'set -eu\nEXIT_CONFIG_ERROR=4\nBACKEND=codex-cli\n' + resolver + '\n"$BIN"\n' + # A same-named working launcher on PATH must not replace an explicitly + # selected installation whose extensionless sibling is missing. + env = dict(self.env, PATH=str(self.root) + os.pathsep + self.env["PATH"]) + for name, binary, expected_rc in ( + ("cmd sibling", shim, 0), + ("missing sibling", missing, 4), + ("non-cmd extension", launcher.with_suffix(".exe"), 4), + ): + with self.subTest(name=name): + if os.name == "nt" and binary.suffix == ".CMD": + # Keep native Python's drive/backslash spelling so the + # production resolver exercises cygpath under Git Bash. + binary_path = shutil.which(str(binary)) + self.assertIsNotNone(binary_path) + else: + binary_path = str(binary).replace("/", "\\") + result = subprocess.run( + [bash, "--noprofile", "--norc", "-c", script], + env=dict(env, BIN=binary_path), capture_output=True, timeout=15, + ) + self.assertEqual(result.returncode, expected_rc, result.stderr) + if expected_rc == 0: + self.assertEqual(result.stdout, b"CMD_SHIM_OK\n") + else: + self.assertIn(b"backend CLI not found", result.stderr) + self.assertNotIn(b"CMD_SHIM_OK", result.stdout) + def test_steer_guidance_after_flags_and_root_option(self): from steer import control for tail in (["guidance text", "--mode", "queue"], diff --git a/skills/pragmatic-orchestration/scripts/tests/run.sh b/skills/pragmatic-orchestration/scripts/tests/run.sh index aefefed..76fce19 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"