Files
MoviePilot/tests/test_agent_subagent_terminal_scope.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

447 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
子任务终端授权必须按真实调用隔离,缓存图和已知句柄不产生隐式权限。
"""
import asyncio
import builtins
import json
import os
import shlex
import subprocess
import sys
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
import pytest
import pytest_asyncio
from langchain_core.language_models.fake_chat_models import FakeListChatModel, FakeMessagesListChatModel
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from pydantic import ValidationError
from app.agent.middleware import subagents as subagent_module
from app.agent.middleware.subagents import (
MoviePilotSubAgentMiddleware,
SubAgentTaskControlMiddleware,
_builtin_subagent_profiles,
_SubAgentAgentProvider,
)
from app.agent.middleware.terminal import SubAgentTerminalGrant, subagent_terminal_scope
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
from app.agent.terminal import manager as terminal_module
from app.agent.terminal.manager import _TerminalSessionManager
from app.agent.terminal.ownership import (
TerminalAccessError,
TerminalScope,
bind_terminal_scope,
current_terminal_scope,
require_terminal_scope,
)
from app.agent.tools.impl.execute_command import ExecuteCommandTool
class _TerminalReadModel(FakeMessagesListChatModel):
"""
只固定工具协议,终端动作经过真实子图 ToolNode、策略和管理器。
"""
terminal_handle: str
def bind_tools(self, _tools: Any, **_kwargs: Any) -> "_TerminalReadModel":
"""
使用提供器装配的真实工具列表,无供应商或网络调用。
"""
return self
def _generate(self, messages: list[BaseMessage], stop: Any = None, run_manager: Any = None, **_kwargs: Any) -> ChatResult:
"""
按本次图状态生成确定性工具调用,不用共享响应序号串扰并发子图。
"""
previous = next((item for item in reversed(messages) if isinstance(item, ToolMessage)), None)
message = AIMessage(content=str(previous.content)) if previous else AIMessage(
content="", tool_calls=[{"id": "read-terminal", "name": "execute_command", "args": {
"action": "read", "session_id": self.terminal_handle,
}}],
)
return ChatResult(generations=[ChatGeneration(message=message)])
def _policy() -> ToolPolicyContext:
"""
使用固定宿主用户,测试输入不能覆盖该身份。
"""
return ToolPolicyContext(
session_id="conversation", user_id="owner", origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT, auth_source=AuthSource.INTERNAL,
agent_context={"is_admin": True},
)
def _provider() -> _SubAgentAgentProvider:
"""
保留真实缓存提供器,以离线图边界观察调用上下文。
"""
return _SubAgentAgentProvider(
model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
tools=[], policy_context=_policy(),
)
def _command() -> str:
"""
测试进程用 stdout 发布证据,并等待父任务输入后才退出。
"""
code = "import sys\nprint('PARENT_ONLY',flush=True)\ndata=sys.stdin.buffer.read()\nprint('BYTES:'+str(len(data)),flush=True)"
args = [sys.executable, "-u", "-c", code]
return subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args)
@pytest_asyncio.fixture
async def terminal_parent(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[tuple[_TerminalSessionManager, TerminalScope, str]]:
"""
测试独占管理器和父进程,失败时也回收自身创建的子进程。
"""
manager = _TerminalSessionManager()
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
parent = TerminalScope(user_id="owner", task_id="parent-task", kind="interactive")
options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {}
try:
with bind_terminal_scope(parent):
payload = await asyncio.wait_for(manager.start(
command=_command(), use_pty=False, yield_time_ms=10000, **options,
), 5)
assert "PARENT_ONLY" in payload["output"]
yield manager, parent, payload["session_id"]
finally:
await asyncio.wait_for(manager.close(), 10)
@pytest.mark.asyncio
async def test_cached_profile_keeps_parallel_child_grants_separate(
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
同一缓存图并发执行,只有显式获授权的真实子任务能读父终端。
"""
manager, parent, session_id = terminal_parent
arrived = asyncio.Event()
observations: dict[str, tuple[TerminalScope, str]] = {}
calls = 0
async def invoke(state: dict[str, Any], **_kwargs: Any) -> dict[str, list[AIMessage]]:
"""
在两个子调用重叠期间读取真实管理器,不用假权限替代隔离检查。
"""
scope = require_terminal_scope()
assert scope is not parent and scope.user_id == parent.user_id
assert scope.task_id in {"shared-child", "sibling-child"}
if scope.task_id == "shared-child":
assert session_id in state["messages"][0].content
result = (await manager.read(session_id=session_id))["output"]
else:
with pytest.raises(TerminalAccessError):
await manager.read(session_id=session_id)
result = "denied"
observations[scope.task_id] = (scope, result)
if len(observations) == 2:
arrived.set()
await asyncio.wait_for(arrived.wait(), 5)
assert require_terminal_scope() is scope
return {"messages": [AIMessage(content=result)]}
graph = SimpleNamespace(ainvoke=invoke)
def create_graph(**_kwargs: Any) -> Any:
"""
记录缓存图创建次数,防止通过每次新建图掩盖共享工具问题。
"""
nonlocal calls
calls += 1
return graph
monkeypatch.setattr(subagent_module, "create_agent", create_graph)
provider = _provider()
with bind_terminal_scope(parent):
results = await asyncio.gather(
provider.run_task(description="读取父证据", subagent_type="general-purpose", task_id="shared-child",
terminal_sessions=[SubAgentTerminalGrant(session_id=session_id)]),
provider.run_task(description=f"尝试读取已知句柄 {session_id}", subagent_type="general-purpose", task_id="sibling-child"),
)
assert current_terminal_scope() is parent
assert (await manager.read(session_id=session_id))["status"] == "running"
assert calls == 1 and provider.get_agent("general-purpose")[1] is graph
assert "PARENT_ONLY" in results[0] and results[1] == "denied"
assert observations["shared-child"][0] is not observations["sibling-child"][0]
for scope, _result in observations.values():
assert scope.closed
with bind_terminal_scope(scope), pytest.raises(TerminalAccessError):
await manager.read(session_id=session_id)
@pytest.mark.asyncio
async def test_cancel_child_revokes_grants_and_preserves_parent_input(
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
取消获授权的子任务只关闭子作用域,父进程仍能收到唯一完整输入。
"""
manager, parent, session_id = terminal_parent
entered = asyncio.Event()
child_scopes: list[TerminalScope] = []
async def invoke(_state: Any, **_kwargs: Any) -> Any:
"""
真实读取授权生效后保持调用运行,让外部取消触发 finally 清理。
"""
child_scopes.append(require_terminal_scope())
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
entered.set()
await asyncio.Event().wait()
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
task = None
try:
with bind_terminal_scope(parent):
task = asyncio.create_task(_provider().run_task(
description="只读等待", subagent_type="general-purpose", task_id="cancel-child",
terminal_sessions=[SubAgentTerminalGrant(session_id=session_id)],
))
await asyncio.wait_for(entered.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert current_terminal_scope() is parent and not parent.closed
payload = await manager.write(session_id=session_id, input_text="parent", close_stdin=True)
output = payload["output"]
async with asyncio.timeout(5):
while not payload["output_complete"]:
payload = await manager.wait(session_id=session_id, timeout_ms=1000,
since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"])
output += payload["output"]
assert "BYTES:6" in output and payload["exit_code"] == 0
assert child_scopes[0].closed
with bind_terminal_scope(child_scopes[0]), pytest.raises(TerminalAccessError):
await manager.read(session_id=session_id)
finally:
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("parent_kind", ["missing", "unrelated"])
async def test_share_failure_never_invokes_child_model(
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch, parent_kind: str,
) -> None:
"""
无父上下文或不拥有该终端的父任务不能通过委派执行模型。
"""
_manager, _parent, session_id = terminal_parent
invoked = False
async def invoke(*_args: Any, **_kwargs: Any) -> Any:
"""
本边界不可触达,调用就表示授权失败仍执行了模型。
"""
nonlocal invoked
invoked = True
return {"messages": [AIMessage(content="unexpected")]}
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
provider = _provider()
arguments = {"description": "读取", "subagent_type": "general-purpose",
"terminal_sessions": [SubAgentTerminalGrant(session_id=session_id)]}
if parent_kind == "missing":
assert current_terminal_scope() is None
with pytest.raises(TerminalAccessError):
await provider.run_task(**arguments)
else:
unrelated = TerminalScope(user_id="owner", task_id="unrelated", kind="interactive")
with bind_terminal_scope(unrelated), pytest.raises(TerminalAccessError):
await provider.run_task(**arguments)
assert not invoked
@pytest.mark.asyncio
async def test_nonterminal_children_use_local_identity_without_loading_manager(monkeypatch: pytest.MonkeyPatch) -> None:
"""
无终端委派仍有独立子身份,但进入和退出都不物化终端单例。
"""
module_name = "app.agent.terminal.manager"
monkeypatch.delitem(sys.modules, module_name)
original_import = builtins.__import__
scopes: list[TerminalScope] = []
def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any:
"""
未启用终端的测试禁止隐式导入终端管理器。
"""
assert name != module_name
return original_import(name, *args, **kwargs)
async def invoke(_state: Any, **_kwargs: Any) -> dict[str, list[AIMessage]]:
"""
记录每次调用真实 scope,确保缓存图不缓存任务身份。
"""
scopes.append(require_terminal_scope())
return {"messages": [AIMessage(content="只读分析完成")]}
monkeypatch.setattr(builtins, "__import__", guarded_import)
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
provider = _provider()
for task_id in ("analysis-one", "analysis-two"):
await provider.run_task(description="分析", subagent_type="general-purpose", task_id=task_id)
assert current_terminal_scope() is None
assert [scope.task_id for scope in scopes] == ["analysis-one", "analysis-two"]
assert all(scope.user_id == "owner" and scope.closed for scope in scopes)
assert scopes[0] is not scopes[1] and module_name not in sys.modules
@pytest.mark.asyncio
async def test_nested_scope_restores_parent_and_rejects_redelegating_grant(
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str],
) -> None:
"""
嵌套普通子调用结束还原上层,获授只读句柄的子任务不能再转授。
"""
manager, parent, session_id = terminal_parent
grants = [SubAgentTerminalGrant(session_id=session_id)]
with bind_terminal_scope(parent):
async with subagent_terminal_scope(task_id="child", user_id="owner", terminal_sessions=grants):
child = require_terminal_scope()
async with subagent_terminal_scope(task_id="nested-analysis", user_id="owner"):
assert require_terminal_scope() is not child
with pytest.raises(TerminalAccessError):
await manager.read(session_id=session_id)
assert current_terminal_scope() is child
with pytest.raises(TerminalAccessError):
async with subagent_terminal_scope(task_id="redelegate", user_id="owner", terminal_sessions=grants):
pytest.fail("子代理不能转授父任务终端")
assert current_terminal_scope() is child
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
assert current_terminal_scope() is parent
@pytest.mark.asyncio
@pytest.mark.parametrize("entry", ["task", "start", "run", "batch", "pipeline"])
async def test_registered_task_entries_forward_per_task_terminal_grants(
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch, entry: str,
) -> None:
"""
阻塞、异步、批量和管道的真实工具入口都按条目转发且分配独立 task_id。
"""
manager, parent, session_id = terminal_parent
scopes: list[TerminalScope] = []
output: list[str] = []
async def invoke(state: dict[str, Any], **_kwargs: Any) -> dict[str, list[AIMessage]]:
"""
直接读取真实管理器,授权随任务输入而非同缓存 profile 传播。
"""
scopes.append(require_terminal_scope())
description = state["messages"][0].content
if description.startswith("shared"):
text = (await manager.read(session_id=session_id))["output"]
else:
with pytest.raises(TerminalAccessError):
await manager.read(session_id=session_id)
text = "denied"
output.append(text)
return {"messages": [AIMessage(content=text)]}
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
middleware_type = MoviePilotSubAgentMiddleware if entry == "task" else SubAgentTaskControlMiddleware
middleware = middleware_type(model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
tools=[], policy_context=_policy())
shared = {"description": "shared", "subagent_type": "general-purpose",
"terminal_sessions": [{"session_id": session_id}]}
arguments: dict[str, Any] = dict(shared)
if entry in {"batch", "pipeline"}:
arguments = {"action": "run" if entry == "batch" else "pipeline", "timeout_ms": 1000,
"tasks": [shared, {"description": "sibling"}]}
elif entry != "task":
arguments.update(action=entry, timeout_ms=1000)
try:
with bind_terminal_scope(parent):
result = await middleware.tools[0].ainvoke(arguments)
if entry == "start":
response = json.loads(result)
ids = [task["task_id"] for task in response["tasks"]]
result = await middleware.tools[0].ainvoke({"action": "wait", "task_ids": ids, "timeout_ms": 1000})
if entry != "task":
response = json.loads(result)
assert response["success"]
assert {scope.task_id for scope in scopes} == {task["task_id"] for task in response["tasks"]}
assert current_terminal_scope() is parent and not parent.closed
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
assert "PARENT_ONLY" in output[0]
assert len(scopes) == (2 if entry in {"batch", "pipeline"} else 1)
assert len({id(scope) for scope in scopes}) == len(scopes)
assert all(scope.closed for scope in scopes)
if len(scopes) == 2:
assert output[1] == "denied"
finally:
if isinstance(middleware, SubAgentTaskControlMiddleware):
assert await middleware.close()
@pytest.mark.parametrize("actions", [["write"], ["interrupt"], ["kill"], ["read", "write"], []])
def test_terminal_grant_schema_rejects_process_control(actions: list[str]) -> None:
"""
只读委派不因声明终端共享而增加写入、终止或其他控制能力。
"""
with pytest.raises(ValidationError):
SubAgentTerminalGrant.model_validate({"session_id": "parent-terminal", "actions": actions})
@pytest.mark.asyncio
async def test_batch_rejects_ambiguous_top_level_grants(monkeypatch: pytest.MonkeyPatch) -> None:
"""
批量顶层共享不能变成隐式广播,调用者须在每个任务明确声明。
"""
def create_graph(**_kwargs: Any) -> Any:
"""
输入被拒绝时不能构造或执行子图。
"""
pytest.fail("批量输入校验失败不应构造子图")
monkeypatch.setattr(subagent_module, "create_agent", create_graph)
middleware = SubAgentTaskControlMiddleware(
model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
tools=[], policy_context=_policy(),
)
response = json.loads(await middleware.tools[0].ainvoke({
"action": "run", "tasks": [{"description": "first"}, {"description": "second"}],
"terminal_sessions": [{"session_id": "parent-terminal"}],
}))
assert response["success"] is False and "单独声明" in response["error"]
assert middleware._tasks == {}
assert await middleware.close()
@pytest.mark.asyncio
async def test_real_cached_graph_carries_each_child_scope_to_tool_node(terminal_parent) -> None:
"""
同一实际缓存图并发调用时,ToolNode 只给获授权的子任务返回父终端证据。
"""
manager, parent, handle = terminal_parent
provider = _SubAgentAgentProvider(
model=_TerminalReadModel(responses=[AIMessage(content="unused")], terminal_handle=handle),
profiles=_builtin_subagent_profiles(),
tools=[ExecuteCommandTool(session_id="conversation", user_id="owner")],
policy_context=_policy(),
)
_, graph = provider.get_agent("general-purpose")
with bind_terminal_scope(parent):
allowed, denied = await asyncio.gather(
provider.run_task(description="读取已分享的证据", subagent_type="general-purpose", task_id="allowed",
terminal_sessions=[SubAgentTerminalGrant(session_id=handle)]),
provider.run_task(description="尝试读取任务中给出的句柄", subagent_type="general-purpose", task_id="denied"),
)
assert provider.get_agent("general-purpose")[1] is graph
assert "PARENT_ONLY" in allowed
assert "terminal_access_denied" in denied and "PARENT_ONLY" not in denied
assert manager._sessions[handle].process.returncode is None
assert not manager._grants