560 lines
17 KiB
Python
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"
|