-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
fix(core): return clear error when file read tool receives a directory path #9090
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -78,13 +78,26 @@ class ParsedDocument: | |||||||||||||||||||||||||||||
| text: str | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _directory_read_error(path: str) -> str: | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| f"Error: `{path}` is a directory, not a file. " | ||||||||||||||||||||||||||||||
| "Use a file path instead, or use `astrbot_execute_shell` " | ||||||||||||||||||||||||||||||
| "to list directory contents." | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _build_probe_script(path: str) -> str: | ||||||||||||||||||||||||||||||
| directory_error = _directory_read_error(path) | ||||||||||||||||||||||||||||||
| return f""" | ||||||||||||||||||||||||||||||
| import base64 | ||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| path = Path({path!r}) | ||||||||||||||||||||||||||||||
| if path.is_dir(): | ||||||||||||||||||||||||||||||
| raise ValueError({directory_error!r}) | ||||||||||||||||||||||||||||||
| if not path.is_file(): | ||||||||||||||||||||||||||||||
| raise ValueError("Error: File does not exist: `{path}`") | ||||||||||||||||||||||||||||||
|
Comment on lines
+99
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): The error message in the probe script will literally contain In the generated probe script this string isn’t an f-string, so |
||||||||||||||||||||||||||||||
| with path.open("rb") as file_obj: | ||||||||||||||||||||||||||||||
| sample = file_obj.read({_FILE_SNIFF_BYTES}) | ||||||||||||||||||||||||||||||
| print( | ||||||||||||||||||||||||||||||
|
|
@@ -652,13 +665,24 @@ async def read_file_tool_result( | |||||||||||||||||||||||||||||
| workspace_dir: str | None = None, | ||||||||||||||||||||||||||||||
| ) -> ToolExecResult: | ||||||||||||||||||||||||||||||
| if local_mode: | ||||||||||||||||||||||||||||||
| file_path = Path(path) | ||||||||||||||||||||||||||||||
| if file_path.is_dir(): | ||||||||||||||||||||||||||||||
| return _directory_read_error(path) | ||||||||||||||||||||||||||||||
| if not file_path.is_file(): | ||||||||||||||||||||||||||||||
| return f"Error: File does not exist: `{path}`" | ||||||||||||||||||||||||||||||
| probe_payload = await _probe_local_file(path) | ||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||
| probe_payload = await _exec_python_json( | ||||||||||||||||||||||||||||||
| booter, | ||||||||||||||||||||||||||||||
| _build_probe_script(path), | ||||||||||||||||||||||||||||||
| action="file probe", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| probe_payload = await _exec_python_json( | ||||||||||||||||||||||||||||||
| booter, | ||||||||||||||||||||||||||||||
| _build_probe_script(path), | ||||||||||||||||||||||||||||||
| action="file probe", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| except RuntimeError as exc: | ||||||||||||||||||||||||||||||
| probe_error = str(exc).removeprefix("file probe failed: ").strip() | ||||||||||||||||||||||||||||||
| if probe_error.startswith("Error:"): | ||||||||||||||||||||||||||||||
| return probe_error | ||||||||||||||||||||||||||||||
| raise | ||||||||||||||||||||||||||||||
|
Comment on lines
+681
to
+685
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In remote/sandbox mode, when the probe script raises a
Suggested change
|
||||||||||||||||||||||||||||||
| sample_b64 = str(probe_payload.get("sample_b64", "") or "") | ||||||||||||||||||||||||||||||
| sample = base64.b64decode(sample_b64) if sample_b64 else b"" | ||||||||||||||||||||||||||||||
| size_bytes = int(probe_payload.get("size_bytes", 0) or 0) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -407,6 +407,26 @@ def test_detect_text_encoding_allows_utf8_probe_cut_mid_character(): | |
| assert file_read_utils.detect_text_encoding(sample) in {"utf-8", "utf-8-sig"} | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_file_read_tool_rejects_directory_path( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| tmp_path, | ||
| ): | ||
| workspace = _setup_local_fs_tools(monkeypatch, tmp_path) | ||
| directory = workspace / "api" | ||
| directory.mkdir() | ||
|
|
||
| result = await fs_tools.FileReadTool().call( | ||
| _make_context(), | ||
| path="api", | ||
| ) | ||
|
|
||
| assert "is a directory, not a file" in result | ||
| assert "astrbot_execute_shell" in result | ||
| assert "Permission denied" not in result | ||
| assert "Is a directory" not in result | ||
|
|
||
|
Comment on lines
+411
to
+428
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current test only covers @pytest.mark.asyncio
async def test_file_read_tool_rejects_directory_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
directory = workspace / "api"
directory.mkdir()
result = await fs_tools.FileReadTool().call(
_make_context(),
path="api",
)
assert "is a directory, not a file" in result
assert "astrbot_execute_shell" in result
assert "Permission denied" not in result
assert "Is a directory" not in result
@pytest.mark.asyncio
async def test_file_read_tool_rejects_directory_path_sandbox(
monkeypatch: pytest.MonkeyPatch,
):
booter = SimpleNamespace(
python=SimpleNamespace(
exec=AsyncMock(
return_value={
"data": {
"output": {},
"error": (
"Traceback (most recent call last):\n"
" File \"<string>\", line 8, in <module>\n"
"ValueError: Error: 'api' is a directory, not a file. "
"Use a file path instead, or use astrbot_execute_shell "
"to list directory contents."
)
}
}
)
)
)
async def _fake_get_booter(_ctx, _umo):
return booter
monkeypatch.setattr(fs_tools, "get_booter", _fake_get_booter)
result = await fs_tools.FileReadTool().call(
_make_sandbox_context(),
path="api",
)
assert "is a directory, not a file" in result
assert "astrbot_execute_shell" in resultReferences
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_file_read_tool_rejects_large_full_text_read_before_local_stream_read( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
pathcontains double quotes (e.g.,some"file.txt), interpolating it directly into the double-quoted string in the generated Python script will cause aSyntaxErrorat runtime. Instead, we should use an f-string in the generated script and evaluate thepathvariable (which is aPathobject) at runtime.