Files
MemeMeow/tests/test_agent_runtime.py
infstellar 3df9f7878c feat(agent): add isolated executor and result protocol
Add authenticated in-container execution, shared runtime activity summaries, strict result-file validation, and OpenCode integration tests.

引入带认证的容器内执行器、共享 runtime 活动摘要、严格结果文件校验和 OpenCode 集成测试。
2026-08-15 15:23:15 +08:00

384 lines
17 KiB
Python

"""共享 Agent 容器边界和任务结果文件协议测试。"""
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
import uuid
import asyncio
import stat
import time
from types import SimpleNamespace
from pathlib import Path
import pytest
from starlette.requests import Request
from api import config_status
from backend.config import Settings
from backend.opencode import OpenCodeError, OpenCodeRunner
def make_settings(tmp_path: Path, **overrides: object) -> Settings:
"""构造仅使用临时目录的 Docker runtime 配置。"""
values: dict[str, object] = {
"data_root": tmp_path / "data",
"image_root": tmp_path / "data" / "images",
"opencode_model": "mememeow/gpt-5.6-luna",
"opencode_base_url": "https://example.invalid/v1",
"opencode_api_key": "test-key",
"agent_container_name": "mememeow-agent-runtime",
"agent_runtime_mode": "docker",
}
values.update(overrides)
return Settings(**values)
def candidate() -> dict[str, object]:
"""返回满足输出 schema 的最小业务对象。"""
return {
"title": "测试标题",
"summary": "测试摘要",
"subjects": ["主体"],
"visible_text": [],
"references": [],
"meaning": None,
"keywords": ["测试"],
"search_queries": [],
"uncertainties": [],
"source_urls": [],
}
def test_container_command_whitelists_environment_and_maps_image(tmp_path: Path):
"""容器命令使用 /images 路径且不继承宿主密钥或数据库环境。"""
image_root = tmp_path / "data" / "images"
image_root.mkdir(parents=True)
image = image_root / "nested" / "meme.png"
image.parent.mkdir()
image.write_bytes(b"image")
runner = OpenCodeRunner(make_settings(tmp_path), project_root=tmp_path)
command = runner.container_command(image, "write result", task_id="task-1")
assert command[:3] == ["docker", "exec", "--workdir"]
assert "/images/nested/meme.png" in command
assert "--auto" in command
assert "--env" in command
assert "test-key" not in " ".join(command)
assert all("=" not in value for index, value in enumerate(command) if index and command[index - 1] == "--env")
assert not any("DATABASE_URL" in value or "HOME=" in value for value in command)
assert "SERPAPI_API_KEY" not in command
def test_docker_environment_contains_claim_task_id(tmp_path: Path):
"""Docker 白名单环境必须和 Host 一样传递当前 claim task id。"""
runner = OpenCodeRunner(make_settings(tmp_path), project_root=tmp_path)
environment = runner._allowed_container_environment(1, "claim-task-123")
assert environment["MEMEMEOW_AGENT_TASK_ID"] == "claim-task-123"
assert "SERPAPI_API_KEY" not in environment
def test_docker_environment_uses_agent_callback_urls(tmp_path: Path):
"""宿主后端与 Docker Agent 分离时,容器应使用专用 host-gateway 回调地址。"""
runner = OpenCodeRunner(
make_settings(
tmp_path,
agent_reverse_image_internal_url="http://host.docker.internal:8275/internal/reverse-image/search",
agent_visual_search_internal_url="http://host.docker.internal:8275/internal/visual-search/match",
),
project_root=tmp_path,
)
environment = runner._allowed_container_environment(0, "claim-task-urls")
assert environment["MEMEMEOW_REVERSE_IMAGE_INTERNAL_URL"].startswith("http://host.docker.internal:")
assert environment["MEMEMEOW_VISUAL_SEARCH_INTERNAL_URL"].startswith("http://host.docker.internal:")
def test_image_path_outside_root_is_rejected(tmp_path: Path):
"""图片根目录外的路径不能映射到 Agent。"""
outside = tmp_path / "outside.png"
outside.write_bytes(b"x")
runner = OpenCodeRunner(make_settings(tmp_path), project_root=tmp_path)
with pytest.raises(OpenCodeError) as error:
runner.map_host_image_path(outside)
assert error.value.code == "agent_image_path_forbidden"
def test_image_symlink_is_rejected_before_resolution(tmp_path: Path):
"""图片根目录内的符号链接也不能借路径解析绕过只读边界。"""
image_root = tmp_path / "data" / "images"
image_root.mkdir(parents=True)
source = image_root / "source.png"
source.write_bytes(b"image")
link = image_root / "link.png"
link.symlink_to(source)
runner = OpenCodeRunner(make_settings(tmp_path), project_root=tmp_path)
with pytest.raises(OpenCodeError) as error:
runner.map_host_image_path(link)
assert error.value.code == "agent_image_path_forbidden"
def test_docker_image_root_mismatch_is_rejected_before_container_exec(tmp_path: Path):
"""Docker 固定 `/images` 挂载与自定义图片根不一致时稳定阻断任务。"""
custom_root = tmp_path / "custom-images"
custom_root.mkdir()
runner = OpenCodeRunner(make_settings(tmp_path, image_root=custom_root))
with pytest.raises(OpenCodeError) as error:
runner.prepare_runtime()
assert error.value.code == "agent_image_root_mismatch"
def test_runtime_probe_strictly_checks_non_root_and_dependencies(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""运行时探针必须把 UID 0 和可写依赖目录判为不安全。"""
runner = OpenCodeRunner(make_settings(tmp_path))
monkeypatch.setattr(runner, "_container_ready", lambda: (True, "ok"))
monkeypatch.setattr(runner, "_container_exec_probe", lambda arguments: (False, "uid=0") if arguments[:2] == ["sh", "-lc"] and "id -u" in arguments[-1] else (True, "ok"))
result = runner.runtime_probe()
assert result["non_root"] is False
assert result["verified"] is False
def test_config_exposes_sanitized_agent_runtime_probe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""`/config` 只返回固定 runtime 标识和布尔状态,不泄露诊断或密钥。"""
runner = OpenCodeRunner(make_settings(tmp_path))
monkeypatch.setattr(
runner,
"runtime_probe",
lambda: {
"mode": "shared-docker-container",
"container_name": "mememeow-agent-runtime",
"container_running": True,
"runtime_root_ready": True,
"workspace_ready": True,
"executable_ready": True,
"skills_ready": True,
"dependencies_ready": True,
"mounts_ready": True,
"non_root": True,
"network_ready": True,
"docker_socket_absent": True,
"verified": True,
"container_diagnostic": "宿主绝对路径不应返回",
},
)
app_stub = SimpleNamespace(
state=SimpleNamespace(
settings=runner.settings,
opencode=runner,
search_engine=SimpleNamespace(has_cache=lambda: False),
)
)
request = Request({
"type": "http",
"method": "GET",
"path": "/config",
"raw_path": b"/config",
"query_string": b"",
"headers": [],
"app": app_stub,
})
payload = asyncio.run(config_status(request))
assert payload["runtime_ready"] is True
assert payload["agent_runtime"]["mode"] == "shared-docker-container"
assert "container_diagnostic" not in payload["agent_runtime"]
assert "opencode_api_key" not in payload
def test_result_path_failure_releases_slot(tmp_path: Path):
"""任务结果路径冲突时不能泄漏已获取的 slot。"""
runner = OpenCodeRunner(make_settings(tmp_path))
runner.prepare_runtime = lambda: None
task_directory = runner.runtime_root / "task-results" / "slot-conflict"
task_directory.mkdir(parents=True)
(task_directory / "result.json.tmp").mkdir()
with pytest.raises(OpenCodeError) as error:
runner.run(tmp_path / "image.png", lambda *_: None, task_id="slot-conflict")
assert error.value.code == "agent_result_path_invalid"
assert runner._slot_semaphore.acquire(timeout=0.1)
runner._slot_semaphore.release()
def _run_container(runner: OpenCodeRunner, *arguments: str) -> subprocess.CompletedProcess[str]:
"""通过与生产运行器相同的 Docker/sg 包装执行容器命令。"""
command = runner._docker_command_for_execution(runner._container_exec(*arguments))
return subprocess.run(command, capture_output=True, text=True, timeout=20, check=False)
@pytest.mark.skipif(os.getenv("MEMEMEOW_AGENT_RUNTIME_E2E") != "1", reason="显式设置 MEMEMEOW_AGENT_RUNTIME_E2E=1 才运行 Docker 集成验收")
def test_shared_container_runs_distinct_sessions_and_result_directories():
"""两个真实 docker exec 任务共享容器,但使用不同 session 和结果目录。"""
project_root = Path(__file__).resolve().parent.parent
settings = Settings.from_env(project_root / ".env")
settings.agent_runtime_mode = "docker"
settings.agent_container_name = os.getenv("MEMEMEOW_AGENT_CONTAINER_NAME", "mememeow-agent-runtime")
runner = OpenCodeRunner(settings, project_root=project_root)
if not runner.runtime_probe().get("verified"):
pytest.skip("共享 Agent 容器未运行或探针未通过")
runner.prepare_runtime()
image = settings.image_root / "example_meme_1.jpg"
if not image.is_file():
pytest.skip("缺少 data/images/example_meme_1.jpg 集成图片")
suffix = uuid.uuid4().hex
task_ids = (f"integration-{suffix}-a", f"integration-{suffix}-b")
stub_path = f"/runtime/workspace/.mememeow-agent-stub-{suffix}"
stub = """#!/bin/sh
set -eu
task="${MEMEMEOW_AGENT_TASK_ID:?}"
directory="/runtime/task-results/$task"
mkdir -p "$directory"
printf '%s\\n' '{"title":"集成验收","summary":"共享容器结果","subjects":["主体"],"visible_text":[],"references":[],"meaning":null,"keywords":["集成"],"search_queries":[],"uncertainties":[],"source_urls":[]}' > "$directory/result.json.draft"
mv "$directory/result.json.draft" "$directory/result.json.tmp"
printf '{"type":"session.created","session_id":"stub-%s"}\\n' "$task"
"""
escaped_stub = shlex.quote(stub)
setup = _run_container(runner, "sh", "-lc", f"printf %s {escaped_stub} > {shlex.quote(stub_path)} && chmod 755 {shlex.quote(stub_path)}")
assert setup.returncode == 0, setup.stderr
original_executable = settings.opencode_executable
settings.opencode_executable = stub_path
try:
first, first_session = runner.run(image, lambda *_: None, task_id=task_ids[0])
second, second_session = runner.run(image, lambda *_: None, task_id=task_ids[1])
assert first["title"] == second["title"] == "集成验收"
assert first_session != second_session
assert (runner.runtime_root / "task-results" / task_ids[0] / "result.json.tmp").is_file()
assert (runner.runtime_root / "task-results" / task_ids[1] / "result.json.tmp").is_file()
boundary = _run_container(
runner,
"sh",
"-lc",
"test \"$(id -u)\" != 0 && test -r /images/example_meme_1.jpg && test ! -w /images && "
"test -r /skills/research-meme-context && test ! -w /skills/research-meme-context && "
"test -r /opt/mememeow/node_modules && test ! -w /opt/mememeow/node_modules && "
"test -r /runtime && test -w /runtime && test ! -e /.env && test ! -S /var/run/docker.sock",
)
assert boundary.returncode == 0, boundary.stderr
finally:
settings.opencode_executable = original_executable
_run_container(runner, "sh", "-lc", f"rm -f {shlex.quote(stub_path)}")
for task_id in task_ids:
shutil.rmtree(runner.runtime_root / "task-results" / task_id, ignore_errors=True)
@pytest.mark.parametrize(
("kind", "expected"),
[("missing", "agent_result_file_missing"), ("invalid", "agent_result_file_invalid_json"), ("schema", "agent_result_file_schema_invalid"), ("large", "agent_result_file_too_large")],
)
def test_result_file_failures_have_stable_codes(tmp_path: Path, kind: str, expected: str):
"""结果文件缺失、JSON 截断、schema 错误和超限均不回退 assistant 文本。"""
runner = OpenCodeRunner(make_settings(tmp_path, agent_result_max_bytes=1024))
_draft, result_path = runner.create_task_result_paths("task-1")
if kind == "invalid":
result_path.write_text('{"title":', encoding="utf-8")
elif kind == "schema":
result_path.write_text(json.dumps({"title": "only-title"}), encoding="utf-8")
elif kind == "large":
result_path.write_bytes(b"x" * 1025)
with pytest.raises(OpenCodeError) as error:
runner.read_result_file(result_path)
assert error.value.code == expected
def test_result_file_success_is_schema_validated_and_task_directories_are_independent(tmp_path: Path):
"""两个任务使用不同结果目录,成功结果只来自最终临时文件。"""
runner = OpenCodeRunner(make_settings(tmp_path))
first_draft, first_result = runner.create_task_result_paths("task-a")
second_draft, second_result = runner.create_task_result_paths("task-b")
assert first_result.parent != second_result.parent
first_draft.write_text(json.dumps(candidate(), ensure_ascii=False), encoding="utf-8")
first_draft.replace(first_result)
second_result.write_text(json.dumps(candidate(), ensure_ascii=False), encoding="utf-8")
assert runner.read_result_file(first_result)["title"] == "测试标题"
assert runner.read_result_file(second_result)["title"] == "测试标题"
def test_retry_clears_previous_result_artifact(tmp_path: Path):
"""同一任务重试前必须移除旧最终文件,防止失败尝试误读历史结果。"""
runner = OpenCodeRunner(make_settings(tmp_path))
draft, result = runner.create_task_result_paths("retry-task")
result.write_text(json.dumps(candidate(), ensure_ascii=False), encoding="utf-8")
runner._reset_task_result_files(draft, result)
assert not draft.exists()
assert not result.exists()
@pytest.mark.parametrize("kind", ["result", "draft", "directory"])
def test_result_paths_and_reads_reject_symlink_hijacking(tmp_path: Path, kind: str):
"""结果最终文件、草稿和任务目录均不能通过符号链接逃逸。"""
runner = OpenCodeRunner(make_settings(tmp_path))
root = runner.runtime_root / "task-results"
root.mkdir(parents=True)
if kind == "directory":
outside = tmp_path / "outside"
outside.mkdir()
(root / "task-link").symlink_to(outside, target_is_directory=True)
with pytest.raises(OpenCodeError) as error:
runner.create_task_result_paths("task-link")
else:
draft, result = runner.create_task_result_paths("task-link")
target = draft if kind == "draft" else result
target.unlink(missing_ok=True)
outside = tmp_path / f"outside-{kind}.json"
outside.write_text(json.dumps(candidate()), encoding="utf-8")
target.symlink_to(outside)
with pytest.raises(OpenCodeError) as error:
if kind == "draft":
runner._reset_task_result_files(draft, result)
else:
runner.read_result_file(result)
assert error.value.code in {"agent_result_path_invalid", "agent_result_file_unreadable"}
def test_result_parent_symlink_is_rejected(tmp_path: Path):
"""task-results 本身被替换为符号链接时不能创建任务目录。"""
runner = OpenCodeRunner(make_settings(tmp_path))
runner.runtime_root.mkdir(parents=True)
outside = tmp_path / "outside-results"
outside.mkdir()
(runner.runtime_root / "task-results").symlink_to(outside, target_is_directory=True)
with pytest.raises(OpenCodeError) as error:
runner.create_task_result_paths("task-1")
assert error.value.code == "agent_result_path_invalid"
def test_cleanup_preserves_active_task_directories(tmp_path: Path):
"""清理旧产物时必须跳过其他仍在运行 task 的结果目录。"""
runner = OpenCodeRunner(make_settings(tmp_path, agent_result_retention_days=1, agent_result_max_tasks=1))
active = runner.runtime_root / "task-results" / "active-task"
old = runner.runtime_root / "task-results" / "old-task"
active.mkdir(parents=True)
old.mkdir(parents=True)
old_mtime = time.time() - 3 * 86400
os.utime(active, (old_mtime, old_mtime))
os.utime(old, (old_mtime, old_mtime))
runner._mark_task_active("active-task")
try:
assert runner.cleanup_task_results() == 1
assert active.is_dir()
assert not old.exists()
finally:
runner._unmark_task_active("active-task")
def test_terminate_container_session_waits_and_escalates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""容器任务终止应发送 TERM、等待确认,再发送 KILL 兜底。"""
runner = OpenCodeRunner(make_settings(tmp_path))
calls: list[str] = []
alive = {"value": True}
ignore_term = {"value": False}
def signal_processes(pattern: str, signal_name: str) -> None:
calls.append(signal_name)
if signal_name == "TERM" and not ignore_term["value"]:
alive["value"] = False
monkeypatch.setattr(runner, "_container_signal_processes", signal_processes)
monkeypatch.setattr(runner, "_container_has_task_processes", lambda pattern: alive["value"])
runner._terminate_container_session("task-1")
assert calls == ["TERM"]
calls.clear()
alive["value"] = True
ignore_term["value"] = True
runner._terminate_container_session("task-2")
assert calls[:2] == ["TERM", "KILL"]