Files
OpenJarvis/tests/sandbox/test_runner.py
T
Jon Saad-FalconandClaude Opus 4.6 a4c4081ff4 Add desktop distribution pipeline: rolling releases, auto-updates, code signing
- Rewrite .github/workflows/desktop.yml: 2-job pipeline (validate + build-and-release)
  with rolling desktop-latest pre-release on push to main and stable desktop-v* releases
- Add UpdateChecker component: checks for updates on startup + every 30 min,
  background download with progress bar, one-click relaunch
- Configure Tauri updater: endpoints pointing to desktop-latest release, pubkey placeholder
- Add tauri-plugin-process for relaunch support (Cargo.toml, lib.rs, package.json)
- Add macOS Entitlements.plist for notarization (network + file access, no sandbox)
- Add scripts/bump-desktop-version.sh for atomic version bumps across 3 config files
- Add desktop/README.md with dev setup, auto-update architecture, signing docs
- Update .gitignore for desktop/node_modules, dist, target
- Configure macOS minimumSystemVersion, Windows timestampUrl
- Include all Phase 14-21 work: agent hardening, RBAC, taint tracking, workflows,
  skills, knowledge graph, sessions, A2A, MCP templates, WASM sandbox, TUI dashboard,
  production tools, CLI expansion, API expansion, learning productionization,
  Tauri desktop app, and 10 new channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:14:05 +00:00

274 lines
8.8 KiB
Python

"""Tests for ContainerRunner and SandboxedAgent."""
from __future__ import annotations
import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.agents._stubs import AgentResult, BaseAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.sandbox.runner import (
_OUTPUT_END,
_OUTPUT_START,
ContainerRunner,
SandboxedAgent,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wrap_output(payload: dict) -> str:
return f"{_OUTPUT_START}\n{json.dumps(payload)}\n{_OUTPUT_END}"
def _mock_proc(
stdout: str = "",
stderr: str = "",
returncode: int = 0,
) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=["docker", "run"],
returncode=returncode,
stdout=stdout,
stderr=stderr,
)
# ---------------------------------------------------------------------------
# ContainerRunner tests
# ---------------------------------------------------------------------------
class TestContainerRunnerInit:
def test_defaults(self):
runner = ContainerRunner()
assert runner._image == ContainerRunner.DEFAULT_IMAGE
assert runner._timeout == ContainerRunner.DEFAULT_TIMEOUT
assert runner._runtime == "docker"
assert runner._max_concurrent == 5
def test_custom_values(self):
runner = ContainerRunner(
image="custom:latest",
timeout=600,
max_concurrent=10,
runtime="podman",
)
assert runner._image == "custom:latest"
assert runner._timeout == 600
assert runner._runtime == "podman"
assert runner._max_concurrent == 10
class TestBuildDockerArgs:
def test_basic_args(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"):
args = runner._build_docker_args(
"test-container", [], None,
)
assert "/usr/bin/docker" in args
assert "run" in args
assert "--rm" in args
assert "--name" in args
assert "test-container" in args
assert "--network" in args
assert "none" in args
assert runner._image in args
def test_with_mounts(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"):
args = runner._build_docker_args(
"test-container", ["/data/project"], None,
)
assert "-v" in args
idx = args.index("-v")
assert args[idx + 1] == "/data/project:/data/project:ro"
def test_with_env(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"):
args = runner._build_docker_args(
"test-container", [], {"FOO": "bar"},
)
assert "-e" in args
idx = args.index("-e")
assert args[idx + 1] == "FOO=bar"
class TestContainerRunnerRun:
def test_successful_run(self):
runner = ContainerRunner()
output = _wrap_output({"content": "Hello!"})
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", return_value=_mock_proc(
stdout=output,
)):
result = runner.run({"prompt": "test"})
assert result["content"] == "Hello!"
def test_timeout(self):
runner = ContainerRunner(timeout=5)
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
cmd=["docker"], timeout=5,
)), \
patch.object(runner, "stop"):
result = runner.run({"prompt": "test"})
assert result["error"] is True
assert result["error_type"] == "timeout"
def test_nonzero_exit(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", return_value=_mock_proc(
returncode=1, stderr="OOM killed",
)):
result = runner.run({"prompt": "test"})
assert result["error"] is True
assert "OOM killed" in result["content"]
def test_no_sentinel_output(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", return_value=_mock_proc(
stdout="plain text output",
)):
result = runner.run({"prompt": "test"})
assert result["content"] == "plain text output"
class TestContainerRunnerRuntimeCheck:
def test_raises_when_runtime_not_found(self):
runner = ContainerRunner()
with patch("shutil.which", return_value=None):
with pytest.raises(RuntimeError, match="not found"):
runner._check_runtime()
class TestCleanupOrphans:
def test_cleanup_orphans(self):
runner = ContainerRunner()
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run") as mock_run:
mock_run.return_value = _mock_proc(stdout="abc123\ndef456")
runner.cleanup_orphans()
assert mock_run.call_count == 2 # ps + rm
class TestContainerRunnerParseOutput:
def test_parse_valid_json(self):
output = _wrap_output({"content": "result", "metadata": {}})
result = ContainerRunner._parse_output(output)
assert result["content"] == "result"
def test_parse_no_sentinels(self):
result = ContainerRunner._parse_output("plain output")
assert result["content"] == "plain output"
def test_parse_invalid_json(self):
output = f"{_OUTPUT_START}\nnot-json\n{_OUTPUT_END}"
result = ContainerRunner._parse_output(output)
assert result.get("parse_error") is True
# ---------------------------------------------------------------------------
# SandboxedAgent tests
# ---------------------------------------------------------------------------
class TestSandboxedAgentInit:
def test_accepts_tools_false(self):
assert SandboxedAgent.accepts_tools is False
def test_agent_id(self):
assert SandboxedAgent.agent_id == "sandboxed"
def test_wraps_agent(self):
inner = MagicMock(spec=BaseAgent)
inner.agent_id = "inner"
inner._engine = MagicMock()
inner._model = "test-model"
runner = MagicMock(spec=ContainerRunner)
agent = SandboxedAgent(
inner, runner,
engine=inner._engine, model="test-model",
)
assert agent._wrapped_agent is inner
assert agent._runner is runner
class TestSandboxedAgentRun:
def test_delegates_to_runner(self):
inner = MagicMock(spec=BaseAgent)
inner.agent_id = "test"
inner._model = "test-model"
runner = MagicMock(spec=ContainerRunner)
runner.run.return_value = {
"content": "sandbox result",
"tool_results": [],
"metadata": {},
}
engine = MagicMock()
agent = SandboxedAgent(
inner, runner, engine=engine, model="test-model",
)
result = agent.run("hello")
assert isinstance(result, AgentResult)
assert result.content == "sandbox result"
assert result.turns == 1
runner.run.assert_called_once()
def test_parses_tool_results(self):
inner = MagicMock(spec=BaseAgent)
inner.agent_id = "test"
inner._model = "m"
runner = MagicMock(spec=ContainerRunner)
runner.run.return_value = {
"content": "done",
"tool_results": [
{
"tool_name": "calc",
"content": "42",
"success": True,
},
],
}
engine = MagicMock()
agent = SandboxedAgent(
inner, runner, engine=engine, model="m",
)
result = agent.run("compute")
assert len(result.tool_results) == 1
assert result.tool_results[0].tool_name == "calc"
assert result.tool_results[0].content == "42"
def test_emits_events(self):
inner = MagicMock(spec=BaseAgent)
inner.agent_id = "test"
inner._model = "m"
runner = MagicMock(spec=ContainerRunner)
runner.run.return_value = {"content": "ok"}
bus = EventBus(record_history=True)
engine = MagicMock()
agent = SandboxedAgent(
inner, runner, engine=engine, model="m", bus=bus,
)
agent.run("test")
types = [e.event_type for e in bus.history]
assert EventType.AGENT_TURN_START in types
assert EventType.AGENT_TURN_END in types