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
6 changes: 6 additions & 0 deletions src/ouroboros/router/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/router/test_extract_first_argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/router/test_valid_dispatch_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading