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

401 lines
18 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 contextvars
import os
import time
from pathlib import Path
import psutil
import pytest
import pytest_asyncio
from app.adapters.system.code.staging import STAGING_RETENTION_SECONDS, sweep_stale
from app.agent.code import manager as code_manager
from app.agent.code.authority import CellAuthority, allowed_tools
from app.agent.code.manager import CodeSessionManager
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
from app.agent.terminal.ownership import TerminalScope, close_terminal_scope
from app.agent.tools import base
from app.agent.tools.impl.api import MoviePilotApiTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.foundation.identity import build_user_memory_key
from app.runtime.tasks import get_task_registry
@pytest_asyncio.fixture
async def manager(tmp_path):
"""
每项用例使用独立管理器,真实内核与后台回收任务都在结束时收口。
"""
instance = CodeSessionManager(tmp_path / 'code', max_kernels=2)
try:
yield instance
finally:
for scope in {entry.scope for entry in instance._entries.values()}:
await instance.close_owner(scope)
if instance._reaper is not None:
instance._reaper.cancel()
await asyncio.gather(instance._reaper, return_exceptions=True)
def _context(scope, *, admin=True):
"""
提供宿主实际持有的可变角色引用,不从脚本参数生成身份。
"""
return ToolPolicyContext(session_id=scope.task_id, user_id=scope.user_id, origin=ToolOrigin.AGENT_INTERACTIVE,
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.WEB_SESSION,
agent_context={'is_admin': admin})
def _authority(scope, dispatch, context=None):
"""
使用真实 API 工具类,只有实际业务执行被替换为无出站回执。
"""
api = MoviePilotApiTool(session_id=scope.task_id, user_id=scope.user_id)
object.__setattr__(api, '_agent_tool_source', 'builtin')
return CellAuthority(scope, context or _context(scope), allowed_tools([api]), dispatch)
async def _dispatch(_tool, arguments):
"""
回放只读数据,供真实 Python 子进程循环调用。
"""
return {'success': True, 'data': arguments.get('query', {})}
@pytest.mark.asyncio
async def test_persistence_reset_and_object_identity_isolation(manager, tmp_path):
"""
同 owner 跨轮保持变量;reset 清空;同名会话的新 owner 对象不能继承。
"""
scope = TerminalScope('alice', 'conversation', 'interactive')
first = await manager.execute(_authority(scope, _dispatch), 'value = 42', cwd=tmp_path)
second = await manager.execute(_authority(scope, _dispatch), 'print(value)', cwd=tmp_path)
assert first['kernel']['reused'] is False and second['kernel']['reused'] is True
assert second['output'] == '42\n' and second['kernel']['execution_count'] == 2
reset = await manager.execute(_authority(scope, _dispatch), 'print("value" in globals())', cwd=tmp_path, reset=True)
assert reset['output'] == 'False\n' and reset['kernel']['state_reset']
fresh = TerminalScope('alice', 'conversation', 'interactive')
isolated = await manager.execute(_authority(fresh, _dispatch), 'print("value" in globals())', cwd=tmp_path)
assert isolated['output'] == 'False\n' and isolated['kernel']['execution_count'] == 1
@pytest.mark.asyncio
async def test_rpc_rebinds_context_every_cell_and_does_not_freeze_first_role(manager, tmp_path):
"""
持久 RPC 连接每个 cell 进入当前上下文,角色撤销立即阻止后续工具调用。
"""
scope = TerminalScope('alice', 'live-role', 'interactive')
context = _context(scope)
marker = contextvars.ContextVar('test_code_marker', default='missing')
async def dispatch(_tool, _arguments):
"""
读取当前调用上下文,并在宿主模拟角色撤销。
"""
if marker.get() == 'second':
context.agent_context['is_admin'] = False
return {'marker': marker.get()}
source = 'from moviepilot_tools import moviepilot_api\nprint(moviepilot_api(operation_id="subscription.list"))'
token = marker.set('first')
try:
first = await manager.execute(_authority(scope, dispatch, context), source, cwd=tmp_path)
marker.set('second')
second = await manager.execute(_authority(scope, dispatch, context), source + '\n' + source, cwd=tmp_path)
finally:
marker.reset(token)
assert "'marker': 'first'" in first['output']
assert "'marker': 'second'" in second['output']
assert second['tool_calls_made'] == 1 and second['success'] is False
assert '权限已变化' in second['tool_errors'][0]['error']
@pytest.mark.asyncio
async def test_budget_and_denied_calls_cannot_be_hidden_by_exit_zero(manager, tmp_path):
"""
第51次调用不派发,写操作和敏感读取都返回外层失败,即使程序本身成功退出。
"""
scope = TerminalScope('alice', 'budget', 'interactive')
dispatched = []
async def dispatch(tool, arguments):
"""
记录实际执行次数,而不是仅检查计数字段。
"""
dispatched.append(arguments)
return await _dispatch(tool, arguments)
result = await manager.execute(_authority(scope, dispatch),
'from moviepilot_tools import moviepilot_api\n'
'for _ in range(51):\n moviepilot_api(operation_id="subscription.list")', cwd=tmp_path)
assert len(dispatched) == result['tool_calls_made'] == 50
assert result['exit_code'] == 0 and result['success'] is False
assert '50' in result['tool_errors'][0]['error']
denied = await manager.execute(_authority(scope, dispatch),
'moviepilot_api(operation_id="subscription.delete", path_params={"subscribe_id": 1})\n'
'moviepilot_api(operation_id="config.system.get", query={"show_secrets": True})', cwd=tmp_path)
assert len(dispatched) == 50 and len(denied['tool_errors']) == 2
assert denied['exit_code'] == 0 and denied['success'] is False
@pytest.mark.asyncio
async def test_timeout_discards_state_and_next_call_starts_fresh(manager, tmp_path):
"""
超时明确状态丢失,随后执行不能误用半次 cell 留下的变量。
"""
scope = TerminalScope('alice', 'timeout', 'interactive')
timed_out = await manager.execute(_authority(scope, _dispatch), 'value = 10\nimport time\ntime.sleep(60)',
cwd=tmp_path, timeout=0.05)
assert timed_out['status'] == 'timeout' and timed_out['kernel']['state_lost']
assert not manager._entries
next_result = await manager.execute(_authority(scope, _dispatch), 'print("value" in globals())', cwd=tmp_path)
assert next_result['output'] == 'False\n' and not next_result['kernel']['reused']
@pytest.mark.asyncio
async def test_host_scope_close_cancels_active_python(manager, tmp_path, monkeypatch):
"""
真实宿主作用域关闭同时收口正在运行的 Python 和全部派发任务。
"""
monkeypatch.setattr(code_manager, 'code_session_manager', manager)
scope = TerminalScope('alice', 'cancel', 'interactive')
entered = asyncio.Event()
async def dispatch(tool, arguments):
"""
等待子进程已到达 RPC 边界,再触发宿主作用域关闭。
"""
entered.set()
return await _dispatch(tool, arguments)
task = get_task_registry().create(manager.execute(_authority(scope, dispatch),
'from moviepilot_tools import moviepilot_api\nmoviepilot_api(operation_id="subscription.list")\n'
'import time\ntime.sleep(60)', cwd=tmp_path), owner='test.code.scope')
await asyncio.wait_for(entered.wait(), 5)
assert await close_terminal_scope(scope)
with pytest.raises(asyncio.CancelledError):
await task
assert not manager._entries and scope.closed
@pytest.mark.asyncio
async def test_idle_lru_and_autonomous_reaper(manager, tmp_path, monkeypatch):
"""
容量淘汰最旧空闲会话,停止新调用后后台仍会回收过期内核。
"""
scopes = [TerminalScope('alice', str(index), 'interactive') for index in range(3)]
for scope in scopes:
await manager.execute(_authority(scope, _dispatch), 'pass', cwd=tmp_path)
assert {entry.scope for entry in manager._entries.values()} == set(scopes[1:])
manager.idle_seconds = 0.01
monkeypatch.setattr(code_manager, 'REAPER_MIN_SECONDS', 0.01)
manager._reaper.cancel()
await asyncio.gather(manager._reaper, return_exceptions=True)
await manager.execute(_authority(scopes[0], _dispatch), 'pass', cwd=tmp_path)
await asyncio.wait_for(asyncio.shield(manager._reaper), 2)
assert not manager._entries
@pytest.mark.asyncio
async def test_output_spill_survives_kernel_exit_and_is_sanitized(manager, tmp_path):
"""
超过内联上限的正文保存在独立目录,退出内核后仍可续读且不泄漏常见凭据。
"""
scope = TerminalScope('alice', 'spill', 'interactive')
result = await manager.execute(_authority(scope, _dispatch),
'print("A" * 1100000)\nprint("api_key=sk-secret0123456789")\nraise SystemExit(0)', cwd=tmp_path)
assert result['kernel']['ended'] and not manager._entries
path = Path(result['stdout_spill_path'])
body = path.read_text()
assert len(body) > 1000000 and 'sk-secret0123456789' not in body
assert path.parent.name == 'output' and 'sk-secret0123456789' not in result['output']
assert result['stdout_capture_incomplete'] is False
@pytest.mark.asyncio
@pytest.mark.parametrize('ending', ['raise SystemExit(0)', 'import time; time.sleep(60)'])
async def test_exit_and_timeout_reap_descendants(manager, tmp_path, ending):
"""
根进程主动退出与超时都收口继承管道的子进程,不能因根已退出而跳过进程组。
"""
if os.name == 'nt':
pytest.skip('真实 POSIX 进程组回收')
scope = TerminalScope('alice', 'tree', 'interactive')
pid_file = tmp_path / 'child.pid'
code = ('import subprocess, sys\nfrom pathlib import Path\n'
'child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])\n'
f'Path({str(pid_file)!r}).write_text(str(child.pid))\n{ending}')
result = await manager.execute(_authority(scope, _dispatch), code, cwd=tmp_path, timeout=0.5)
assert result['kernel']['ended'] and not manager._entries
child_pid = int(pid_file.read_text())
assert not psutil.pid_exists(child_pid) or psutil.Process(child_pid).status() == psutil.STATUS_ZOMBIE
def test_stale_staging_cleanup_does_not_follow_links_or_remove_live_paths(tmp_path):
"""
七天清理只处理固定布局中的过期路径,不跟随链接或删除当前内核。
"""
parent = tmp_path / 'users' / 'alice'
parent.mkdir(parents=True)
stale, active = parent / 'kernel-stale', parent / 'kernel-active'
stale.mkdir()
active.mkdir()
outside = tmp_path / 'outside'
outside.mkdir()
link = parent / 'kernel-link'
link.symlink_to(outside, target_is_directory=True)
before = time.time() - STAGING_RETENTION_SECONDS - 1
for path in (stale, active, outside):
os.utime(path, (before, before))
sweep_stale(tmp_path, {active})
assert not stale.exists() and active.exists() and outside.exists() and link.is_symlink()
@pytest.mark.asyncio
async def test_other_user_output_cannot_be_read_as_ordinary_agent(manager, tmp_path, monkeypatch):
"""
普通用户不能借通用文件工具读取其他用户的 Python 输出缓存。
"""
monkeypatch.setattr(base, 'get_runtime_setting', lambda _key: tmp_path)
alice = ReadFileTool(session_id='read', user_id='alice')
alice.set_agent_context({'is_admin': False})
other = tmp_path / 'agent' / 'runtime' / 'code' / 'users' / build_user_memory_key('bob') / 'output' / 'stdout-test.txt'
_, error = await alice._check_local_file_access(str(other), operation='读取')
assert error and '其他用户' in error
@pytest.mark.asyncio
async def test_unavailable_rpc_is_visible_even_when_script_ignores_it(manager, tmp_path):
"""
底层 allowlist 拒绝也进入当前 cell 的失败摘要,脚本忽略返回值不能掩盖。
"""
scope = TerminalScope('alice', 'unknown', 'interactive')
result = await manager.execute(_authority(scope, _dispatch),
'from moviepilot_tools import _call\n_call("write_file", {})', cwd=tmp_path)
assert result['exit_code'] == 0 and not result['success']
assert result['tool_calls_made'] == 0 and result['tool_errors'][0]['tool'] == 'write_file'
@pytest.mark.asyncio
async def test_cell_end_cancels_lingering_rpc_before_reusing_kernel(manager, tmp_path):
"""
Python 后台线程不能延长已结束 cell 的调用窗口,遗留调用被取消并公开未完成。
"""
scope = TerminalScope('alice', 'lingering', 'interactive')
ready = tmp_path / 'ready'
canceled = asyncio.Event()
async def dispatch(_tool, _arguments):
"""
通知脚本真实 RPC 已经进入宿主,并模拟尚未结束的只读查询。
"""
ready.touch()
try:
await asyncio.Event().wait()
finally:
canceled.set()
result = await manager.execute(_authority(scope, dispatch),
'import threading, time\nfrom pathlib import Path\nfrom moviepilot_tools import moviepilot_api\n'
'threading.Thread(target=lambda: moviepilot_api(operation_id="subscription.list"), daemon=True).start()\n'
f'while not Path({str(ready)!r}).exists():\n time.sleep(0.005)\nprint("cell ended")', cwd=tmp_path, timeout=5)
assert canceled.is_set() and not result['success']
assert '未完成' in result['tool_errors'][0]['error']
next_result = await manager.execute(_authority(scope, _dispatch), 'print("fresh cell")', cwd=tmp_path)
assert next_result['success'] and next_result['kernel']['reused']
@pytest.mark.asyncio
async def test_queued_cell_after_exit_keeps_new_process_registered(manager, tmp_path):
"""
前一个 cell 退出时已有排队引用,后一个重建的进程仍必须被 owner 和回收器持有。
"""
scope = TerminalScope('alice', 'queued', 'interactive')
entered, release = asyncio.Event(), asyncio.Event()
async def dispatch(_tool, _arguments):
"""
让第二次调用有机会完成准入并排队。
"""
entered.set()
await release.wait()
return {'success': True}
first = get_task_registry().create(manager.execute(_authority(scope, dispatch),
'from moviepilot_tools import moviepilot_api\nmoviepilot_api("subscription.list")\nraise SystemExit(0)',
cwd=tmp_path), owner='test.code.first')
await asyncio.wait_for(entered.wait(), 5)
second = get_task_registry().create(manager.execute(_authority(scope, _dispatch), 'print("new kernel")',
cwd=tmp_path), owner='test.code.second')
try:
async with asyncio.timeout(5):
while len(next(iter(manager._entries.values())).attached) != 2:
await asyncio.sleep(0.001)
release.set()
results = await asyncio.gather(first, second)
assert results[0]['kernel']['ended']
assert results[1]['output'] == 'new kernel\n' and results[1]['kernel']['state_reset']
assert len(manager._entries) == 1
process = next(iter(manager._entries.values())).kernel.process
assert process.returncode is None
assert await manager.close_owner(scope)
assert process.returncode is not None
finally:
release.set()
for task in (first, second):
if not task.done():
task.cancel()
await asyncio.gather(first, second, return_exceptions=True)
@pytest.mark.asyncio
async def test_long_lived_tasks_do_not_retain_first_request_context(manager, tmp_path):
"""
持久 socket、管道读取和进程回收任务不能保存首轮历史上下文。
"""
scope = TerminalScope('alice', 'context', 'interactive')
history = contextvars.ContextVar('test_large_history')
token = history.set(object())
try:
result = await manager.execute(_authority(scope, _dispatch), 'print("ready")', cwd=tmp_path)
assert result['success']
records = [record for record in get_task_registry().records if record.owner.startswith('agent.code.')]
assert records and all(record.task.get_context().get(history) is None for record in records)
finally:
history.reset(token)
@pytest.mark.asyncio
async def test_cancel_during_admission_releases_queued_reference(manager, tmp_path, monkeypatch):
"""
准入期间等待旧内核回收时被取消,也不能留下永久占用的排队引用。
"""
scope = TerminalScope('alice', 'admission', 'interactive')
entered = asyncio.Event()
original = manager._sweep_locked
async def sweep():
"""
只在新 entry 已登记后的容量清理边界暂停。
"""
if manager._entries:
entered.set()
await asyncio.Event().wait()
await original()
monkeypatch.setattr(manager, '_sweep_locked', sweep)
task = get_task_registry().create(manager.execute(_authority(scope, _dispatch), 'print(1)', cwd=tmp_path),
owner='test.code.admission')
await asyncio.wait_for(entered.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert manager._entries and manager._reaper is not None
assert all(not entry.attached and entry.kernel.process is None for entry in manager._entries.values())
assert await manager.close_owner(scope)
assert not manager._entries