Skip to content

Commit 45fcac9

Browse files
[fix] script_runner: anchor runtime detection patterns to prevent false positives (#563)
* fix: use anchored patterns in script_runner runtime detection Bare substring matching in _detect_runtime and _transform_runtime_command caused false positives when a runtime name appeared inside a flag value. For example, `copilot --model gpt-5.3-codex -p file.prompt.md` was mis-detected as a codex command and transformed into `codex exec ...`, producing "No such file or directory: 'codex'" at runtime. - is_runtime_cmd: switch from `runtime in command` to word-boundary regex - _detect_runtime: use `(?:^|\s)runtime(?:\s|$)` instead of bare `in` - _transform_runtime_command: anchor codex/copilot/llm regexes with `^` Fixes #396, closes #454. * fix: replace em dash with ASCII hyphen in test comment Repo requires source files to stay within printable ASCII to avoid Windows cp1252 UnicodeEncodeError. --------- Co-authored-by: Daniel Meppiel <51440732+danielmeppiel@users.noreply.github.com>
1 parent 7839e14 commit 45fcac9

File tree

2 files changed

+39
-11
lines changed

2 files changed

+39
-11
lines changed

src/apm_cli/core/script_runner.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ def _auto_compile_prompts(
261261

262262
# Check if this is a runtime command (copilot, codex, llm) before transformation
263263
is_runtime_cmd = any(
264-
runtime in command for runtime in ["copilot", "codex", "llm"]
264+
re.search(r"(?:^|\s)" + runtime + r"(?:\s|$)", command)
265+
for runtime in ["copilot", "codex", "llm"]
265266
) and re.search(re.escape(prompt_file), command)
266267

267268
# Transform command based on runtime pattern
@@ -343,7 +344,7 @@ def _transform_runtime_command(
343344
# Handle individual runtime patterns without environment variables
344345

345346
# Handle "codex [args] file.prompt.md [more_args]" -> "codex exec [args] [more_args]"
346-
if re.search(r"codex\s+.*" + re.escape(prompt_file), command):
347+
if re.search(r"^codex\s+.*" + re.escape(prompt_file), command):
347348
match = re.search(
348349
r"codex\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
349350
)
@@ -359,7 +360,7 @@ def _transform_runtime_command(
359360
return result
360361

361362
# Handle "copilot [args] file.prompt.md [more_args]" -> "copilot [args] [more_args]"
362-
elif re.search(r"copilot\s+.*" + re.escape(prompt_file), command):
363+
elif re.search(r"^copilot\s+.*" + re.escape(prompt_file), command):
363364
match = re.search(
364365
r"copilot\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
365366
)
@@ -378,7 +379,7 @@ def _transform_runtime_command(
378379
return result
379380

380381
# Handle "llm [args] file.prompt.md [more_args]" -> "llm [args] [more_args]"
381-
elif re.search(r"llm\s+.*" + re.escape(prompt_file), command):
382+
elif re.search(r"^llm\s+.*" + re.escape(prompt_file), command):
382383
match = re.search(
383384
r"llm\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
384385
)
@@ -410,12 +411,11 @@ def _detect_runtime(self, command: str) -> str:
410411
Name of the detected runtime (copilot, codex, llm, or unknown)
411412
"""
412413
command_lower = command.lower().strip()
413-
# Check for runtime keywords anywhere in the command, not just at the start
414-
if "copilot" in command_lower:
414+
if re.search(r"(?:^|\s)copilot(?:\s|$)", command_lower):
415415
return "copilot"
416-
elif "codex" in command_lower:
416+
elif re.search(r"(?:^|\s)codex(?:\s|$)", command_lower):
417417
return "codex"
418-
elif "llm" in command_lower:
418+
elif re.search(r"(?:^|\s)llm(?:\s|$)", command_lower):
419419
return "llm"
420420
else:
421421
return "unknown"

tests/unit/test_script_runner.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,18 +118,46 @@ def test_transform_runtime_command_copilot_removes_p_flag(self):
118118
def test_detect_runtime_copilot(self):
119119
"""Test runtime detection for copilot commands."""
120120
assert self.script_runner._detect_runtime("copilot --log-level all") == "copilot"
121-
121+
122122
def test_detect_runtime_codex(self):
123123
"""Test runtime detection for codex commands."""
124124
assert self.script_runner._detect_runtime("codex exec --skip-git-repo-check") == "codex"
125-
125+
126126
def test_detect_runtime_llm(self):
127127
"""Test runtime detection for llm commands."""
128128
assert self.script_runner._detect_runtime("llm --model gpt-4") == "llm"
129-
129+
130130
def test_detect_runtime_unknown(self):
131131
"""Test runtime detection for unknown commands."""
132132
assert self.script_runner._detect_runtime("unknown-command") == "unknown"
133+
134+
def test_detect_runtime_model_name_containing_codex(self):
135+
"""codex as a substring of a model name should not be detected as the codex runtime."""
136+
# e.g. copilot --model gpt-5.3-codex - the runtime is copilot, not codex
137+
assert self.script_runner._detect_runtime("copilot --model gpt-5.3-codex") == "copilot"
138+
139+
def test_detect_runtime_hyphenated_codex(self):
140+
"""A hyphen-prefixed codex substring must not trigger codex detection."""
141+
assert self.script_runner._detect_runtime("run-codex-tool --flag") == "unknown"
142+
143+
def test_transform_runtime_command_copilot_with_codex_model(self):
144+
"""copilot command using --model containing 'codex' must not be mis-routed to codex runtime."""
145+
original = "copilot --allow-all-tools --model gpt-5.3-codex -p fix-issue.prompt.md"
146+
result = self.script_runner._transform_runtime_command(
147+
original, "fix-issue.prompt.md", self.compiled_content, self.compiled_path
148+
)
149+
# Should be treated as a copilot command, not transformed into "codex exec ..."
150+
assert result.startswith("copilot")
151+
assert "codex exec" not in result
152+
153+
def test_transform_runtime_command_copilot_with_codex_model_name(self):
154+
"""--model codex (bare word as model name) must not trigger codex runtime path."""
155+
original = "copilot --model codex -p fix-issue.prompt.md"
156+
result = self.script_runner._transform_runtime_command(
157+
original, "fix-issue.prompt.md", self.compiled_content, self.compiled_path
158+
)
159+
assert result.startswith("copilot")
160+
assert "codex exec" not in result
133161

134162
@patch('subprocess.run')
135163
@patch('apm_cli.core.script_runner.shutil.which', return_value=None)

0 commit comments

Comments
 (0)