diff --git a/src/ouroboros/router/dispatch.py b/src/ouroboros/router/dispatch.py index ae1c32913..69c551768 100644 --- a/src/ouroboros/router/dispatch.py +++ b/src/ouroboros/router/dispatch.py @@ -54,6 +54,10 @@ _MCP_TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _SKILL_IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _DISPATCH_TEMPLATE_PATTERN = re.compile(r"\$(?:CWD|1)(?![A-Za-z0-9_])") +# Windows literal path payloads (drive-letter `C:\…` or UNC `\\server\share\…`) +# must skip shell tokenization — `shlex.split` treats backslash as an escape and +# silently drops it, so `C:\temp\seed.yaml` would dispatch as `C:tempseed.yaml`. +_WINDOWS_LITERAL_PATH_PATTERN = re.compile(r"^(?:[A-Za-z]:\\|\\\\)") _REQUIRED_MCP_FRONTMATTER_KEYS = ("mcp_tool", "mcp_args") _MCP_FRONTMATTER_VALUE_TYPES = "string, finite number, boolean, null, list, or mapping" _PACKAGED_SKILL_CACHE: TemporaryDirectory[str] | None = None @@ -274,6 +278,8 @@ def extract_first_argument(remainder: str | None) -> str | None: return None if re.search(r"[\r\n].*\S", remainder): return remainder + if _WINDOWS_LITERAL_PATH_PATTERN.match(remainder.strip()): + return remainder try: parts = shlex.split(remainder) except ValueError: diff --git a/tests/unit/router/test_extract_first_argument.py b/tests/unit/router/test_extract_first_argument.py index 551494b47..6e1635280 100644 --- a/tests/unit/router/test_extract_first_argument.py +++ b/tests/unit/router/test_extract_first_argument.py @@ -53,6 +53,21 @@ "goal: test\nconstraints:\n - keep it simple\nacceptance_criteria:\n - works", id="multiline-inline-content-preserved", ), + pytest.param( + r"C:\temp\seed.yaml --strict", + r"C:\temp\seed.yaml --strict", + id="windows-drive-path-preserved", + ), + pytest.param( + r"\\server\share\seed.yaml --strict", + r"\\server\share\seed.yaml --strict", + id="windows-unc-path-preserved", + ), + pytest.param( + " C:\\temp\\seed.yaml --strict", + " C:\\temp\\seed.yaml --strict", + id="windows-drive-path-with-incidental-leading-whitespace-preserved", + ), ], ) def test_extract_first_argument_returns_full_argument_payload( diff --git a/tests/unit/router/test_valid_dispatch_normalization.py b/tests/unit/router/test_valid_dispatch_normalization.py index c26cf4bf1..e75fb077d 100644 --- a/tests/unit/router/test_valid_dispatch_normalization.py +++ b/tests/unit/router/test_valid_dispatch_normalization.py @@ -354,6 +354,98 @@ def test_valid_parsed_dispatch_reconstructs_multiline_prompt_with_newline_separa assert result.mcp_args["seed_path"] == seed_content +def test_valid_dispatch_preserves_windows_drive_letter_path_payload(tmp_path: Path) -> None: + """Windows drive-letter literal paths must reach ``$1`` with backslashes intact. + + Without a Windows-literal carve-out the single-line path falls through to + ``shlex.split``, which treats ``\\`` as an escape character and silently + drops it. ``C:\\temp\\seed.yaml --strict`` would dispatch as + ``C:tempseed.yaml --strict``. This regression goes end-to-end through + ``resolve_skill_dispatch`` so the actual ``mcp_args`` payload is asserted + against the verbatim path, not just the prompt echo. + """ + skills_dir = tmp_path / "skills" + skill_md_path = _write_dispatchable_skill(skills_dir, "run") + runtime_cwd = tmp_path / "workspace" + prompt = r"ooo run C:\temp\seed.yaml --strict" + expected_argument = r"C:\temp\seed.yaml --strict" + expected_args = { + "seed_path": expected_argument, + "cwd": str(runtime_cwd), + "combined": f"cwd={runtime_cwd} seed={expected_argument}", + "nested": { + "values": [ + expected_argument, + str(runtime_cwd), + True, + ], + }, + } + + result = resolve_skill_dispatch( + ResolveRequest( + prompt=prompt, + cwd=runtime_cwd, + skills_dir=skills_dir, + ) + ) + + _assert_resolved_payload( + result, + Resolved( + skill_name="run", + command_prefix="ooo run", + prompt=prompt, + skill_path=skill_md_path, + mcp_tool="ouroboros_execute_seed", + mcp_args=expected_args, + first_argument=expected_argument, + ), + ) + + +def test_valid_dispatch_preserves_windows_unc_path_payload(tmp_path: Path) -> None: + """Windows UNC literal paths (``\\\\server\\share\\…``) must keep their backslashes.""" + skills_dir = tmp_path / "skills" + skill_md_path = _write_dispatchable_skill(skills_dir, "run") + runtime_cwd = tmp_path / "workspace" + prompt = r"ooo run \\server\share\seed.yaml --strict" + expected_argument = r"\\server\share\seed.yaml --strict" + expected_args = { + "seed_path": expected_argument, + "cwd": str(runtime_cwd), + "combined": f"cwd={runtime_cwd} seed={expected_argument}", + "nested": { + "values": [ + expected_argument, + str(runtime_cwd), + True, + ], + }, + } + + result = resolve_skill_dispatch( + ResolveRequest( + prompt=prompt, + cwd=runtime_cwd, + skills_dir=skills_dir, + ) + ) + + _assert_resolved_payload( + result, + Resolved( + skill_name="run", + command_prefix="ooo run", + prompt=prompt, + skill_path=skill_md_path, + mcp_tool="ouroboros_execute_seed", + mcp_args=expected_args, + first_argument=expected_argument, + ), + ) + + def test_valid_dispatch_without_argument_normalizes_first_argument_template_to_empty_string( tmp_path: Path, ) -> None: