From 076437a75e1feaf593a85d2b4a7949cf5a7c47a8 Mon Sep 17 00:00:00 2001 From: "ark-hand[bot]" Date: Wed, 14 Oct 2026 07:19:59 +0000 Subject: [PATCH] fix(selfHosted): support file path aliases Sync-Source-Commit: cf3337b788fc74ac1cde11d50f78730774cd7e5c Hand-Written-Reason: No Ark-APIs provenance marker; treated as a hand-written source commit. Release-Version: 0.12.0 --- src/arkruntime/selfhosted/tools.py | 18 ++++++++---- tests/selfhosted/test_tools.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/arkruntime/selfhosted/tools.py b/src/arkruntime/selfhosted/tools.py index cdb791b..a4e4525 100644 --- a/src/arkruntime/selfhosted/tools.py +++ b/src/arkruntime/selfhosted/tools.py @@ -177,7 +177,7 @@ class ReadFileTool(Tool): def execute(self, tool_input: Any, context: ToolContext) -> ToolResult: args = _as_mapping(tool_input) try: - path = _safe_path(context, _read_file_path(args)) + path = _safe_path(context, _file_path(args)) except ValueError as exc: return error_result(str(exc)) view_range = args.get("view_range") @@ -239,7 +239,7 @@ def execute(self, tool_input: Any, context: ToolContext) -> ToolResult: return text_result(_render_read_lines(text, offset, limit)) -def _read_file_path(args: Mapping[str, Any]) -> str: +def _file_path(args: Mapping[str, Any]) -> str: path = "" for name in ("file_path", "path", "file"): value = args.get(name) @@ -329,8 +329,11 @@ class WriteFileTool(Tool): def execute(self, tool_input: Any, context: ToolContext) -> ToolResult: args = _as_mapping(tool_input) - raw_path = str(args.get("path") or args.get("file") or "") - path = _safe_path(context, raw_path) + try: + raw_path = _file_path(args) + path = _safe_path(context, raw_path) + except ValueError as exc: + return error_result(str(exc)) content = str(args.get("content") or "") Path(path).parent.mkdir(parents=True, exist_ok=True) verified = _safe_path(context, raw_path) @@ -345,8 +348,11 @@ class EditFileTool(Tool): def execute(self, tool_input: Any, context: ToolContext) -> ToolResult: args = _as_mapping(tool_input) - raw_path = str(args.get("path") or args.get("file") or "") - path = _safe_path(context, raw_path) + try: + raw_path = _file_path(args) + path = _safe_path(context, raw_path) + except ValueError as exc: + return error_result(str(exc)) old = str(args.get("old_string") or args.get("old") or "") new = str(args.get("new_string") or args.get("new") or "") if not old: diff --git a/tests/selfhosted/test_tools.py b/tests/selfhosted/test_tools.py index db1b4c2..2dec69b 100644 --- a/tests/selfhosted/test_tools.py +++ b/tests/selfhosted/test_tools.py @@ -165,6 +165,50 @@ def test_read_keeps_legacy_inputs_without_ambiguity(tmp_path) -> None: assert _text(range_conflict) == "view_range cannot be combined with offset or limit" +@pytest.mark.parametrize("path_key", ["file_path", "path", "file"]) +def test_file_tools_accept_path_aliases(tmp_path, path_key) -> None: + context = ToolContext(workdir=str(tmp_path)) + source = tmp_path / "source.txt" + source.write_text("old") + + read = ReadFileTool().execute({path_key: "source.txt"}, context) + write = WriteFileTool().execute({path_key: "written.txt", "content": "created"}, context) + edit = EditFileTool().execute( + {path_key: "source.txt", "old_string": "old", "new_string": "new"}, + context, + ) + + assert not read.is_error + assert not write.is_error + assert not edit.is_error + assert (tmp_path / "written.txt").read_text() == "created" + assert source.read_text() == "new" + + +@pytest.mark.parametrize("tool", [ReadFileTool(), WriteFileTool(), EditFileTool()]) +def test_file_tools_validate_path_aliases(tmp_path, tool) -> None: + context = ToolContext(workdir=str(tmp_path)) + (tmp_path / "example.txt").write_text("old") + common = { + "file_path": "example.txt", + "path": "example.txt", + "file": "example.txt", + "content": "new", + "old_string": "old", + "new_string": "new", + } + + same = tool.execute(common, context) + conflict = tool.execute({**common, "path": "other.txt"}, context) + missing = tool.execute({"content": "new", "old_string": "old", "new_string": "new"}, context) + + assert not same.is_error + assert conflict.is_error + assert _text(conflict) == "file_path, path, and file must not conflict" + assert missing.is_error + assert _text(missing) == "file_path is required" + + def test_grep_skips_symlink_that_escapes_workdir(tmp_path) -> None: outside = tmp_path.parent / f"{tmp_path.name}-outside" outside.mkdir()