From 87db5d325a1ec9f2dbd8efe3ad728910e920ab50 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Thu, 9 Apr 2026 19:25:25 +0200 Subject: [PATCH 1/3] fix(run): prioritize APM-managed runtimes and pin codex compatibility (#605) - _detect_installed_runtime() now checks ~/.apm/runtimes/ before system PATH, preventing system stubs from shadowing APM-managed binaries - Pin codex setup scripts to v0.1.2025051600 (last version supporting wire_api="chat" for GitHub Models compatibility) - Add warnings in codex setup scripts about GitHub Models limitation - Add 6 unit tests covering runtime detection priority scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + scripts/runtime/setup-codex.ps1 | 7 ++- scripts/runtime/setup-codex.sh | 7 ++- src/apm_cli/core/script_runner.py | 13 +++-- tests/unit/test_script_runner.py | 85 +++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f1f058a..ed6ccbef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm run start` now checks `~/.apm/runtimes/` before system PATH for runtime detection, preventing system stubs from shadowing APM-managed binaries (#605) +- Pin codex setup to v0.1.2025051600, the last version compatible with GitHub Models `wire_api = "chat"` (#605) - Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622) ### Changed diff --git a/scripts/runtime/setup-codex.ps1 b/scripts/runtime/setup-codex.ps1 index 5396e4b8e..814dba9b6 100644 --- a/scripts/runtime/setup-codex.ps1 +++ b/scripts/runtime/setup-codex.ps1 @@ -1,9 +1,11 @@ # Setup script for Codex runtime (Windows) # Downloads Codex binary from GitHub releases and configures with GitHub Models +# Pin to last version compatible with GitHub Models wire_api="chat" (#605) +# Codex v0.116+ requires wire_api="responses" which GitHub Models does not support. param( [switch]$Vanilla, - [string]$Version = "latest" + [string]$Version = "0.1.2025051600" ) $ErrorActionPreference = "Stop" @@ -165,6 +167,9 @@ wire_api = "chat" "@ | Set-Content -Path $codexConfig -Encoding UTF8 Write-Success "Codex configuration created at $codexConfig" + Write-WarningText "Codex is pinned to v0.1.2025051600 for GitHub Models compatibility (wire_api='chat')." + Write-WarningText "Later versions (v0.116+) require wire_api='responses' which GitHub Models does not support." + Write-WarningText "To use a newer version, run: apm runtime setup codex (e.g. 'latest')" } else { Write-Info "Vanilla mode: Skipping APM configuration" } diff --git a/scripts/runtime/setup-codex.sh b/scripts/runtime/setup-codex.sh index 8e0593dd5..9402b5f15 100755 --- a/scripts/runtime/setup-codex.sh +++ b/scripts/runtime/setup-codex.sh @@ -23,7 +23,9 @@ source "$SCRIPT_DIR/setup-common.sh" # Configuration CODEX_REPO="openai/codex" -CODEX_VERSION="latest" # Default version +# Pin to last version compatible with GitHub Models wire_api="chat" (#605) +# Codex v0.116+ requires wire_api="responses" which GitHub Models does not support. +CODEX_VERSION="0.1.2025051600" VANILLA_MODE=false # Parse command line arguments @@ -208,6 +210,9 @@ wire_api = "chat" EOF log_success "Codex configuration created at $codex_config" + log_warning "Codex is pinned to v0.1.2025051600 for GitHub Models compatibility (wire_api=\"chat\")." + log_warning "Later versions (v0.116+) require wire_api=\"responses\" which GitHub Models does not support." + log_warning "To use a newer version, run: apm runtime setup codex (e.g. 'latest')" log_info "APM configured Codex with GitHub Models as default provider" log_info "Use 'apm install' to configure MCP servers for your projects" else diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index 87798c5ce..486999468 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -847,7 +847,8 @@ def _create_minimal_config(self) -> None: def _detect_installed_runtime(self) -> str: """Detect installed runtime with priority order. - Priority: copilot > codex > error + Priority: APM-managed (~/.apm/runtimes/) > system PATH + Within each source: copilot > codex > error Returns: Name of detected runtime @@ -855,9 +856,15 @@ def _detect_installed_runtime(self) -> str: Raises: RuntimeError: If no compatible runtime is found """ - import shutil + runtime_dir = Path.home() / ".apm" / "runtimes" - # Priority order: copilot first (recommended), then codex + # Check APM-managed runtimes first (highest priority) + for runtime_name in ["copilot", "codex"]: + runtime_path = runtime_dir / runtime_name + if runtime_path.exists() and runtime_path.is_file(): + return runtime_name + + # Fall back to system PATH if shutil.which("copilot"): return "copilot" elif shutil.which("codex"): diff --git a/tests/unit/test_script_runner.py b/tests/unit/test_script_runner.py index 31c79b0af..0162cd05d 100644 --- a/tests/unit/test_script_runner.py +++ b/tests/unit/test_script_runner.py @@ -864,3 +864,88 @@ def test_skips_resolution_on_non_windows(self, mock_sys, mock_which, mock_run): ) mock_which.assert_not_called() + + +class TestDetectInstalledRuntime: + """Test _detect_installed_runtime() APM-managed vs PATH priority (#605).""" + + def setup_method(self): + self.runner = ScriptRunner() + + @patch("apm_cli.core.script_runner.shutil.which") + @patch("apm_cli.core.script_runner.Path.home") + def test_apm_managed_copilot_preferred_over_path(self, mock_home, mock_which, tmp_path): + """APM-managed copilot in ~/.apm/runtimes/ takes priority over PATH.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + (runtime_dir / "copilot").write_text("binary") + mock_home.return_value = tmp_path + # Even if shutil.which finds codex on PATH, APM copilot wins + mock_which.side_effect = lambda name: "/usr/bin/codex" if name == "codex" else None + + result = self.runner._detect_installed_runtime() + + assert result == "copilot" + mock_which.assert_not_called() + + @patch("apm_cli.core.script_runner.shutil.which") + @patch("apm_cli.core.script_runner.Path.home") + def test_apm_managed_codex_when_no_copilot(self, mock_home, mock_which, tmp_path): + """APM-managed codex is returned when copilot is not in runtimes dir.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + (runtime_dir / "codex").write_text("binary") + mock_home.return_value = tmp_path + + result = self.runner._detect_installed_runtime() + + assert result == "codex" + mock_which.assert_not_called() + + @patch("apm_cli.core.script_runner.shutil.which") + @patch("apm_cli.core.script_runner.Path.home") + def test_falls_back_to_path_when_no_apm_runtimes(self, mock_home, mock_which, tmp_path): + """Falls back to shutil.which when ~/.apm/runtimes/ has no binaries.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + mock_home.return_value = tmp_path + mock_which.side_effect = lambda name: "/usr/local/bin/copilot" if name == "copilot" else None + + result = self.runner._detect_installed_runtime() + + assert result == "copilot" + + @patch("apm_cli.core.script_runner.shutil.which", return_value=None) + @patch("apm_cli.core.script_runner.Path.home") + def test_raises_when_no_runtime_found(self, mock_home, mock_which, tmp_path): + """Raises RuntimeError when no runtime is found anywhere.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + mock_home.return_value = tmp_path + + with pytest.raises(RuntimeError, match="No compatible runtime found"): + self.runner._detect_installed_runtime() + + @patch("apm_cli.core.script_runner.shutil.which") + @patch("apm_cli.core.script_runner.Path.home") + def test_apm_managed_ignores_directories(self, mock_home, mock_which, tmp_path): + """Directories named 'copilot' or 'codex' in runtimes dir should not match.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + (runtime_dir / "copilot").mkdir() # directory, not a file + mock_home.return_value = tmp_path + mock_which.side_effect = lambda name: "/usr/bin/codex" if name == "codex" else None + + result = self.runner._detect_installed_runtime() + + assert result == "codex" + + @patch("apm_cli.core.script_runner.shutil.which", return_value=None) + @patch("apm_cli.core.script_runner.Path.home") + def test_nonexistent_runtime_dir_falls_to_path(self, mock_home, mock_which, tmp_path): + """When ~/.apm/runtimes/ doesn't exist, falls through to PATH check.""" + # Don't create the runtime_dir — it won't exist + mock_home.return_value = tmp_path + + with pytest.raises(RuntimeError, match="No compatible runtime found"): + self.runner._detect_installed_runtime() From 77f3bdc20d12c97a3bf9ca6d1d4c20d0c6a2d9ad Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Thu, 9 Apr 2026 19:33:43 +0200 Subject: [PATCH 2/3] fix: address Copilot review - Windows .exe detection and changelog (#605) - Use shutil.which(name, path=runtime_dir) for APM-managed runtime detection, handling .exe suffix on Windows via PATHEXT automatically - Add test for Windows .exe binary detection in APM runtimes dir - Update changelog entries to reference PR #651 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- src/apm_cli/core/script_runner.py | 6 ++- tests/unit/test_script_runner.py | 65 ++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6ccbef9..48b51e681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `apm run start` now checks `~/.apm/runtimes/` before system PATH for runtime detection, preventing system stubs from shadowing APM-managed binaries (#605) -- Pin codex setup to v0.1.2025051600, the last version compatible with GitHub Models `wire_api = "chat"` (#605) +- `apm run start` now checks `~/.apm/runtimes/` before system PATH for runtime detection, preventing system stubs from shadowing APM-managed binaries (#651) +- Pin codex setup to v0.1.2025051600, the last version compatible with GitHub Models `wire_api = "chat"` (#651) - Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622) ### Changed diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index 486999468..21be5b831 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -859,9 +859,11 @@ def _detect_installed_runtime(self) -> str: runtime_dir = Path.home() / ".apm" / "runtimes" # Check APM-managed runtimes first (highest priority) + # Use shutil.which with path= to handle platform-specific extensions + # (e.g. .exe on Windows via PATHEXT) for runtime_name in ["copilot", "codex"]: - runtime_path = runtime_dir / runtime_name - if runtime_path.exists() and runtime_path.is_file(): + found = shutil.which(runtime_name, path=str(runtime_dir)) + if found: return runtime_name # Fall back to system PATH diff --git a/tests/unit/test_script_runner.py b/tests/unit/test_script_runner.py index 0162cd05d..58f7f411c 100644 --- a/tests/unit/test_script_runner.py +++ b/tests/unit/test_script_runner.py @@ -880,13 +880,24 @@ def test_apm_managed_copilot_preferred_over_path(self, mock_home, mock_which, tm runtime_dir.mkdir(parents=True) (runtime_dir / "copilot").write_text("binary") mock_home.return_value = tmp_path - # Even if shutil.which finds codex on PATH, APM copilot wins - mock_which.side_effect = lambda name: "/usr/bin/codex" if name == "codex" else None + + def which_side_effect(name, mode=None, path=None): + if path is not None: + # APM-dir search + return str(runtime_dir / name) if name == "copilot" else None + # System PATH — should never be reached + return "/usr/bin/codex" if name == "codex" else None + + mock_which.side_effect = which_side_effect result = self.runner._detect_installed_runtime() assert result == "copilot" - mock_which.assert_not_called() + # Verify shutil.which was called with path= for the APM-dir check, + # and NOT called without path= (system PATH should be skipped) + for call in mock_which.call_args_list: + assert call.kwargs.get("path") or (len(call.args) > 2 and call.args[2] is not None), \ + "System PATH shutil.which should not be called when APM-managed runtime found" @patch("apm_cli.core.script_runner.shutil.which") @patch("apm_cli.core.script_runner.Path.home") @@ -897,10 +908,16 @@ def test_apm_managed_codex_when_no_copilot(self, mock_home, mock_which, tmp_path (runtime_dir / "codex").write_text("binary") mock_home.return_value = tmp_path + def which_side_effect(name, mode=None, path=None): + if path is not None: + return str(runtime_dir / name) if name == "codex" else None + return None + + mock_which.side_effect = which_side_effect + result = self.runner._detect_installed_runtime() assert result == "codex" - mock_which.assert_not_called() @patch("apm_cli.core.script_runner.shutil.which") @patch("apm_cli.core.script_runner.Path.home") @@ -909,7 +926,13 @@ def test_falls_back_to_path_when_no_apm_runtimes(self, mock_home, mock_which, tm runtime_dir = tmp_path / ".apm" / "runtimes" runtime_dir.mkdir(parents=True) mock_home.return_value = tmp_path - mock_which.side_effect = lambda name: "/usr/local/bin/copilot" if name == "copilot" else None + + def which_side_effect(name, mode=None, path=None): + if path is not None: + return None # Nothing in APM dir + return "/usr/local/bin/copilot" if name == "copilot" else None + + mock_which.side_effect = which_side_effect result = self.runner._detect_installed_runtime() @@ -934,7 +957,13 @@ def test_apm_managed_ignores_directories(self, mock_home, mock_which, tmp_path): runtime_dir.mkdir(parents=True) (runtime_dir / "copilot").mkdir() # directory, not a file mock_home.return_value = tmp_path - mock_which.side_effect = lambda name: "/usr/bin/codex" if name == "codex" else None + + def which_side_effect(name, mode=None, path=None): + if path is not None: + return None # shutil.which won't match directories + return "/usr/bin/codex" if name == "codex" else None + + mock_which.side_effect = which_side_effect result = self.runner._detect_installed_runtime() @@ -949,3 +978,27 @@ def test_nonexistent_runtime_dir_falls_to_path(self, mock_home, mock_which, tmp_ with pytest.raises(RuntimeError, match="No compatible runtime found"): self.runner._detect_installed_runtime() + + @patch("apm_cli.core.script_runner.shutil.which") + @patch("apm_cli.core.script_runner.Path.home") + def test_windows_exe_detected_in_apm_runtimes(self, mock_home, mock_which, tmp_path): + """On Windows, codex.exe in ~/.apm/runtimes/ is detected via shutil.which PATHEXT.""" + runtime_dir = tmp_path / ".apm" / "runtimes" + runtime_dir.mkdir(parents=True) + # Windows installs codex.exe, not codex + (runtime_dir / "codex.exe").write_text("binary") + mock_home.return_value = tmp_path + + def which_side_effect(name, mode=None, path=None): + if path is not None: + # Simulate Windows PATHEXT: shutil.which("codex", path=dir) finds codex.exe + if name == "codex": + return str(runtime_dir / "codex.exe") + return None + return None + + mock_which.side_effect = which_side_effect + + result = self.runner._detect_installed_runtime() + + assert result == "codex" From e4d86cf158411ff6c9c87cecdb024af189bb9caa Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Fri, 10 Apr 2026 12:36:47 +0200 Subject: [PATCH 3/3] revert: remove codex version pin from runtime detection PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex version pin (0.1.2025051600) references a deleted tag — the openai/codex project removed all old-format releases. This caused CI integration tests to fail (tar receives an HTML 404 page instead of a binary). Revert setup-codex.sh and setup-codex.ps1 to main's CODEX_VERSION=latest to unblock this PR. The version pinning concern will be addressed in a separate issue and PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 - scripts/runtime/setup-codex.ps1 | 7 +------ scripts/runtime/setup-codex.sh | 7 +------ 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 027911b85..8fe8276ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `apm run start` now checks `~/.apm/runtimes/` before system PATH for runtime detection, preventing system stubs from shadowing APM-managed binaries (#651) -- Pin codex setup to v0.1.2025051600, the last version compatible with GitHub Models `wire_api = "chat"` (#651) - Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622) ### Changed diff --git a/scripts/runtime/setup-codex.ps1 b/scripts/runtime/setup-codex.ps1 index 814dba9b6..5396e4b8e 100644 --- a/scripts/runtime/setup-codex.ps1 +++ b/scripts/runtime/setup-codex.ps1 @@ -1,11 +1,9 @@ # Setup script for Codex runtime (Windows) # Downloads Codex binary from GitHub releases and configures with GitHub Models -# Pin to last version compatible with GitHub Models wire_api="chat" (#605) -# Codex v0.116+ requires wire_api="responses" which GitHub Models does not support. param( [switch]$Vanilla, - [string]$Version = "0.1.2025051600" + [string]$Version = "latest" ) $ErrorActionPreference = "Stop" @@ -167,9 +165,6 @@ wire_api = "chat" "@ | Set-Content -Path $codexConfig -Encoding UTF8 Write-Success "Codex configuration created at $codexConfig" - Write-WarningText "Codex is pinned to v0.1.2025051600 for GitHub Models compatibility (wire_api='chat')." - Write-WarningText "Later versions (v0.116+) require wire_api='responses' which GitHub Models does not support." - Write-WarningText "To use a newer version, run: apm runtime setup codex (e.g. 'latest')" } else { Write-Info "Vanilla mode: Skipping APM configuration" } diff --git a/scripts/runtime/setup-codex.sh b/scripts/runtime/setup-codex.sh index 9402b5f15..8e0593dd5 100755 --- a/scripts/runtime/setup-codex.sh +++ b/scripts/runtime/setup-codex.sh @@ -23,9 +23,7 @@ source "$SCRIPT_DIR/setup-common.sh" # Configuration CODEX_REPO="openai/codex" -# Pin to last version compatible with GitHub Models wire_api="chat" (#605) -# Codex v0.116+ requires wire_api="responses" which GitHub Models does not support. -CODEX_VERSION="0.1.2025051600" +CODEX_VERSION="latest" # Default version VANILLA_MODE=false # Parse command line arguments @@ -210,9 +208,6 @@ wire_api = "chat" EOF log_success "Codex configuration created at $codex_config" - log_warning "Codex is pinned to v0.1.2025051600 for GitHub Models compatibility (wire_api=\"chat\")." - log_warning "Later versions (v0.116+) require wire_api=\"responses\" which GitHub Models does not support." - log_warning "To use a newer version, run: apm runtime setup codex (e.g. 'latest')" log_info "APM configured Codex with GitHub Models as default provider" log_info "Use 'apm install' to configure MCP servers for your projects" else