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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added APM-managed runtime binary resolution before PATH lookup; `find_runtime_binary()` now includes path-traversal security guards via `validate_path_segments` and `ensure_path_within`. (#605)
- Added codex >= v0.116 compatibility warning for GitHub Models in `setup-codex.sh` and `setup-codex.ps1`. (#605)
- Added `LAST_COMPAT_VERSION_MINOR` constant to both Codex setup scripts so the compatibility boundary is defined once. (#605)
- `apm uninstall` now accepts the same marketplace notation as `apm install` (e.g. `my-plugin@official`) -- no more `owner/repo` lookup before removing a plugin you installed by name. Refs resolve via lockfile first (offline), then registry fallback, with a supply-chain guard that refuses any registry-returned canonical not already in the lockfile. ([#1323](https://github.com/microsoft/apm/issues/1323))
- Added `--target/-t` option to `apm update` command to specify agent target (#1297)
- `apm pack --marketplace=FORMATS` filters which marketplace formats are built in a single run; accepts comma-separated names and sentinels `all`/`none`. (#1317)
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/integrations/runtime-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ scripts:

APM automatically downloads, installs, and configures the Codex CLI with GitHub Models for free usage.

> **⚠️ Compatibility Note — Codex v0.116+ and GitHub Models:** Codex v0.116 and later default
> to the OpenAI Responses API (`wire_api=responses`), which GitHub Models does not expose
> (returns 404). If you install a Codex version ≥ v0.116, change `wire_api` in
> `~/.codex/config.toml` from `"responses"` to `"chat"` to restore GitHub Models
> compatibility. Alternatively, pin to the last known-compatible release:
> `apm runtime setup codex --version rust-v0.115.0`

### Setup

#### 1. Install via APM
Expand Down
18 changes: 18 additions & 0 deletions scripts/runtime/setup-codex.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ if (Test-Path $tokenHelperPath) {

# Configuration
$CodexRepo = "openai/codex"
# Last Codex minor version that works with GitHub Models without wire_api=chat (#605)
$LastCompatVersionMinor = 115

function Install-Codex {
Write-Info "Setting up Codex runtime..."
Expand Down Expand Up @@ -85,6 +87,7 @@ function Install-Codex {
}

Write-Info "Using Codex release: $latestTag"
$Version = $latestTag
$downloadUrl = "https://github.com/$CodexRepo/releases/download/$latestTag/codex-$codexPlatform.exe.tar.gz"
} else {
$downloadUrl = "https://github.com/$CodexRepo/releases/download/$Version/codex-$codexPlatform.exe.tar.gz"
Expand Down Expand Up @@ -168,6 +171,21 @@ wire_api = "responses"

Write-Success "Codex configuration created at $codexConfig"
Write-Info "Using Codex $Version."

# Version compatibility check
if ($Version -match '^rust-v0\.(\d+)') {
$codexMinor = [int]$Matches[1]
if ($codexMinor -gt $LastCompatVersionMinor) {
Write-Host ""
Write-Warning "codex >= v0.116 requires wire_api=chat configuration for GitHub Models compatibility."
Write-Warning "The generated config uses wire_api=responses, which returns 404 with GitHub Models."
Write-Warning "To fix, update wire_api in ${codexConfig}:"
Write-Warning " wire_api = `"chat`""
Write-Warning "Or install an older compatible version: apm runtime setup codex --version rust-v0.115.0"
Write-Host ""
}
}

Write-Info "Override with: apm runtime setup codex --version <version> (e.g. 'latest')"
} else {
Write-Info "Vanilla mode: Skipping APM configuration"
Expand Down
16 changes: 16 additions & 0 deletions scripts/runtime/setup-codex.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ CODEX_REPO="openai/codex"
# Users can override with: apm runtime setup codex --version <version> (e.g. 'latest')
CODEX_VERSION="rust-v0.118.0"
VANILLA_MODE=false
# Last Codex minor version that works with GitHub Models without wire_api=chat (#605)
LAST_COMPAT_VERSION_MINOR=115

# Parse command line arguments
while [[ $# -gt 0 ]]; do
Expand Down Expand Up @@ -127,6 +129,7 @@ setup_codex() {
fi

log_info "Using Codex release: $latest_tag"
CODEX_VERSION="$latest_tag"
download_url="https://github.com/$CODEX_REPO/releases/download/$latest_tag/codex-$codex_platform.tar.gz"
else
download_url="https://github.com/$CODEX_REPO/releases/download/$CODEX_VERSION/codex-$codex_platform.tar.gz"
Expand Down Expand Up @@ -211,6 +214,19 @@ EOF

log_success "Codex configuration created at $codex_config"
log_info "Using Codex $CODEX_VERSION."

# Version compatibility check
codex_minor=$(echo "$CODEX_VERSION" | sed -n 's/^rust-v0\.\([0-9]*\).*/\1/p')
if [ -n "$codex_minor" ] && [ "$codex_minor" -gt "$LAST_COMPAT_VERSION_MINOR" ] 2>/dev/null; then
echo ""
log_warning "codex >= v0.116 requires wire_api=chat configuration for GitHub Models compatibility."
log_warning "The generated config uses wire_api=responses, which returns 404 with GitHub Models."
log_warning "To fix, update wire_api in $codex_config:"
log_warning " wire_api = \"chat\""
log_warning "Or install an older compatible version: apm runtime setup codex --version rust-v0.115.0"
echo ""
fi

log_info "Override with: apm runtime setup codex --version <version> (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"
Expand Down
22 changes: 11 additions & 11 deletions src/apm_cli/core/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import os
import re
import shutil
import subprocess
import sys
import time
Expand All @@ -12,6 +11,7 @@
import yaml # noqa: F401

from ..output.script_formatters import ScriptExecutionFormatter
from ..runtime.utils import find_runtime_binary
from .token_manager import setup_runtime_environment


Expand Down Expand Up @@ -571,11 +571,13 @@ def _execute_runtime_command(
for line in env_lines:
print(line)

# Execute using argument list (no shell interpretation) with updated environment
# On Windows, resolve the executable via shutil.which() so that shell
# wrappers like copilot.cmd / copilot.ps1 are found without shell=True.
if sys.platform == "win32" and actual_command_args:
resolved = shutil.which(actual_command_args[0])
# Resolve the runtime binary to the APM-managed path (or fallback to PATH).
# This must happen here, not in _generate_runtime_command, so the command
# string stays parseable (bare names) by _detect_runtime / _transform_runtime_command.
# find_runtime_binary checks ~/.apm/runtimes/<name> first, then shutil.which(),
# which also covers Windows shell wrappers (.cmd / .ps1) via PATHEXT.
if actual_command_args:
resolved = find_runtime_binary(actual_command_args[0])
if resolved:
actual_command_args[0] = resolved
return subprocess.run(actual_command_args, check=True, env=env_vars)
Expand Down Expand Up @@ -921,13 +923,11 @@ def _detect_installed_runtime(self) -> str:
Raises:
RuntimeError: If no compatible runtime is found
"""
import shutil

if shutil.which("copilot"):
if find_runtime_binary("copilot"):
return "copilot"
elif shutil.which("codex"):
elif find_runtime_binary("codex"):
return "codex"
elif shutil.which("gemini"):
elif find_runtime_binary("gemini"):
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return "gemini"
else:
raise RuntimeError(
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from apm_cli.core.null_logger import NullCommandLogger
from apm_cli.deps.lockfile import LockFile, get_lockfile_path
from apm_cli.runtime.utils import find_runtime_binary
from apm_cli.utils.console import (
_get_console, # noqa: F401 -- module attribute; patched by tests and used via re-export
_rich_error,
Expand Down Expand Up @@ -836,7 +837,7 @@ def _filter_runtimes(detected_runtimes: list[str]) -> list[str]:
except ImportError:
available = []
for rt in mcp_compatible:
if shutil.which(rt):
if find_runtime_binary(rt):
available.append(rt)
return available

Expand All @@ -847,7 +848,7 @@ def _filter_runtimes(detected_runtimes: list[str]) -> list[str]:
mcp_compatible = [
rt for rt in detected_runtimes if rt in ClientFactory.supported_clients()
]
return [rt for rt in mcp_compatible if shutil.which(rt)]
return [rt for rt in mcp_compatible if find_runtime_binary(rt)]

# ------------------------------------------------------------------
# Per-runtime installation
Expand Down
12 changes: 8 additions & 4 deletions src/apm_cli/integration/mcp_integrator_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from __future__ import annotations

import builtins
import shutil
from pathlib import Path
from typing import TYPE_CHECKING

from apm_cli.core.null_logger import NullCommandLogger
from apm_cli.runtime.utils import find_runtime_binary
from apm_cli.utils.console import STATUS_SYMBOLS

if TYPE_CHECKING:
Expand Down Expand Up @@ -188,7 +188,7 @@ def run_mcp_install( # noqa: PLR0915
# adapter from writing to ~/.claude.json on hosts
# where Claude Code was never installed.
if (project_root_path / ".claude").is_dir() or (
shutil.which("claude") is not None
find_runtime_binary("claude") is not None
):
ClientFactory.create_client(runtime_name)
installed_runtimes.append(runtime_name)
Expand All @@ -199,7 +199,9 @@ def run_mcp_install( # noqa: PLR0915
except (ValueError, ImportError):
continue
except ImportError:
installed_runtimes = [rt for rt in ["copilot", "codex"] if shutil.which(rt) is not None]
installed_runtimes = [
rt for rt in ["copilot", "codex"] if find_runtime_binary(rt) is not None
]
# VS Code: check binary on PATH or .vscode/ directory presence
if _is_vscode_available(project_root=project_root_path):
installed_runtimes.append("vscode")
Expand All @@ -216,7 +218,9 @@ def run_mcp_install( # noqa: PLR0915
if (project_root_path / ".windsurf").is_dir():
installed_runtimes.append("windsurf")
# Claude Code: directory-presence OR binary-on-PATH
if (project_root_path / ".claude").is_dir() or (shutil.which("claude") is not None):
if (project_root_path / ".claude").is_dir() or (
find_runtime_binary("claude") is not None
):
installed_runtimes.append("claude")

# Step 2: Get runtimes referenced in apm.yml scripts
Expand Down
7 changes: 4 additions & 3 deletions src/apm_cli/runtime/codex_runtime.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Codex runtime adapter for APM."""

import shutil
import subprocess
from typing import Any, Dict, Optional # noqa: F401, UP035

from .base import RuntimeAdapter
from .utils import find_runtime_binary


class CodexRuntime(RuntimeAdapter):
Expand Down Expand Up @@ -39,8 +39,9 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str:
try:
# Use codex exec to execute the prompt with real-time streaming
# Always skip git repo check when running from APM
codex_binary = find_runtime_binary("codex") or "codex"
process = subprocess.Popen(
["codex", "exec", "--skip-git-repo-check", prompt_content],
[codex_binary, "exec", "--skip-git-repo-check", prompt_content],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Merge stderr into stdout for streaming
text=True,
Expand Down Expand Up @@ -136,7 +137,7 @@ def is_available() -> bool:
Returns:
bool: True if runtime is available, False otherwise
"""
return shutil.which("codex") is not None
return find_runtime_binary("codex") is not None

@staticmethod
def get_runtime_name() -> str:
Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/runtime/copilot_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import json
import os # noqa: F401
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict, Optional # noqa: F401, UP035

from .base import RuntimeAdapter, _stream_subprocess_output
from .utils import find_runtime_binary


class CopilotRuntime(RuntimeAdapter):
Expand Down Expand Up @@ -149,7 +149,7 @@ def is_available() -> bool:
Returns:
bool: True if runtime is available, False otherwise
"""
return shutil.which("copilot") is not None
return find_runtime_binary("copilot") is not None

@staticmethod
def get_runtime_name() -> str:
Expand Down
66 changes: 66 additions & 0 deletions src/apm_cli/runtime/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Runtime binary resolution utilities."""

from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path

from apm_cli.utils.path_security import (
PathTraversalError,
ensure_path_within,
validate_path_segments,
)


def find_runtime_binary(name: str) -> str | None:
"""Return the resolved path to a runtime binary.

Priority:
1. ~/.apm/runtimes/<name> (APM-managed, executable)
2. shutil.which(name) (system PATH fallback)

On Windows the APM-managed binary may carry a ``.exe`` suffix, so both
``name`` and ``name.exe`` are checked under ``~/.apm/runtimes/``.

Raises
------
PathTraversalError
If *name* contains path-traversal sequences (e.g. ``..``, ``/``,
``\\``) or is an absolute path. This is a security guard against
user-supplied input that could escape the ``~/.apm/runtimes/``
directory.
"""
# Security: reject names containing path-traversal or separator
# characters before any filesystem path is constructed.
# Runtime names must be simple identifiers (e.g. "codex", "python").
if "/" in name or "\\" in name:
raise PathTraversalError(
f"Invalid runtime name '{name}': must be a plain binary name "
"without path separators ('/' or '\\\\')"
)
validate_path_segments(name, context="runtime name", reject_empty=True)

apm_runtimes = Path.home() / ".apm" / "runtimes"

def _safe_executable(candidate: Path) -> bool:
"""Return True iff *candidate* is an executable file within *apm_runtimes*."""
if not (candidate.is_file() and os.access(candidate, os.X_OK)):
return False
try:
ensure_path_within(candidate, apm_runtimes)
except PathTraversalError:
return False
return True

if sys.platform == "win32":
apm_path_exe = apm_runtimes / f"{name}.exe"
if _safe_executable(apm_path_exe):
return str(apm_path_exe)

apm_path = apm_runtimes / name
if _safe_executable(apm_path):
return str(apm_path)

return shutil.which(name)
1 change: 1 addition & 0 deletions tests/unit/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit tests for runtime modules."""
Loading
Loading