Skip to content
Open
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
181 changes: 129 additions & 52 deletions src/apm_cli/core/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .token_manager import setup_runtime_environment
from ..output.script_formatters import ScriptExecutionFormatter
from ..utils.path_security import ensure_path_within, PathTraversalError


class ScriptRunner:
Expand Down Expand Up @@ -108,9 +109,12 @@ def run_script(self, script_name: str, params: Dict[str, str]) -> bool:
# Build helpful error message
error_msg = f"Script or prompt '{script_name}' not found.\n"
error_msg += f"Available scripts in apm.yml: {available}\n"
error_msg += f"\nTo find available prompts, check:\n"
error_msg += f" - Local: .apm/prompts/, .github/prompts/, or project root\n"
error_msg += f" - Dependencies: apm_modules/*/.apm/prompts/\n"
error_msg += f"\nTo get started, create a prompt file first:\n"
error_msg += f" echo '# My agent prompt' > {script_name}.prompt.md\n"
error_msg += f"\nThen run again -- APM will auto-discover it.\n"
error_msg += f"\nOr define a script explicitly in apm.yml:\n"
Comment thread
edenfunf marked this conversation as resolved.
error_msg += f" scripts:\n"
error_msg += f" {script_name}: copilot {script_name}.prompt.md\n"
error_msg += f"\nOr install a prompt package:\n"
error_msg += f" apm install <owner>/<repo>/path/to/prompt.prompt.md\n"

Expand Down Expand Up @@ -261,8 +265,7 @@ def _auto_compile_prompts(

# Check if this is a runtime command (copilot, codex, llm) before transformation
is_runtime_cmd = any(
re.search(r"(?:^|\s)" + runtime + r"(?:\s|$)", command)
for runtime in ["copilot", "codex", "llm"]
runtime in command for runtime in ["copilot", "codex", "llm"]
) and re.search(re.escape(prompt_file), command)
Comment thread
edenfunf marked this conversation as resolved.

# Transform command based on runtime pattern
Expand Down Expand Up @@ -342,44 +345,46 @@ def _transform_runtime_command(
return result

# Handle individual runtime patterns without environment variables
# Note: copilot is checked before codex so that "copilot --model codex ..."
# is not mis-detected as a codex command.

# Handle "codex [args] file.prompt.md [more_args]" -> "codex exec [args] [more_args]"
if re.search(r"^codex\s+.*" + re.escape(prompt_file), command):
# Handle "copilot [args] file.prompt.md [more_args]" -> "copilot [args] [more_args]"
if re.search(r"copilot\s+.*" + re.escape(prompt_file), command):
match = re.search(
r"codex\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
r"copilot\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
)
if match:
args_before_file = match.group(1).strip()
args_after_file = match.group(3).strip()

result = "codex exec"
result = "copilot"
if args_before_file:
result += f" {args_before_file}"
# Remove any existing -p flag since we'll handle it in execution
cleaned_args = args_before_file.replace("-p", "").strip()
if cleaned_args:
result += f" {cleaned_args}"
if args_after_file:
result += f" {args_after_file}"
return result

# Handle "copilot [args] file.prompt.md [more_args]" -> "copilot [args] [more_args]"
elif re.search(r"^copilot\s+.*" + re.escape(prompt_file), command):
# Handle "codex [args] file.prompt.md [more_args]" -> "codex exec [args] [more_args]"
elif re.search(r"codex\s+.*" + re.escape(prompt_file), command):
match = re.search(
r"copilot\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
r"codex\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
)
if match:
args_before_file = match.group(1).strip()
args_after_file = match.group(3).strip()

result = "copilot"
result = "codex exec"
if args_before_file:
# Remove any existing -p flag since we'll handle it in execution
cleaned_args = args_before_file.replace("-p", "").strip()
if cleaned_args:
result += f" {cleaned_args}"
result += f" {args_before_file}"
if args_after_file:
result += f" {args_after_file}"
return result

# Handle "llm [args] file.prompt.md [more_args]" -> "llm [args] [more_args]"
elif re.search(r"^llm\s+.*" + re.escape(prompt_file), command):
elif re.search(r"llm\s+.*" + re.escape(prompt_file), command):
match = re.search(
r"llm\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
)
Expand Down Expand Up @@ -411,11 +416,19 @@ def _detect_runtime(self, command: str) -> str:
Name of the detected runtime (copilot, codex, llm, or unknown)
"""
command_lower = command.lower().strip()
if re.search(r"(?:^|\s)copilot(?:\s|$)", command_lower):
if not command_lower:
return "unknown"
# Match on the binary stem only (e.g. "/path/to/codex.exe arg" -> "codex").
# This handles Windows absolute paths being prepended while avoiding false
# positives from tools whose names contain a runtime keyword as a component
# (e.g. "run-codex-tool" must not be detected as codex).
first_arg = command_lower.split()[0]
binary_stem = Path(first_arg).stem
if binary_stem == "copilot":
return "copilot"
elif re.search(r"(?:^|\s)codex(?:\s|$)", command_lower):
elif binary_stem == "codex":
return "codex"
elif re.search(r"(?:^|\s)llm(?:\s|$)", command_lower):
elif binary_stem == "llm":
return "llm"
else:
return "unknown"
Expand Down Expand Up @@ -497,12 +510,25 @@ def _execute_runtime_command(
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.
# On Windows, resolve the executable via APM runtimes dir first, then shutil.which(),
# so that APM-installed binaries are found even when ~/.apm/runtimes is not in PATH.
if sys.platform == "win32" and actual_command_args:
resolved = shutil.which(actual_command_args[0])
if resolved:
actual_command_args[0] = resolved
exe_name = actual_command_args[0]
apm_runtimes = Path.home() / ".apm" / "runtimes"
# Check APM runtimes directory first
apm_candidates = [
apm_runtimes / exe_name,
apm_runtimes / f"{exe_name}.exe",
apm_runtimes / f"{exe_name}.cmd",
apm_runtimes / f"{exe_name}.bat",
]
apm_resolved = next((str(c) for c in apm_candidates if c.exists()), None)
if apm_resolved:
actual_command_args[0] = apm_resolved
else:
resolved = shutil.which(exe_name)
if resolved:
actual_command_args[0] = resolved
Comment thread
edenfunf marked this conversation as resolved.
return subprocess.run(actual_command_args, check=True, env=env_vars)

def _discover_prompt_file(self, name: str) -> Optional[Path]:
Expand Down Expand Up @@ -546,7 +572,8 @@ def _discover_prompt_file(self, name: str) -> Optional[Path]:
]

for path in local_search_paths:
if path.exists() and not path.is_symlink():
if path.exists():
ensure_path_within(path, Path.cwd())
return path

# 2. Search in dependencies and detect collisions
Expand All @@ -556,14 +583,21 @@ def _discover_prompt_file(self, name: str) -> Optional[Path]:
raw_matches = list(apm_modules.rglob(search_name))

# Also search for SKILL.md in directories matching the name
# e.g., name="architecture-blueprint-generator" -> find */architecture-blueprint-generator/SKILL.md
for skill_dir in apm_modules.rglob(name):
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
raw_matches.append(skill_file)

# Filter out symlinks
matches = [m for m in raw_matches if not m.is_symlink()]
# Filter out paths that resolve outside the project directory (e.g. malicious symlinks)
matches = []
for m in raw_matches:
try:
ensure_path_within(m, Path.cwd())
matches.append(m)
except PathTraversalError:
pass

if len(matches) == 0:
return None
Expand Down Expand Up @@ -847,7 +881,15 @@ def _create_minimal_config(self) -> None:
def _detect_installed_runtime(self) -> str:
"""Detect installed runtime with priority order.

Priority: copilot > codex > error
Checks APM-managed runtimes (~/.apm/runtimes/) first, then PATH.
This ensures explicitly APM-installed binaries take priority over
system-level stubs (e.g. GitHub CLI copilot extensions).

Priority:
1. APM runtimes dir: copilot (codex excluded -- v0.116+ is
incompatible with GitHub Models' Chat Completions API)
2. PATH: llm > copilot > codex (llm uses Chat Completions, works
with GitHub Models even when codex dropped that API)

Returns:
Name of detected runtime
Expand All @@ -857,18 +899,40 @@ def _detect_installed_runtime(self) -> str:
"""
import shutil

# Priority order: copilot first (recommended), then codex
if shutil.which("copilot"):
apm_runtimes = Path.home() / ".apm" / "runtimes"

# 1. Check APM-managed runtimes directory first (highest priority).
# Only copilot is checked here -- codex installed via APM runtimes
# will be v0.116+ which dropped Chat Completions support and is
# incompatible with GitHub Models.
# llm is checked via PATH only (installed as a Python package).
for name in ("copilot",):
candidates = [
apm_runtimes / name,
apm_runtimes / f"{name}.exe",
apm_runtimes / f"{name}.cmd",
]
if any(c.exists() for c in candidates):
# Verify the binary is actually executable before returning
exe = next(c for c in candidates if c.exists())
if exe.stat().st_size > 0:
return name

Comment thread
edenfunf marked this conversation as resolved.
# 2. Fall back to PATH -- prefer llm (uses Chat Completions, works with
# GitHub Models even when codex has dropped that API format)
if shutil.which("llm"):
return "llm"
elif shutil.which("copilot"):
return "copilot"
elif shutil.which("codex"):
return "codex"
else:
raise RuntimeError(
"No compatible runtime found.\n"
"Install GitHub Copilot CLI with:\n"
" apm runtime setup copilot\n"
"Or install Codex CLI with:\n"
" apm runtime setup codex"
"Install the llm CLI with:\n"
" apm runtime setup llm\n"
"Or install GitHub Copilot CLI with:\n"
" apm runtime setup copilot"
)

def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str:
Expand All @@ -887,6 +951,9 @@ def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str:
elif runtime == "codex":
# Codex CLI with default sandbox and git repo check skip
return f"codex -s workspace-write --skip-git-repo-check {prompt_file}"
elif runtime == "llm":
# llm CLI -- uses Chat Completions, compatible with GitHub Models
return f"llm -m github/gpt-4o {prompt_file}"
else:
raise ValueError(f"Unsupported runtime: {runtime}")

Expand Down Expand Up @@ -947,50 +1014,60 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str:
def _resolve_prompt_file(self, prompt_file: str) -> Path:
"""Resolve prompt file path, checking local directory first, then common directories, then dependencies.

Symlinks are rejected outright to prevent traversal attacks.

Args:
prompt_file: Relative path to the .prompt.md file

Returns:
Path: Resolved path to the prompt file

Raises:
FileNotFoundError: If prompt file is not found or is a symlink
FileNotFoundError: If prompt file is not found in local or dependency modules
"""
prompt_path = Path(prompt_file)

# First check if it exists in current directory (local)
if prompt_path.exists():
if prompt_path.is_symlink():
def _check_and_return(path: Path) -> Path:
"""Validate containment and return path, converting traversal errors to FileNotFoundError."""
try:
ensure_path_within(path, Path.cwd())
except PathTraversalError:
raise FileNotFoundError(
f"Prompt file '{prompt_file}' is a symlink. "
f"Symlinks are not allowed for security reasons."
f"Prompt file '{path}' is a symlink that resolves outside the "
f"project directory. Symlinks pointing outside the project are "
f"not allowed for security reasons."
)
return prompt_path
return path

# First check if it exists in current directory (local)
if prompt_path.exists():
return _check_and_return(prompt_path)

Comment thread
edenfunf marked this conversation as resolved.
# Check in common project directories
common_dirs = [".github/prompts", ".apm/prompts"]
for common_dir in common_dirs:
common_path = Path(common_dir) / prompt_file
if common_path.exists() and not common_path.is_symlink():
return common_path
if common_path.exists():
return _check_and_return(common_path)

# If not found locally, search in dependency modules
apm_modules_dir = Path("apm_modules")
if apm_modules_dir.exists():
# Search all dependency directories for the prompt file
# Handle org/repo directory structure (e.g., apm_modules/microsoft/apm-sample-package/)
for org_dir in apm_modules_dir.iterdir():
if org_dir.is_dir() and not org_dir.name.startswith("."):
# Iterate through repos within the org
for repo_dir in org_dir.iterdir():
if repo_dir.is_dir() and not repo_dir.name.startswith("."):
# Check in the root of the repository
dep_prompt_path = repo_dir / prompt_file
if dep_prompt_path.exists() and not dep_prompt_path.is_symlink():
return dep_prompt_path
if dep_prompt_path.exists():
return _check_and_return(dep_prompt_path)

# Also check in common subdirectories
for subdir in ["prompts", ".", "workflows"]:
sub_prompt_path = repo_dir / subdir / prompt_file
if sub_prompt_path.exists() and not sub_prompt_path.is_symlink():
return sub_prompt_path
if sub_prompt_path.exists():
return _check_and_return(sub_prompt_path)

# If still not found, raise an error with helpful message
searched_locations = [
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/test_script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def test_transform_runtime_command_copilot_with_codex_model_name(self):
@patch('subprocess.run')
@patch('apm_cli.core.script_runner.shutil.which', return_value=None)
@patch('apm_cli.core.script_runner.setup_runtime_environment')
def test_execute_runtime_command_with_env_vars(self, mock_setup_env, mock_which, mock_subprocess):
@patch('apm_cli.core.script_runner.Path.home', return_value=Path('/nonexistent/home'))
def test_execute_runtime_command_with_env_vars(self, mock_home, mock_setup_env, mock_which, mock_subprocess):
"""Test runtime command execution with environment variables."""
mock_setup_env.return_value = {'EXISTING_VAR': 'value'}
mock_subprocess.return_value.returncode = 0
Expand Down Expand Up @@ -190,7 +191,8 @@ def test_execute_runtime_command_with_env_vars(self, mock_setup_env, mock_which,
@patch('subprocess.run')
@patch('apm_cli.core.script_runner.shutil.which', return_value=None)
@patch('apm_cli.core.script_runner.setup_runtime_environment')
def test_execute_runtime_command_multiple_env_vars(self, mock_setup_env, mock_which, mock_subprocess):
@patch('apm_cli.core.script_runner.Path.home', return_value=Path('/nonexistent/home'))
def test_execute_runtime_command_multiple_env_vars(self, mock_home, mock_setup_env, mock_which, mock_subprocess):
"""Test runtime command execution with multiple environment variables."""
mock_setup_env.return_value = {}
mock_subprocess.return_value.returncode = 0
Expand Down