Files
MemeMeow/tests/test_opencode_workspace.py
2026-09-24 11:20:49 +08:00

303 lines
15 KiB
Python

"""scope-aware OpenCode workspace、selector 和 capability 边界测试。"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.config import Settings
from backend.opencode import OpenCodeError, OpenCodeRunner, _workspace_opencode_config
from backend.opencode_workspace import (
DirectoryWorkspaceProvider,
TrustedWorkspaceContext,
WorkspaceCapabilityError,
WorkspaceCapabilitySigner,
WorkspaceResolutionError,
build_edit_permission_rules,
)
from backend.tasks import TaskRecord
from executor import server as executor_server
def _workspace_tree(root: Path, selector: str) -> None:
"""创建测试 provider 要求的固定只读视图。"""
base = root / selector
for name in ("workspace", "images", "metadata", "skills"):
(base / name).mkdir(parents=True)
(base / "images" / "sample.png").write_bytes(b"image")
(base / "metadata" / "sample.json").write_text("{}", encoding="utf-8")
(base / "skills" / "SKILL.md").write_text("skill", encoding="utf-8")
def test_external_selectors_share_db_but_not_workspace_paths(tmp_path: Path) -> None:
"""不同 selector 复用 runtime DB,同时隔离工作目录和输入视图。"""
runtime = tmp_path / "runtime"
root = tmp_path / "workspaces"
_workspace_tree(root, "scope-a")
_workspace_tree(root, "scope-b")
provider = DirectoryWorkspaceProvider(runtime, root, selector_for_scope=lambda scope: scope)
first = provider.resolve(TrustedWorkspaceContext("task-a", "attempt-a", "scope-a", image_relative_path="sample.png"))
second = provider.resolve(TrustedWorkspaceContext("task-b", "attempt-b", "scope-b", image_relative_path="sample.png"))
assert first.db_path == second.db_path == runtime / "opencode.db"
assert first.directory != second.directory
assert first.config_file != second.config_file
assert first.config_dir != second.config_dir
assert first.image_path("sample.png") != second.image_path("sample.png")
assert first.candidate_root == root / "scope-a" / "candidates" / "task-a"
assert not first.candidate_root.exists()
assert dict(first.permission_rules)["*"] == "deny"
assert dict(first.permission_rules)[f"{first.candidate_root.absolute()}"] == "allow"
assert dict(first.permission_rules)[f"{first.candidate_root.absolute()}/**"] == "allow"
assert dict(first.permission_rules)[f"{first.task_scratch_root.absolute()}"] == "allow"
assert dict(first.permission_rules)[f"{first.task_scratch_root.absolute()}/**"] == "allow"
assert dict(first.permission_rules)[f"{first.task_results_root.absolute()}"] == "allow"
assert dict(first.permission_rules)[f"{first.task_results_root.absolute()}/*"] == "allow"
edit_rules = dict(
build_edit_permission_rules(
task_scratch_root=first.task_scratch_root,
config_file=first.config_file,
config_dir=first.config_dir,
draft_path=first.draft_path,
result_path=first.result_path,
candidate_root=first.candidate_root,
)
)
assert edit_rules["*"] == "deny"
assert edit_rules[f"**/{first.config_file.as_posix().lstrip('/')}"] == "deny"
assert edit_rules[f"**/{first.draft_path.as_posix().lstrip('/')}"] == "allow"
assert edit_rules[f"**/{first.candidate_root.as_posix().lstrip('/')}/**"] == "deny"
assert not first.task_scratch_root.exists()
assert not first.task_results_root.exists()
repeat = provider.resolve(TrustedWorkspaceContext("task-a", "attempt-retry", "scope-a", selector="scope-a", image_relative_path="sample.png"))
assert repeat.directory == first.directory
assert repeat.config_file == first.config_file
assert repeat.db_path == first.db_path
def test_workspace_config_separates_external_access_from_edit_access(tmp_path: Path) -> None:
"""外部目录父级检查与普通编辑权限必须共同收窄到结果协议。"""
runtime = tmp_path / "runtime"
root = tmp_path / "workspaces"
_workspace_tree(root, "scope-a")
provider = DirectoryWorkspaceProvider(runtime, root, selector_for_scope=lambda scope: scope)
workspace = provider.resolve(TrustedWorkspaceContext("task", "attempt", "scope-a", image_relative_path="sample.png"))
config = _workspace_opencode_config(workspace)
external = config["permission"]["external_directory"]
edit = config["permission"]["edit"]
assert external[str(workspace.skill_root.absolute())] == "allow"
assert external[str(workspace.task_scratch_root.absolute())] == "allow"
assert external[f"{workspace.task_results_root.absolute()}/*"] == "allow"
assert edit["*"] == "deny"
assert edit[f"**/{workspace.draft_path.as_posix().lstrip('/')}"] == "allow"
assert edit[f"**/{workspace.config_file.as_posix().lstrip('/')}"] == "deny"
def test_selector_mapping_and_symlink_fail_closed(tmp_path: Path) -> None:
"""非法映射、未知目录和 workspace 符号链接不会创建可执行路径。"""
runtime = tmp_path / "runtime"
root = tmp_path / "workspaces"
_workspace_tree(root, "known")
provider = DirectoryWorkspaceProvider(runtime, root, selector_for_scope=lambda _scope: "../outside")
with pytest.raises(WorkspaceResolutionError) as error:
provider.resolve(TrustedWorkspaceContext("task", "attempt", "scope"))
assert error.value.code == "opencode_workspace_invalid"
link_root = tmp_path / "linked"
link_root.symlink_to(root, target_is_directory=True)
linked_provider = DirectoryWorkspaceProvider(runtime, link_root, selector_for_scope=lambda _scope: "known")
with pytest.raises(WorkspaceResolutionError):
linked_provider.resolve(TrustedWorkspaceContext("task", "attempt", "scope"))
reserved_provider = DirectoryWorkspaceProvider(root.parent, root, selector_for_scope=lambda _scope: "local")
with pytest.raises(WorkspaceResolutionError) as error:
reserved_provider.resolve(TrustedWorkspaceContext("task", "attempt", "scope"))
assert error.value.code == "opencode_workspace_invalid"
incomplete_root = root / "incomplete"
incomplete_root.mkdir()
(incomplete_root / "images").mkdir()
(incomplete_root / "metadata").mkdir()
(incomplete_root / "skills").mkdir()
incomplete_provider = DirectoryWorkspaceProvider(runtime, root, selector_for_scope=lambda _scope: "incomplete")
with pytest.raises(WorkspaceResolutionError) as error:
incomplete_provider.resolve(TrustedWorkspaceContext("task", "attempt", "scope"))
assert error.value.code == "opencode_workspace_invalid"
assert not (incomplete_root / "workspace").exists()
def test_workspace_capability_binds_attempt_selector_and_resume(tmp_path: Path) -> None:
"""capability 的签名、期限、selector 和 resume 绑定必须全部成立。"""
signer = WorkspaceCapabilitySigner("workspace-secret", clock=lambda: 100)
token = signer.issue(task_id="task", attempt_id="attempt", selector="scope-a", session_id="session", resume_of_attempt_id="old")
claims = signer.verify(token, task_id="task", attempt_id="attempt", selector="scope-a", session_id="session", resume_of_attempt_id="old")
assert claims["workspace_selector"] == "scope-a"
with pytest.raises(WorkspaceCapabilityError) as error:
signer.verify(token, task_id="task", attempt_id="attempt", selector="scope-b", session_id="session", resume_of_attempt_id="old")
assert error.value.code == "opencode_workspace_mismatch"
expired = WorkspaceCapabilitySigner("workspace-secret", clock=lambda: 500)
with pytest.raises(WorkspaceCapabilityError) as error:
expired.verify(token, task_id="task", attempt_id="attempt", selector="scope-a", session_id="session", resume_of_attempt_id="old")
assert error.value.code == "opencode_workspace_capability_expired"
with pytest.raises(WorkspaceCapabilityError) as error:
signer.verify(token, task_id="task", attempt_id="attempt", selector="scope-a", session_id="session", resume_of_attempt_id="old", now=400)
assert error.value.code == "opencode_workspace_capability_expired"
def test_workspace_capability_low_level_sign_cannot_extend_ttl() -> None:
"""低层 sign 入口也必须遵守 signer 的最大有效期。"""
signer = WorkspaceCapabilitySigner("workspace-secret", ttl_seconds=60, clock=lambda: 100)
claims = {
"v": 1,
"task_id": "task",
"attempt_id": "attempt",
"workspace_selector": "scope-a",
"audience": "mememeow-agent-executor",
"exp": 100 + 61,
}
with pytest.raises(WorkspaceCapabilityError) as error:
signer.sign(claims)
assert error.value.code == "opencode_workspace_capability_invalid"
@pytest.mark.parametrize("relative", ["nested/./sample.png", "nested//sample.png", "C:/sample.png", "C:sample.png", "sample.png\x00"])
def test_relative_image_paths_reject_normalized_or_control_segments(relative: str) -> None:
"""原始路径段在归一化前也必须拒绝,避免绕过相对路径契约。"""
with pytest.raises(WorkspaceResolutionError, match="相对路径无效"):
TrustedWorkspaceContext("task", "attempt", "scope", image_relative_path=relative)
with pytest.raises(ValueError, match="agent_image_path_forbidden"):
executor_server._relative_image_path(relative)
def test_corrupt_persisted_workspace_selector_disables_resume() -> None:
"""损坏的持久 selector 不能清洗成 None 后继续暴露可恢复 session。"""
record = TaskRecord.from_dict(
{
"task_id": "task",
"task_type": "meme_context_generation",
"status": "failed",
"resume_available": True,
"resume_reason": "session_resumable",
"session_id": "session-task",
"executor_attempt_id": "attempt-task",
"workspace_selector": "../outside",
}
)
snapshot = record.as_dict()
assert snapshot["resume_available"] is False
assert snapshot["resume_reason"] == "opencode_workspace_mismatch"
def test_executor_resolves_signed_selector_to_current_image_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""executor 只接受签名 selector,并将图片解析到对应 workspace 视图。"""
runtime = tmp_path / "runtime"
image_root = tmp_path / "images"
skill_root = tmp_path / "skill"
selector_root = tmp_path / "selector-root"
runtime.mkdir()
image_root.mkdir()
skill_root.mkdir()
(image_root / "sample.png").write_bytes(b"legacy")
_workspace_tree(selector_root, "scope-a")
fake = tmp_path / "opencode"
fake.write_text("#!/bin/sh\n", encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setattr(executor_server, "RUNTIME_ROOT", runtime)
monkeypatch.setattr(executor_server, "WORKSPACE", runtime / "workspace")
monkeypatch.setattr(executor_server, "RESULT_ROOT", runtime / "task-results")
monkeypatch.setattr(executor_server, "LOG_ROOT", runtime / "logs")
monkeypatch.setattr(executor_server, "IMAGE_ROOT", image_root)
monkeypatch.setattr(executor_server, "SKILL_ROOT", skill_root)
monkeypatch.setattr(executor_server, "WORKSPACE_ROOT", selector_root)
monkeypatch.setenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN", "executor-token")
monkeypatch.setenv("MEMEMEOW_OPENCODE_EXECUTABLE", str(fake))
monkeypatch.setenv("MEMEMEOW_WORKSPACE_CAPABILITY_KEY", "capability-key")
executor = executor_server.Executor()
try:
signer = WorkspaceCapabilitySigner("capability-key")
token = signer.issue(task_id="task", attempt_id="attempt", selector="scope-a")
values = executor._validate_request(
{
"task_id": "task",
"executor_attempt_id": "attempt",
"image_relative_path": "sample.png",
"workspace_selector": "scope-a",
"workspace_capability": token,
}
)
layout = values["workspace_layout"]
assert isinstance(layout, executor_server.WorkspaceLayout)
assert layout.images_root == selector_root / "scope-a" / "images"
assert layout.directory == selector_root / "scope-a" / "workspace"
assert layout.candidate_root == selector_root / "scope-a" / "candidates" / "task"
assert layout.config_file == layout.directory / "tasks" / "task" / "opencode.json"
assert layout.config_dir == layout.directory / "tasks" / "task" / ".opencode"
assert not (runtime / "task-results" / "task").exists()
assert not (layout.directory / "tasks" / "task").exists()
clock = [100]
short_signer = WorkspaceCapabilitySigner("capability-key", ttl_seconds=1, clock=lambda: clock[0])
short_token = short_signer.issue(task_id="queued", attempt_id="queued-attempt", selector="scope-a")
executor.capability_signer = short_signer
queued_task = executor_server.TaskState(
task_id="queued",
business_task_id="queued",
executor_attempt_id="queued-attempt",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
workspace_selector="scope-a",
workspace_capability=short_token,
)
clock[0] = 101
with pytest.raises(RuntimeError, match="opencode_workspace_capability_expired"):
executor._verify_task_workspace_capability(queued_task)
with pytest.raises(ValueError, match="opencode_workspace_mismatch"):
executor._validate_request(
{
"task_id": "task-2",
"executor_attempt_id": "attempt-2",
"image_relative_path": "sample.png",
"workspace_selector": "scope-a",
"workspace_capability": token,
}
)
assert not (runtime / "task-results" / "task-2").exists()
assert not (layout.directory / "tasks" / "task-2").exists()
finally:
executor.close()
def test_runner_rejects_external_workspace_without_capability_before_task_side_effects(tmp_path: Path) -> None:
"""executor 外部 workspace 缺少 capability 时不能创建 Task 临时或结果目录。"""
runtime = tmp_path / "runtime"
root = tmp_path / "workspaces"
_workspace_tree(root, "scope-a")
settings = Settings(
data_root=tmp_path / "data",
image_root=tmp_path / "images",
opencode_runtime_root=runtime,
opencode_model="gpt-5.6-luna",
opencode_base_url="https://example.invalid/v1",
opencode_api_key="api-key",
agent_runtime_mode="executor",
agent_executor_url="http://agent:8277",
agent_executor_token="executor-token",
)
runner = OpenCodeRunner(
settings,
workspace_provider=DirectoryWorkspaceProvider(runtime, root, selector_for_scope=lambda scope: scope),
)
context = TrustedWorkspaceContext("task", "attempt", "scope-a", image_relative_path="sample.png")
with pytest.raises(OpenCodeError) as error:
runner.run(tmp_path / "unused.png", lambda *_args: None, task_id="task", workspace_context=context)
assert error.value.code == "opencode_workspace_capability_unavailable"
assert not (runtime / "task-results" / "task").exists()
assert not (root / "scope-a" / "workspace" / "tasks" / "task").exists()
assert not (runtime / "workspace").exists()