Files
2026-07-13 13:12:33 +08:00

560 lines
17 KiB
Python

from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from pathlib import Path
from types import SimpleNamespace
import pytest
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
from opensquilla.sandbox import sensitive_paths
from opensquilla.sandbox.integration import reset_runtime
from opensquilla.tools.builtin import patch as patch_tool
from opensquilla.tools.registry import get_default_registry
from opensquilla.tools.types import (
InteractionMode,
RetryableToolInputError,
ToolContext,
ToolError,
current_tool_context,
)
def _original_async(fn: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]:
return fn.__wrapped__.__wrapped__ # type: ignore[attr-defined, no-any-return]
@pytest.fixture(autouse=True)
def _reset_approval_queue():
reset_approval_queue()
yield
reset_approval_queue()
@pytest.fixture(autouse=True)
def _run_patch_executor_inline(monkeypatch: pytest.MonkeyPatch) -> None:
def _run_in_executor_inline(self, executor, func, *args):
future = self.create_future()
try:
future.set_result(func(*args))
except Exception as exc: # pragma: no cover - exercised by awaiting callers
future.set_exception(exc)
return future
monkeypatch.setattr(
patch_tool.asyncio.BaseEventLoop,
"run_in_executor",
_run_in_executor_inline,
)
def test_apply_patch_schema_exposes_optional_approval_id() -> None:
registered = get_default_registry().get("apply_patch")
assert registered is not None
assert "approval_id" in registered.spec.parameters
assert "approval_id" not in registered.spec.required
def test_apply_patch_schema_exposes_optional_patch_file_path() -> None:
registered = get_default_registry().get("apply_patch")
assert registered is not None
assert "path" in registered.spec.parameters
assert "path" not in registered.spec.required
@pytest.mark.asyncio
async def test_apply_patch_blocks_sensitive_path(tmp_path: Path) -> None:
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: .env
+TOKEN=secret
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "sensitive_path"
assert not (tmp_path / ".env").exists()
@pytest.mark.asyncio
async def test_apply_patch_blocks_sensitive_key_file_suffix(tmp_path: Path) -> None:
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: id_rsa
+secret
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["sensitive_path"] == "/id_rsa"
assert not (tmp_path / "id_rsa").exists()
@pytest.mark.asyncio
async def test_apply_patch_blocks_workspace_write_deny_glob(tmp_path: Path) -> None:
token = current_tool_context.set(
ToolContext(
workspace_dir=str(tmp_path),
workspace_write_deny_globs=["blocked/**"],
)
)
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: blocked/generated.txt
+nope
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
assert payload["matched_pattern"] == "blocked/**"
assert not (tmp_path / "blocked" / "generated.txt").exists()
@pytest.mark.asyncio
async def test_apply_patch_accepts_standard_unified_hunk(tmp_path: Path) -> None:
target = tmp_path / "src" / "feature.py"
target.parent.mkdir()
target.write_text("old = 1\nkeep = True\n", encoding="utf-8")
ctx = ToolContext(workspace_dir=str(tmp_path))
token = current_tool_context.set(ctx)
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Update File: src/feature.py
@@ -1,2 +1,2 @@
-old = 1
+old = 2
keep = True
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert result == "Applied patch: 1 file(s) modified"
assert target.read_text(encoding="utf-8") == "old = 2\nkeep = True\n"
assert [entry["relative_path"] for entry in ctx.workspace_file_writes] == [
"src/feature.py"
]
@pytest.mark.asyncio
async def test_apply_patch_accepts_patch_text_from_configured_scratch_file(
tmp_path: Path,
) -> None:
target = tmp_path / "src" / "feature.py"
target.parent.mkdir()
target.write_text("old = 1\n", encoding="utf-8")
scratch = tmp_path / "scratch"
scratch.mkdir()
patch_file = scratch / "fix.patch"
patch_file.write_text(
"""*** Begin Patch
*** Update File: src/feature.py
@@ -1,1 +1,1 @@
-old = 1
+old = 2
*** End Patch""",
encoding="utf-8",
)
token = current_tool_context.set(
ToolContext(workspace_dir=str(tmp_path), scratch_dir=str(scratch))
)
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(path=str(patch_file))
finally:
current_tool_context.reset(token)
assert result == "Applied patch: 1 file(s) modified"
assert target.read_text(encoding="utf-8") == "old = 2\n"
@pytest.mark.asyncio
async def test_apply_patch_notes_docs_and_derived_workspace_writes(tmp_path: Path) -> None:
docs_target = tmp_path / "docs" / "content" / "manual" / "manual.yml"
docs_target.parent.mkdir(parents=True)
docs_target.write_text("old docs\n", encoding="utf-8")
derived_target = tmp_path / "jq.1.prebuilt"
derived_target.write_text("old generated\n", encoding="utf-8")
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Update File: docs/content/manual/manual.yml
@@ -1,1 +1,1 @@
-old docs
+new docs
*** Update File: jq.1.prebuilt
@@ -1,1 +1,1 @@
-old generated
+new generated
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert result.startswith("Applied patch: 2 file(s) modified")
assert "documentation file(s) changed" in result
assert "verify the docs build" in result
assert "generated or derived-looking file(s) changed" in result
assert "regenerate/verify" in result
@pytest.mark.asyncio
async def test_apply_patch_rejects_update_without_hunks(tmp_path: Path) -> None:
target = tmp_path / "src" / "feature.py"
target.parent.mkdir()
target.write_text("old = 1\n", encoding="utf-8")
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
with pytest.raises(RetryableToolInputError, match="did not contain any hunk headers"):
await apply_patch(
"""*** Begin Patch
*** Update File: src/feature.py
--- a/src/feature.py
+++ b/src/feature.py
-old = 1
+old = 2
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert target.read_text(encoding="utf-8") == "old = 1\n"
@pytest.mark.asyncio
async def test_apply_patch_context_mismatch_is_model_retriable(tmp_path: Path) -> None:
target = tmp_path / "src" / "feature.py"
target.parent.mkdir()
target.write_text("actual = 1\n", encoding="utf-8")
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
with pytest.raises(RetryableToolInputError) as exc_info:
await apply_patch(
"""*** Begin Patch
*** Update File: src/feature.py
@@ -1,1 +1,1 @@
-expected = 1
+actual = 2
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert "context mismatch" in exc_info.value.user_message
assert "Read the current file content" in exc_info.value.user_message
assert target.read_text(encoding="utf-8") == "actual = 1\n"
@pytest.mark.asyncio
async def test_apply_patch_allows_workspace_under_sensitive_parent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(sensitive_paths, "_SENSITIVE_PREFIXES", (str(tmp_path),))
monkeypatch.setattr(
sensitive_paths,
"_WORKSPACE_PARENT_EXCEPTION_MARKERS",
(str(tmp_path),),
)
token = current_tool_context.set(ToolContext(workspace_dir=str(workspace)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: docs/plan.md
+hello
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert result.startswith("Applied patch: 1 file(s) added")
assert "documentation file(s) changed" in result
assert (workspace / "docs" / "plan.md").read_text(encoding="utf-8") == "hello"
@pytest.mark.asyncio
async def test_apply_patch_workspace_exception_keeps_leaf_secret_blocks(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(sensitive_paths, "_SENSITIVE_PREFIXES", (str(tmp_path),))
monkeypatch.setattr(
sensitive_paths,
"_WORKSPACE_PARENT_EXCEPTION_MARKERS",
(str(tmp_path),),
)
token = current_tool_context.set(ToolContext(workspace_dir=str(workspace)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: .env
+TOKEN=secret
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "sensitive_path"
assert not (workspace / ".env").exists()
@pytest.mark.asyncio
async def test_apply_patch_workspace_escape_blocks_without_sandbox_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("old\n", encoding="utf-8")
monkeypatch.setattr(patch_tool, "_default_patch_root", lambda: tmp_path.resolve())
token = current_tool_context.set(ToolContext(workspace_dir=str(workspace)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Update File: outside.txt
@@@ -1,1 +1,1 @@@
-old
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "outside_workspace"
assert outside.read_text(encoding="utf-8") == "old\n"
assert get_approval_queue().list_pending("exec") == []
@pytest.mark.asyncio
async def test_apply_patch_sensitive_path_blocks_even_with_approval_id(tmp_path: Path) -> None:
approval_id = get_approval_queue().request(
"exec",
{
"toolName": "apply_patch",
"command": "apply_patch pretend",
"args": {},
},
)
get_approval_queue().resolve(approval_id, True)
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Add File: .env
+TOKEN=secret
*** End Patch""",
approval_id=approval_id,
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "sensitive_path"
assert not (tmp_path / ".env").exists()
@pytest.mark.asyncio
async def test_apply_patch_rejects_foreign_posix_path_on_windows(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(patch_tool, "os", SimpleNamespace(name="nt"), raising=False)
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
with pytest.raises(ToolError, match="foreign_host_path"):
await apply_patch(
"""*** Begin Patch
*** Add File: /Users/a1/Desktop/report.txt
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
@pytest.mark.asyncio
async def test_apply_patch_rejects_foreign_windows_path_on_posix(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(patch_tool, "os", SimpleNamespace(name="posix"), raising=False)
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
with pytest.raises(ToolError, match="foreign_host_path"):
await apply_patch(
"""*** Begin Patch
*** Add File: C:\\Users\\a1\\Desktop\\report.txt
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
@pytest.mark.asyncio
async def test_apply_patch_elevated_full_skips_outside_workspace_approval(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("old\n", encoding="utf-8")
monkeypatch.setattr(patch_tool, "_default_patch_root", lambda: tmp_path.resolve())
token = current_tool_context.set(ToolContext(workspace_dir=str(workspace), elevated="full"))
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Update File: outside.txt
@@@ -1,1 +1,1 @@@
-old
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert result == "Applied patch: 1 file(s) modified"
assert outside.read_text(encoding="utf-8") == "new\n"
assert get_approval_queue().list_pending("exec") == []
@pytest.mark.asyncio
async def test_apply_patch_run_mode_full_skips_sandbox_wrapper_gate(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
reset_runtime()
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("old\n", encoding="utf-8")
monkeypatch.setattr(patch_tool, "_default_patch_root", lambda: tmp_path.resolve())
token = current_tool_context.set(
ToolContext(
is_owner=True,
workspace_dir=str(workspace),
run_mode="full",
session_key="agent:main:test",
)
)
try:
result = await patch_tool.apply_patch(
"""*** Begin Patch
*** Update File: outside.txt
@@@ -1,1 +1,1 @@@
-old
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
reset_runtime()
assert result == (
"Applied patch: 1 file(s) modified Note: workspace changes are now present. "
"Before final, inspect git_diff and run focused verification for the changed behavior."
)
assert outside.read_text(encoding="utf-8") == "new\n"
assert get_approval_queue().list_pending("exec") == []
@pytest.mark.asyncio
async def test_apply_patch_unattended_bypass_blocks_outside_workspace_without_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("old\n", encoding="utf-8")
monkeypatch.setattr(patch_tool, "_default_patch_root", lambda: tmp_path.resolve())
token = current_tool_context.set(
ToolContext(
workspace_dir=str(workspace),
elevated="bypass",
interaction_mode=InteractionMode.UNATTENDED,
)
)
apply_patch = _original_async(patch_tool.apply_patch)
try:
result = await apply_patch(
"""*** Begin Patch
*** Update File: outside.txt
@@@ -1,1 +1,1 @@@
-old
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "outside_workspace"
assert outside.read_text(encoding="utf-8") == "old\n"
assert get_approval_queue().list_pending("exec") == []
@pytest.mark.asyncio
async def test_apply_patch_add_file_refuses_existing_file(tmp_path: Path) -> None:
target = tmp_path / "existing.txt"
target.write_text("old\n", encoding="utf-8")
token = current_tool_context.set(ToolContext(workspace_dir=str(tmp_path)))
apply_patch = _original_async(patch_tool.apply_patch)
try:
with pytest.raises(RetryableToolInputError, match="target already exists"):
await apply_patch(
"""*** Begin Patch
*** Add File: existing.txt
+new
*** End Patch"""
)
finally:
current_tool_context.reset(token)
assert target.read_text(encoding="utf-8") == "old\n"