_remote_write branched on getattr(env, "_stdin_mode", "pipe") and only passed stdin_data on pipe backends, echoing base64 into argv elsewhere. BaseEnvironment.execute already embeds stdin_data as a heredoc for heredoc-mode backends (modal/daytona/vercel), and managed_modal forwards it as stdinData; _write_to_sandbox already relies on that for every backend. The branch duplicated base-class logic, and its defensive getattr default meant a fake env with neither _stdin_mode nor a stdin_data parameter raised TypeError on every RPC response write. The poll loop swallowed the error, so no res_* file appeared and test_code_execution_file_rpc hung forever (it passes on base). Collapse to one path that always passes stdin_data, and teach the file-RPC Shell fake to accept it and feed it as input. ScriptedEnv no longer needs its _stdin_mode stub.
100 lines
4.6 KiB
Python
100 lines
4.6 KiB
Python
"""Generated stubs and the production file poller over real shell/filesystem I/O."""
|
|
import concurrent.futures
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from tools.code_execution_tool import generate_hermes_tools_module
|
|
from tools.code_execution_rpc import _rpc_poll_loop
|
|
|
|
|
|
CALLS = {
|
|
"web_search": {"query": "fixture", "limit": 3},
|
|
"web_extract": {"urls": ["https://example.test"], "char_limit": 3000},
|
|
"read_file": {"path": "reference", "offset": 2, "limit": 4},
|
|
"write_file": {"path": "output", "content": "café", "cross_profile": False},
|
|
"search_files": {"pattern": "x", "target": "files", "path": ".", "file_glob": "*.py",
|
|
"limit": 4, "offset": 2, "output_mode": "count", "context": 3, "order": "modified"},
|
|
"patch": {"path": "output", "old_string": "old", "new_string": "new", "replace_all": True,
|
|
"mode": "replace", "patch": None, "cross_profile": False},
|
|
"terminal": {"command": "echo fixture", "timeout": 3, "workdir": "/tmp"},
|
|
}
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_generated_file_rpc_kwargs_correlation_and_authority(tmp_path, monkeypatch):
|
|
from pm.shell import bash
|
|
from tools.registry import registry
|
|
import tools.file_tools # noqa: F401 - populate schemas
|
|
import tools.web_tools # noqa: F401
|
|
import tools.terminal_tool # noqa: F401
|
|
|
|
shell = bash()
|
|
assert shell
|
|
rpc = tmp_path / "rpc with spaces"
|
|
rpc.mkdir()
|
|
monkeypatch.delenv("HERMES_RPC_DIR", raising=False)
|
|
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
|
|
namespace = {}
|
|
exec(generate_hermes_tools_module([], transport="file"), namespace)
|
|
assert namespace["_RPC_DIR"] == str(tmp_path / "hermes_rpc")
|
|
assert "terminal" not in namespace
|
|
monkeypatch.setenv("HERMES_RPC_DIR", str(rpc))
|
|
monkeypatch.setenv("HERMES_RPC_TOKEN", "right-token")
|
|
exec(generate_hermes_tools_module(list(CALLS), transport="file"), namespace)
|
|
seen, log, counter = [], [], [0]
|
|
|
|
def dispatch(name, args, **kwargs):
|
|
seen.append((name, args.copy()))
|
|
return json.dumps({"name": name, "args": args})
|
|
|
|
monkeypatch.setattr("model_tools.handle_function_call", dispatch)
|
|
|
|
class Shell:
|
|
def execute(self, command, cwd=None, timeout=None, stdin_data=None):
|
|
result = subprocess.run([shell, "-c", command], cwd=cwd, timeout=timeout,
|
|
env=dict(os.environ), input=stdin_data or "", capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return {"output": result.stdout}
|
|
|
|
stop = threading.Event()
|
|
budget = len(CALLS) + 8
|
|
poller = threading.Thread(target=_rpc_poll_loop, args=(Shell(), str(rpc), "owner", log, counter,
|
|
budget, frozenset(CALLS), stop, "right-token"), daemon=True)
|
|
poller.start()
|
|
try:
|
|
# Raw clients bypass stub visibility; neither missing nor wrong token may dispatch.
|
|
for seq, token in [(9001, None), (9002, "wrong-token")]:
|
|
request = {"seq": seq, "tool": "terminal", "args": {"command": "forbidden"}}
|
|
if token is not None:
|
|
request["token"] = token
|
|
path = rpc / f"req_{seq}"
|
|
path.write_text(json.dumps(request), encoding="utf-8")
|
|
deadline = time.monotonic() + 5
|
|
while path.exists() and time.monotonic() < deadline:
|
|
stop.wait(.01)
|
|
assert not path.exists()
|
|
assert not (rpc / f"res_{seq:06d}").exists()
|
|
assert seen == [] and counter == [0]
|
|
for name, args in CALLS.items():
|
|
blocked = {"background", "heartbeat", "pty", "notify", "notify_on_complete", "watch_patterns", "persist_on_release"} if name == "terminal" else set()
|
|
schema_keys = set(registry.get_entry(name).schema["parameters"]["properties"]) - blocked
|
|
assert schema_keys <= set(args), (name, schema_keys - set(args))
|
|
assert namespace[name](**args) == {"name": name, "args": args}
|
|
assert seen == list(CALLS.items())
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
results = list(pool.map(lambda i: namespace["terminal"](f"tag-{i}", 3, "/tmp"), range(8)))
|
|
assert [r["args"]["command"] for r in results] == [f"tag-{i}" for i in range(8)]
|
|
assert "not available" in namespace["_call"]("unauthorized-tool", {})["error"]
|
|
assert "limit reached" in namespace["terminal"]("over-budget")["error"]
|
|
assert counter == [budget] and len(seen) == len(log) == budget
|
|
finally:
|
|
stop.set()
|
|
poller.join(timeout=10)
|
|
assert not poller.is_alive()
|