|
10 | 10 | import shlex |
11 | 11 | from typing import Any, cast |
12 | 12 |
|
| 13 | +import asyncssh |
13 | 14 | import mcp.types as mcp_types |
14 | 15 | import pytest |
15 | 16 |
|
@@ -47,7 +48,22 @@ async def run( |
47 | 48 | self.commands.append(command) |
48 | 49 | parts = shlex.split(command) |
49 | 50 | if len(parts) == 3 and parts[:2] == ["cat", "--"]: |
50 | | - return _Completed(stdout=self._store.get(parts[2], b"").decode()) |
| 51 | + if parts[2] not in self._store: |
| 52 | + if check: |
| 53 | + raise asyncssh.ProcessError( |
| 54 | + env=None, |
| 55 | + command=command, |
| 56 | + subsystem=None, |
| 57 | + exit_status=1, |
| 58 | + exit_signal=None, |
| 59 | + returncode=1, |
| 60 | + stdout="", |
| 61 | + stderr=f"cat: {parts[2]}: No such file or directory", |
| 62 | + ) |
| 63 | + return _Completed( |
| 64 | + stderr=f"cat: {parts[2]}: No such file or directory", exit_status=1 |
| 65 | + ) |
| 66 | + return _Completed(stdout=self._store[parts[2]].decode()) |
51 | 67 | if len(parts) == 3 and parts[:2] == ["cat", ">"]: |
52 | 68 | assert input is not None |
53 | 69 | self._store[parts[2]] = input.encode() |
@@ -520,3 +536,37 @@ async def test_gemini_edit_creates_file_when_old_string_empty() -> None: |
520 | 536 | await tool.execute({"file_path": "/n.txt", "old_string": "", "new_string": "fresh"}) |
521 | 537 |
|
522 | 538 | assert ssh.files["/n.txt"] == b"fresh" |
| 539 | + |
| 540 | + |
| 541 | +def test_map_path_leaves_a_symlinked_spelling_of_the_workspace_alone() -> None: |
| 542 | + """A workspace made at /tmp/w is served as /private/tmp/w on macOS; re-anchoring |
| 543 | + the caller's spelling instead of stripping it nests the path under itself.""" |
| 544 | + cap = Capability( |
| 545 | + name="shell", |
| 546 | + protocol="ssh/2", |
| 547 | + url="ssh://localhost:22", |
| 548 | + params={"cwd": "/private/tmp/w", "cwd_aliases": ["/tmp/w"]}, |
| 549 | + ) |
| 550 | + ssh = SSHClient(cap, cast("Any", None)) |
| 551 | + |
| 552 | + assert ssh.map_path("/tmp/w/calc.py") == "/private/tmp/w/calc.py" |
| 553 | + assert ssh.map_path("/private/tmp/w/calc.py") == "/private/tmp/w/calc.py" |
| 554 | + assert ssh.map_path("/tmp/w") == "/private/tmp/w" |
| 555 | + # Workspace-relative addressing still anchors, and an unrelated absolute |
| 556 | + # path is still clamped into the workspace like a chroot. |
| 557 | + assert ssh.map_path("/REPORT.md") == "/private/tmp/w/REPORT.md" |
| 558 | + assert ssh.map_path("/tmp/elsewhere/f.txt") == "/private/tmp/w/tmp/elsewhere/f.txt" |
| 559 | + |
| 560 | + |
| 561 | +async def test_reading_a_missing_file_is_a_tool_error_not_a_raised_traceback() -> None: |
| 562 | + """Reading before creating is the first thing an editor tool does; that failure |
| 563 | + must come back as a tool result carrying the shell's message.""" |
| 564 | + ssh = _FakeSSH() |
| 565 | + tool = ClaudeTextEditorTool( |
| 566 | + spec=ClaudeTextEditorTool.default_spec("claude"), client=cast("SSHClient", ssh) |
| 567 | + ) |
| 568 | + |
| 569 | + result = await tool.execute({"command": "view", "path": "/nope.txt"}) |
| 570 | + |
| 571 | + assert result.isError is True |
| 572 | + assert "No such file or directory" in result_text(result) |
0 commit comments