mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 05:17:09 +00:00
A session's sandbox is made when its first turn runs, not at connect or folder pick. The folder step restarts the Homebrew gateway on a Mac and checks the config in use. The preparing line names the real sandbox.
223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""Workspace tools in a sandbox: same definitions, same results, execution in the runner.
|
|
|
|
Every operation here runs twice on twin folders: once with the in-process tools (direct
|
|
mode) and once with the proxies that send execution to a tool runner. The answers, the
|
|
errors and the files left on disk must match, and what the model is shown about each tool
|
|
(name, schema, approval metadata) must be identical.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="the tool runner needs Unix sockets")
|
|
|
|
from coworker.agents.base import AgentContext # noqa: E402 (before catalog: import order)
|
|
from coworker import catalog # noqa: E402, I001
|
|
from coworker.roots import RootDir # noqa: E402
|
|
from coworker.sandbox.bundle import build_runner_zipapp # noqa: E402
|
|
from coworker.sandbox.providers.runner_local import RunnerLocalProvider # noqa: E402
|
|
from coworker.sandbox.workspace import RunnerWorkspace # noqa: E402
|
|
from coworker.tools import ToolRegistry # noqa: E402
|
|
|
|
CAPABILITIES = ["code_files", "git", "search"]
|
|
|
|
|
|
def _seed(folder: Path) -> None:
|
|
(folder / "src").mkdir(parents=True)
|
|
(folder / "src" / "app.py").write_text("def greet():\n return 'hello'\n\n# TODO: say more\n")
|
|
(folder / "src" / "util.py").write_text("VALUE = 1\n# TODO: remove\n")
|
|
(folder / "README.md").write_text("# Demo\n\nSome text.\n")
|
|
(folder / "node_modules").mkdir()
|
|
(folder / "node_modules" / "junk.js").write_text("// TODO: never shown\n")
|
|
for cmd in (["init", "-q"], ["add", "-A"], ["-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-qm", "first"]):
|
|
subprocess.run(["git", "-C", str(folder), *cmd], check=True, capture_output=True)
|
|
|
|
|
|
def _tree(folder: Path) -> dict[str, str]:
|
|
return {
|
|
str(p.relative_to(folder)): p.read_text(errors="replace")
|
|
for p in sorted(folder.rglob("*"))
|
|
if p.is_file() and ".git" not in p.relative_to(folder).parts
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def twins(tmp_path):
|
|
"""(direct tools, runner tools, direct folder, runner folder), both folders seeded alike."""
|
|
direct_dir, runner_dir = tmp_path / "direct" / "ws", tmp_path / "runner" / "ws"
|
|
for folder in (direct_dir, runner_dir):
|
|
folder.mkdir(parents=True)
|
|
_seed(folder)
|
|
direct_ctx = AgentContext(workspace=direct_dir)
|
|
provider = RunnerLocalProvider(cwd=runner_dir, runner_path=build_runner_zipapp(tmp_path / "dist"))
|
|
sandbox = RunnerWorkspace(provider, cwd=runner_dir)
|
|
runner_ctx = AgentContext(workspace=runner_dir, executor=sandbox.executor, sandbox=sandbox)
|
|
direct = {t.__name__: t for t in catalog.expand(CAPABILITIES, direct_ctx)}
|
|
through_runner = {t.__name__: t for t in catalog.expand(CAPABILITIES, runner_ctx)}
|
|
yield direct, through_runner, direct_dir, runner_dir
|
|
sandbox.close()
|
|
|
|
|
|
def _both(twins, name, *args, **kwargs):
|
|
"""Run one tool in both modes. Returns the two outcomes with each folder's own path
|
|
replaced by a placeholder, so they can be compared."""
|
|
direct, through_runner, direct_dir, runner_dir = twins
|
|
outcomes = []
|
|
for tools, folder in ((direct, direct_dir), (through_runner, runner_dir)):
|
|
try:
|
|
outcome = ("ok", tools[name](*args, **kwargs))
|
|
except Exception as exc:
|
|
outcome = ("raised", type(exc).__name__, str(exc))
|
|
outcomes.append(repr(outcome).replace(str(folder.resolve()), "<ws>").replace(str(folder), "<ws>"))
|
|
return outcomes
|
|
|
|
|
|
def test_the_model_sees_the_same_tools_either_way(twins):
|
|
direct, through_runner, _, _ = twins
|
|
assert sorted(direct) == sorted(through_runner)
|
|
a, b = ToolRegistry(), ToolRegistry()
|
|
a.register_all(list(direct.values()))
|
|
b.register_all(list(through_runner.values()))
|
|
for name in direct:
|
|
assert a.get(name).schema == b.get(name).schema, name
|
|
assert vars(a.get(name).metadata) == vars(b.get(name).metadata), name
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name, args, kwargs",
|
|
[
|
|
("read_file", ("src/app.py",), {}),
|
|
("read_file", ("src/app.py",), {"start_line": 2, "max_lines": 1}),
|
|
("read_file", ("missing.py",), {}),
|
|
("read_file", ("../outside.txt",), {}),
|
|
("list_files", (), {}),
|
|
("list_files", ("src",), {"pattern": "*.py", "recursive": False}),
|
|
("grep", ("TODO",), {}),
|
|
("grep", ("TODO", "src"), {"glob": "util.*", "max_results": 5}),
|
|
("grep", ("(unclosed",), {}),
|
|
("grep", ("x", "../.."), {}),
|
|
("git_status", (), {}),
|
|
("git_diff", (), {}),
|
|
("git_log", (), {"max_count": 5}),
|
|
("git_log", ("src/app.py",), {}),
|
|
],
|
|
)
|
|
def test_reading_tools_answer_alike(twins, name, args, kwargs):
|
|
direct_answer, runner_answer = _both(twins, name, *args, **kwargs)
|
|
if name == "git_log": # commit hashes differ between the twin repositories by design
|
|
import re
|
|
|
|
direct_answer, runner_answer = (re.sub(r"'hash': '[0-9a-f]+'", "'hash': '<h>'", x) for x in (direct_answer, runner_answer))
|
|
assert direct_answer == runner_answer
|
|
|
|
|
|
def test_editing_tools_answer_alike_and_leave_the_same_files(twins):
|
|
_, _, direct_dir, runner_dir = twins
|
|
steps = [
|
|
("write_file", ("notes/new.md", "one\ntwo\nthree\n"), {}),
|
|
("write_file", ("notes/new.md", "again\n"), {"overwrite": False}), # refuses
|
|
("replace_in_file", ("src/app.py", "'hello'", "'hi'"), {}),
|
|
("replace_in_file", ("src/app.py", "not-there", "x"), {}), # raises
|
|
("replace_in_file", ("src/util.py", "VALUE", "NUMBER"), {"expected_replacements": 2}), # raises
|
|
(
|
|
"apply_unified_diff",
|
|
("--- a/README.md\n+++ b/README.md\n@@ -1,3 +1,3 @@\n # Demo\n \n-Some text.\n+Some better text.\n",),
|
|
{},
|
|
),
|
|
(
|
|
"apply_patch",
|
|
("*** Begin Patch\n*** Add File: notes/added.txt\n+made by a patch\n*** Delete File: src/util.py\n*** End Patch\n",),
|
|
{},
|
|
),
|
|
("write_file", ("../escape.txt", "no"), {}), # outside the session's folders
|
|
("git_status", (), {}),
|
|
("git_diff", ("README.md",), {}),
|
|
]
|
|
for name, args, kwargs in steps:
|
|
direct_answer, runner_answer = _both(twins, name, *args, **kwargs)
|
|
assert direct_answer == runner_answer, name
|
|
assert _tree(direct_dir) == _tree(runner_dir)
|
|
assert "Some better text." in (runner_dir / "README.md").read_text()
|
|
assert not (runner_dir / "src" / "util.py").exists()
|
|
assert not (runner_dir.parent / "escape.txt").exists()
|
|
|
|
|
|
def test_a_folder_granted_later_is_seen_at_once_and_read_only_is_kept(tmp_path):
|
|
"""The session's roots list is live: a folder added while the session runs must work
|
|
on the next call, and a read-only folder must refuse writes, exactly as in direct mode."""
|
|
primary, extra = tmp_path / "scratch", tmp_path / "granted"
|
|
primary.mkdir()
|
|
extra.mkdir()
|
|
(extra / "ref.txt").write_text("reference\n")
|
|
roots = [RootDir(path=primary, writable=True)]
|
|
provider = RunnerLocalProvider(cwd=primary, runner_path=build_runner_zipapp(tmp_path / "dist"))
|
|
sandbox = RunnerWorkspace(provider, cwd=primary)
|
|
try:
|
|
ctx = AgentContext(workspace=primary, executor=sandbox.executor, roots=roots, sandbox=sandbox)
|
|
tools = {t.__name__: t for t in catalog.expand(["files"], ctx)}
|
|
assert "error" in tools["read_file"](str(extra / "ref.txt")) # not granted yet
|
|
roots.append(RootDir(path=extra, writable=False))
|
|
assert "reference" in tools["read_file"](str(extra / "ref.txt"))["content"]
|
|
with pytest.raises(PermissionError):
|
|
tools["write_file"](str(extra / "new.txt"), "nope")
|
|
tools["write_file"]("mine.txt", "fine")
|
|
assert (primary / "mine.txt").read_text() == "fine"
|
|
finally:
|
|
sandbox.close()
|
|
|
|
|
|
def test_direct_mode_uses_the_tools_untouched(tmp_path):
|
|
(tmp_path / "f.txt").write_text("x\n")
|
|
plain = catalog.expand(CAPABILITIES, AgentContext(workspace=tmp_path))
|
|
assert not any(hasattr(t, "__wrapped__") for t in plain) # no proxy in the way
|
|
|
|
|
|
def test_the_packed_runner_carries_the_toolkits_and_needs_no_site_packages(tmp_path):
|
|
zipapp = build_runner_zipapp(tmp_path)
|
|
with zipfile.ZipFile(zipapp) as zf:
|
|
names = set(zf.namelist())
|
|
assert {"owrunner/aisuite_toolkits/files.py", "owrunner/aisuite_toolkits/git.py", "owrunner/agents.py"} <= names
|
|
probe = (
|
|
"import sys; sys.path.insert(0, sys.argv[1]);"
|
|
"from owrunner import toolcalls;"
|
|
"assert toolcalls.FileToolkit.__module__.startswith('owrunner.'), toolcalls.FileToolkit.__module__;"
|
|
"print(toolcalls.call('list_files', {}, workspace=sys.argv[2]))"
|
|
)
|
|
(tmp_path / "ws").mkdir()
|
|
(tmp_path / "ws" / "a.txt").write_text("a")
|
|
done = subprocess.run([sys.executable, "-S", "-c", probe, str(zipapp), str(tmp_path / "ws")], capture_output=True, text=True)
|
|
assert done.returncode == 0, done.stderr
|
|
assert done.stdout.strip() == "['a.txt']"
|
|
|
|
|
|
def test_a_session_workspace_makes_its_sandbox_on_first_use(tmp_path):
|
|
"""RunnerWorkspace(start=False): nothing is made until the runner is needed, and the
|
|
file tools are still routed to the runner (never run here) before that."""
|
|
folder = tmp_path / "ws"
|
|
folder.mkdir()
|
|
(folder / "a.txt").write_text("hello\n")
|
|
provider = RunnerLocalProvider(cwd=folder, runner_path=build_runner_zipapp(tmp_path / "dist"))
|
|
made: list[str] = []
|
|
real_create = provider.create
|
|
provider.create = lambda: (made.append("create"), real_create())[1]
|
|
sandbox = RunnerWorkspace(provider, cwd=folder, start=False)
|
|
try:
|
|
assert not sandbox.started and made == []
|
|
ctx = AgentContext(workspace=folder, executor=sandbox.executor, sandbox=sandbox)
|
|
tools = {t.__name__: t for t in catalog.expand(["code_files"], ctx)}
|
|
assert getattr(tools["read_file"], "__wrapped_in_runner__", True) # proxied, not local
|
|
assert "hello" in str(tools["read_file"]("a.txt")) # the first call makes the sandbox
|
|
assert sandbox.started and made == ["create"]
|
|
assert sandbox.executor.run("echo again")["exit_code"] == 0 and made == ["create"]
|
|
finally:
|
|
sandbox.close()
|
|
unused = RunnerWorkspace(RunnerLocalProvider(cwd=folder, runner_path=build_runner_zipapp(tmp_path / "dist2")), cwd=folder, start=False)
|
|
unused.close() # never started: nothing to take down
|
|
assert not unused.started
|