diff --git a/app/agent/middleware/subagents.py b/app/agent/middleware/subagents.py index acefbcafd..c2b18fe28 100644 --- a/app/agent/middleware/subagents.py +++ b/app/agent/middleware/subagents.py @@ -4,7 +4,7 @@ import asyncio import json import uuid from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from functools import lru_cache from typing import Any, Literal, Optional @@ -29,6 +29,7 @@ from app.agent.middleware.summarization import ( ContextPreservingSummarizationMiddleware, FinalRequestCompactionMiddleware, ) +from app.agent.middleware.terminal import SubAgentTerminalGrant, subagent_terminal_scope from app.agent.middleware.utils import append_to_system_message from app.agent.middleware.vision import VisionMiddleware from app.agent.policy.contracts import ( @@ -81,6 +82,7 @@ Rules: - Give the user only your synthesized final answer and the minimum necessary next step. - If a task requires configuration changes, deletion, adding downloads, adding subscriptions, or any high-impact action, the main agent must handle it directly under the confirmation policy. - Child tools enforce read-only operations. Perform command launches, browser navigation/interactions, and external MCP calls in the main agent; pass the resulting evidence to a child for analysis when useful. +- To let a child inspect a parent terminal, declare `terminal_sessions=[{session_id, actions:["read","wait"]}]` on that task. Mentioning a handle in its description does not grant access. Share separately for each batch or pipeline task; process control remains with the parent. """ SUBAGENT_TASK_DESCRIPTION = ( @@ -160,6 +162,10 @@ class _TaskToolInput(BaseModel): default="general-purpose", description="Subagent type to invoke, such as general-purpose or media-researcher", ) + terminal_sessions: list[SubAgentTerminalGrant] = Field( + default_factory=list, + description="Explicit parent terminal sessions shared with this child for read-only inspection.", + ) class _SubAgentTaskSpec(BaseModel): @@ -170,6 +176,10 @@ class _SubAgentTaskSpec(BaseModel): default="general-purpose", description="Subagent type to invoke, such as general-purpose or media-researcher", ) + terminal_sessions: list[SubAgentTerminalGrant] = Field( + default_factory=list, + description="Explicit parent terminal grants for this task only; siblings inherit no grants.", + ) class _SubAgentControlInput(BaseModel): @@ -187,6 +197,10 @@ class _SubAgentControlInput(BaseModel): default="general-purpose", description="Single task subagent type for action=start or action=run.", ) + terminal_sessions: list[SubAgentTerminalGrant] = Field( + default_factory=list, + description="Terminal grants for a single description; with tasks, set grants separately in each task spec.", + ) tasks: Optional[list[_SubAgentTaskSpec]] = Field( default=None, description="Batch task specs for action=start or action=run.", @@ -223,6 +237,7 @@ class _SubAgentRuntimeTask: created_at: datetime started_at: Optional[datetime] = None finished_at: Optional[datetime] = None + terminal_sessions: list[SubAgentTerminalGrant] = field(default_factory=list) def is_subagent_stream_metadata(metadata: Any) -> bool: @@ -494,6 +509,7 @@ class _SubAgentAgentProvider: description: str, subagent_type: Optional[str], task_id: Optional[str] = None, + terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None, ) -> str: """调用指定子代理并只返回供主代理读取的结果。""" agent_name, agent = self.get_agent(subagent_type) @@ -503,19 +519,24 @@ class _SubAgentAgentProvider: f"开始调用子代理: subagent_type={agent_name}, task_id={log_task_id}" ) try: - result = await agent.ainvoke( - {"messages": [HumanMessage(content=description)]}, - config={ - "configurable": { - "thread_id": f"subagent-{agent_name}-{thread_suffix}", - SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE, + async with subagent_terminal_scope( + task_id=thread_suffix, + user_id=self._policy_context.user_id, + terminal_sessions=terminal_sessions, + ) as terminal_context: + result = await agent.ainvoke( + {"messages": [HumanMessage(content=description + terminal_context)]}, + config={ + "configurable": { + "thread_id": f"subagent-{agent_name}-{thread_suffix}", + SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE, + }, + "metadata": { + "lc_agent_name": agent_name, + SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE, + }, }, - "metadata": { - "lc_agent_name": agent_name, - SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE, - }, - }, - ) + ) except Exception as err: logger.error( f"子代理调用失败: subagent_type={agent_name}, " @@ -573,11 +594,17 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware): """懒加载指定名称的子代理图。""" return self._provider.get_agent(agent_name)[1] - async def _run_task(self, description: str, subagent_type: str) -> str: + async def _run_task( + self, + description: str, + subagent_type: str, + terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None, + ) -> str: """调用指定子代理并只返回供主代理读取的结果。""" return await self._provider.run_task( description=description, subagent_type=subagent_type, + terminal_sessions=terminal_sessions, ) async def awrap_model_call( @@ -768,8 +795,11 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): description: Optional[str], subagent_type: Optional[str], tasks: Optional[list[_SubAgentTaskSpec]], + terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None, ) -> tuple[list[_SubAgentTaskSpec], Optional[str]]: """规范化单任务和批量任务输入。""" + if tasks and terminal_sessions: + return [], "批量或管道任务请在每个 tasks 条目中单独声明 terminal_sessions。" specs = [] for task in tasks or []: if isinstance(task, dict): @@ -781,6 +811,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): _SubAgentTaskSpec( description=description, subagent_type=subagent_type or "general-purpose", + terminal_sessions=terminal_sessions or [], ) ) if not specs: @@ -811,6 +842,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): description=record.description, subagent_type=record.subagent_type, task_id=record.task_id, + terminal_sessions=record.terminal_sessions, ) logger.info( f"异步子代理任务执行完成: task_id={record.task_id}, " @@ -856,6 +888,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): subagent_type=spec.subagent_type or "general-purpose", task=None, created_at=datetime.now(), + terminal_sessions=spec.terminal_sessions, ) task = asyncio.create_task( self._execute_managed_task(record), @@ -1043,6 +1076,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): description=description, subagent_type=record.subagent_type, task_id=record.task_id, + terminal_sessions=record.terminal_sessions, ) logger.info( f"管道子代理任务执行完成: task_id={record.task_id}, " @@ -1074,6 +1108,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): subagent_type=spec.subagent_type or "general-purpose", task=None, created_at=datetime.now(), + terminal_sessions=spec.terminal_sessions, ) def _track_pipeline_task( @@ -1160,6 +1195,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): task_id: Optional[str] = None, wait_mode: str = "all", timeout_ms: Optional[int] = SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS, + terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None, ) -> str: """管理异步子代理任务。""" logger.info(f"收到子代理管控操作: action={action}") @@ -1171,6 +1207,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): description=description, subagent_type=subagent_type, tasks=tasks, + terminal_sessions=terminal_sessions, ) if error: logger.info( diff --git a/app/agent/middleware/terminal.py b/app/agent/middleware/terminal.py new file mode 100644 index 000000000..63fd499d1 --- /dev/null +++ b/app/agent/middleware/terminal.py @@ -0,0 +1,84 @@ +"""子代理终端只读共享输入与单次调用作用域。""" + +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.agent.terminal.ownership import ( + TerminalScope, + bind_terminal_scope, + close_terminal_scope, + current_terminal_scope, + require_terminal_scope, +) + + +def _default_actions() -> list[Literal["read", "wait"]]: + """每条授权独立创建动作列表,默认仅可读取或等待父终端。""" + return ["read", "wait"] + + +class SubAgentTerminalGrant(BaseModel): # type: ignore[misc] + """父任务明确授予子代理的单个终端读取能力,不扩大只读策略。""" + + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(..., min_length=1, description="Terminal session owned by the parent task.") + actions: list[Literal["read", "wait"]] = Field( + default_factory=_default_actions, + min_length=1, + description="Explicit read-only actions to share; writes and process control cannot be delegated.", + ) + + +def _normalize_grants(terminal_sessions: list[SubAgentTerminalGrant]) -> dict[str, frozenset[str]]: + """合并同一父句柄的只读动作,直调入口也执行与工具 schema 相同的校验。""" + grants: dict[str, frozenset[str]] = {} + for value in terminal_sessions: + grant = SubAgentTerminalGrant.model_validate(value) + grants[grant.session_id] = grants.get(grant.session_id, frozenset()) | frozenset(grant.actions) + return grants + + +@asynccontextmanager +async def subagent_terminal_scope( + *, + task_id: str, + user_id: str, + terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None, +) -> AsyncIterator[str]: + """每次子图调用独立绑定能力;授权失败不调用模型,结束仅撤销子任务能力。""" + grants = _normalize_grants(terminal_sessions or []) + parent = current_terminal_scope() + if parent is not None or grants: + parent = require_terminal_scope() + child = TerminalScope( + user_id=parent.user_id if parent is not None else user_id, + task_id=task_id, + kind="subagent", + ) + try: + if grants: + # 无终端的独立子任务无需加载或创建进程级终端管理器。 + from app.agent.terminal.manager import get_terminal_session_manager + + get_terminal_session_manager().share(require_terminal_scope(), child, grants) + context = "" + if grants: + context = ( + "\n\n\n" + "The host explicitly granted these parent terminals for the listed read-only actions. " + "This does not authorize writes, EOF, interrupts, or termination.\n" + + json.dumps([ + {"session_id": session_id, "actions": sorted(actions)} + for session_id, actions in grants.items() + ], ensure_ascii=False) + + "\n" + ) + with bind_terminal_scope(child): + yield context + finally: + await close_terminal_scope(child) diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 9d7d4ac35..06db35eab 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -63,6 +63,12 @@ from app.agent.policy.registry import requests_system_setting_secrets from app.agent.policy.sanitizer import sanitize_for_host from app.agent.prompt import prompt_manager from app.agent.runtime import agent_runtime_manager +from app.agent.terminal.ownership import ( + TerminalAccessError, + TerminalScope, + bind_terminal_scope, + close_terminal_scope, +) from app.agent.tools.catalog import ToolCatalogSnapshot from app.agent.tools.impl.api import MoviePilotApiTool from app.agent.tools.impl.mcp import create_external_mcp_tools @@ -379,6 +385,10 @@ class MoviePilotAgent: """创建会话 Agent,并保存组合根注入的数据与记忆能力。""" self.session_id = session_id self.user_id = user_id + self._terminal_scope = TerminalScope( + user_id=user_id or "", task_id=session_id, kind="conversation" + ) + self._scheduled_terminal_scopes: set[TerminalScope] = set() self.channel = channel self.source = source self.username = username @@ -1628,7 +1638,10 @@ class MoviePilotAgent: return tuple(merged) def begin_shutdown(self) -> None: - """在任何异步等待前封住当前 Agent 的 detached 子代理提交。""" + """在任何异步等待前封住当前 Agent 的子代理提交和终端作用域。""" + self._terminal_scope.seal() + for scope in self._scheduled_terminal_scopes: + scope.seal() self._shutdown_started = True self._seal_subagent_middleware_instances(self._subagent_middlewares) @@ -1996,10 +2009,36 @@ class MoviePilotAgent: images: Optional[List[str]] = None, files: Optional[List[dict[str, Any]]] = None, has_audio_input: bool = False, + *, + terminal_scope: Optional[TerminalScope] = None, ) -> str: - """ - 处理用户消息,流式推理并返回 Agent 回复 - """ + """绑定宿主任务身份覆盖本轮全部推理;正常轮次和图重建保留对话归属。""" + scope = terminal_scope or self._terminal_scope + if scope.closed or self._shutdown_started or scope.user_id != (self.user_id or ""): + raise TerminalAccessError() + if scope is not self._terminal_scope: + self._scheduled_terminal_scopes.add(scope) + with bind_terminal_scope(scope): + return await self._process( + message, images=images, files=files, has_audio_input=has_audio_input + ) + + async def release_terminal_scope(self, scope: TerminalScope) -> bool: + """收口宿主临时任务的终端;未真实收敛的作用域留给 Agent 清理重试。""" + self._scheduled_terminal_scopes.add(scope) + if not await close_terminal_scope(scope): + return False + self._scheduled_terminal_scopes.discard(scope) + return True + + async def _process( + self, + message: str, + images: Optional[List[str]] = None, + files: Optional[List[dict[str, Any]]] = None, + has_audio_input: bool = False, + ) -> str: + """在已绑定的宿主任务上下文中流式推理并返回 Agent 回复。""" user_display_saved = False try: logger.info( @@ -2463,11 +2502,16 @@ class MoviePilotAgent: async def cleanup(self) -> bool: """ - 清理智能体资源;detached 子代理未收敛时保留 owner 并返回 False。 + 清理智能体资源;子代理或终端未真实收敛时保留 owner 并返回 False。 """ self.begin_shutdown() - if not await self._invalidate_cached_agent(): - logger.error(f"MoviePilot智能体仍有子代理 owner 未收敛: session_id={self.session_id}") + children_closed = await self._invalidate_cached_agent() + terminals_closed = await close_terminal_scope(self._terminal_scope) + for scope in tuple(self._scheduled_terminal_scopes): + if not await self.release_terminal_scope(scope): + terminals_closed = False + if not children_closed or not terminals_closed: + logger.error(f"MoviePilot智能体仍有子代理或终端未收敛: session_id={self.session_id}") return False self._pending_secret_confirmation = None self.protected_output_callback = None diff --git a/app/agent/session.py b/app/agent/session.py index 44ebcbe9f..33b766edb 100644 --- a/app/agent/session.py +++ b/app/agent/session.py @@ -1,13 +1,14 @@ """Agent 会话队列、worker 与资源状态的唯一 owner。""" import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Awaitable, Callable, Dict, List, Optional, cast from app.agent.contracts import ReplyMode from app.agent.memory import MemoryManager, memory_manager from app.agent.orchestrator import MoviePilotAgent, _SessionUsageSnapshot +from app.agent.terminal.ownership import TerminalScope, close_terminal_scope from app.application.agent import AgentDataContext from app.chain.agent import AgentChain from app.runtime.execution import run_in_threadpool @@ -112,6 +113,17 @@ class _MessageTask: agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None completion_future: Optional[asyncio.Future[str]] = None enqueued_at: Optional[float] = None + scheduled_run_id: Optional[str] = None + terminal_scope: Optional[TerminalScope] = field(default=None, init=False) + agent: Optional[MoviePilotAgent] = field(default=None, init=False, repr=False) + terminal_released: bool = field(default=False, init=False) + + def __post_init__(self) -> None: + """由队列宿主使用持久化运行 ID 装配身份,不向模型暴露可写归属字段。""" + if self.scheduled_run_id is not None: + self.terminal_scope = TerminalScope( + user_id=self.user_id, task_id=self.scheduled_run_id, kind="scheduled" + ) class AgentManagerUnavailableError(RuntimeError): @@ -126,6 +138,7 @@ class AgentManagerQueueFullError(RuntimeError): code = "agent_manager_queue_full" def __init__(self, session_id: str, limit: int) -> None: + """保存拒绝的会话及容量,供入站边界返回排队失败。""" self.session_id = session_id self.limit = limit super().__init__( @@ -283,6 +296,7 @@ class AgentSessionOwner: agent_factory: Optional[Callable[..., MoviePilotAgent]] = None, agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None, wait_for_completion: bool = False, + scheduled_run_id: Optional[str] = None, ) -> str: """ 处理用户消息:将消息放入会话队列,按顺序依次处理。 @@ -312,6 +326,7 @@ class AgentSessionOwner: agent_factory=agent_factory, agent_setup=agent_setup, completion_future=completion_future, + scheduled_run_id=scheduled_run_id, ) async with self._lifecycle_lock: if not self._accepting_tasks: @@ -376,9 +391,28 @@ class AgentSessionOwner: ) if completion_future: - return await completion_future + try: + return await completion_future + except asyncio.CancelledError: + # 取消等待不能留下仍可执行命令的定时任务;对话 worker 可能仍在收尾。 + await self._close_scheduled_task_scope(task) + raise return "" + @staticmethod + async def _close_scheduled_task_scope(task: _MessageTask) -> bool: + """取消等待与 worker 收尾共用任务归属,失败时保留已装配 Agent 的重试记录。""" + if task.terminal_scope is None or task.terminal_released: + return True + task.terminal_scope.seal() + if task.agent is not None: + released = await task.agent.release_terminal_scope(task.terminal_scope) + else: + released = await close_terminal_scope(task.terminal_scope) + if released: + task.terminal_released = True + return released + async def _session_worker(self, session_id: str) -> None: """ 会话消息处理worker:从队列中逐条取出消息并处理。 @@ -405,6 +439,10 @@ class AgentSessionOwner: task_type = _agent_task_metric_type(task.source, task.channel) active_metric_recorded = False try: + if task.terminal_scope is not None and task.terminal_scope.closed: + if task.completion_future and not task.completion_future.done(): + task.completion_future.cancel() + continue if task.enqueued_at is not None: queue_wait_ms = max( 0.0, @@ -426,6 +464,8 @@ class AgentSessionOwner: ) active_metric_recorded = True result = await self._process_message_internal(task) + if not await self._close_scheduled_task_scope(task): + raise AgentManagerUnavailableError("Agent 定时任务仍有终端在停止") if task.completion_future and not task.completion_future.done(): if ( not self._accepting_tasks @@ -454,8 +494,11 @@ class AgentSessionOwner: -1, task_type=task_type, ) - await self._finish_task_processing_status(task) - queue.task_done() + try: + await self._close_scheduled_task_scope(task) + finally: + await self._finish_task_processing_status(task) + queue.task_done() if session_id in self._session_cancel_requested: break @@ -485,6 +528,8 @@ class AgentSessionOwner: task = queue.get_nowait() except asyncio.QueueEmpty: break + if task.terminal_scope is not None: + task.terminal_scope.seal() if task.completion_future and not task.completion_future.done(): if error is None: task.completion_future.cancel() @@ -517,13 +562,18 @@ class AgentSessionOwner: existing_agent = self.active_agents.get(session_id) if ( existing_agent - and task.agent_factory - and isinstance(task.agent_factory, type) - and not isinstance(existing_agent, task.agent_factory) + and ( + existing_agent.user_id != task.user_id + or ( + task.agent_factory + and isinstance(task.agent_factory, type) + and not isinstance(existing_agent, task.agent_factory) + ) + ) ): if await existing_agent.cleanup() is False: raise AgentManagerUnavailableError( - f"Agent 会话 {session_id} 仍有子代理任务在停止" + f"Agent 会话 {session_id} 仍有子代理或终端在停止" ) self.active_agents.pop(session_id, None) @@ -569,7 +619,6 @@ class AgentSessionOwner: self.active_agents[session_id] = agent else: agent = self.active_agents[session_id] - agent.user_id = task.user_id # 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务 # 复用会话 Agent 时继续沿用上一条入站消息的渠道。 agent.channel = task.channel @@ -588,6 +637,7 @@ class AgentSessionOwner: if task.message_callback is not None and hasattr(agent, "set_message_callback"): agent.set_message_callback(task.message_callback) + task.agent = agent if task.agent_setup is not None: task.agent_setup(agent) @@ -597,13 +647,16 @@ class AgentSessionOwner: } if task.has_audio_input: process_kwargs["has_audio_input"] = True + if task.terminal_scope is not None: + process_kwargs["terminal_scope"] = task.terminal_scope return await agent.process(task.message, **process_kwargs) async def stop_current_task(self, session_id: str) -> bool: """ 应急停止当前正在执行的Agent推理任务,但保留会话和记忆。 与 clear_session 不同,此方法不会销毁Agent实例或清除记忆, - 用户可以在停止后继续对话。 + 用户可以在停止后继续对话;已交付的交互对话后台终端保持运行, + 后续对话仍可读取或显式终止。定时运行的独立终端随该运行收尾。 """ async with self._lifecycle_lock: return await self._stop_current_task_locked(session_id) diff --git a/app/agent/tasks.py b/app/agent/tasks.py index cdf35c7c7..1c8814cfd 100644 --- a/app/agent/tasks.py +++ b/app/agent/tasks.py @@ -110,6 +110,7 @@ class AgentTaskOwner(AgentLifecycleOwner): reply_mode=ReplyMode.DISPATCH, allow_message_tools=True, wait_for_completion=True, + scheduled_run_id=run.run_id, ) result_text = str(result or "").strip() success = not result_text.startswith( diff --git a/app/agent/terminal/manager.py b/app/agent/terminal/manager.py index fb22a1fd9..d40ad89f6 100644 --- a/app/agent/terminal/manager.py +++ b/app/agent/terminal/manager.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import errno -import json import os import signal import subprocess @@ -14,6 +13,14 @@ from types import ModuleType from typing import Any, Optional from app.agent.shell import AgentShell, build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell +from app.agent.terminal.output import ( + TERMINAL_DEFAULT_READ_BYTES, + TerminalOutputError, + read_payload, + resolve_cursor, + validate_output_budget, +) +from app.agent.terminal.ownership import TerminalAccessError, TerminalScope, require_terminal_scope from app.agent.terminal.session import TERMINAL_RETENTION_SECONDS, _TerminalSession from app.agent.tools.impl._command_safety import validate_command_safety from app.runtime.log import logger @@ -31,8 +38,6 @@ else: TERMINAL_CONCURRENCY_LIMIT = 4 -TERMINAL_DEFAULT_READ_BYTES = 10 * 1024 -TERMINAL_MAX_READ_BYTES = 64 * 1024 TERMINAL_READ_CHUNK_SIZE = 4096 TERMINAL_PTY_POLL_INTERVAL = 0.05 TERMINAL_WAIT_DEFAULT_MS = 1000 @@ -41,17 +46,7 @@ TERMINAL_YIELD_DEFAULT_MS = 250 TERMINAL_YIELD_MAX_MS = 10 * 1000 TERMINAL_KILL_GRACE_SECONDS = 3 _KILL_SIGNAL = getattr(signal, "SIGKILL", 9) - - -class TerminalOutputError(ValueError): - """携带稳定错误码和最小页预算的可恢复输出读取错误。""" - - def __init__(self, message: str, *, code: str = "invalid_output_cursor", minimum_read_bytes: Optional[int] = None) -> None: - """保留结构化恢复提示,调用方不必解析中文消息。""" - super().__init__(message) - self.code = code - self.minimum_read_bytes = minimum_read_bytes - +_SHARE_ACTIONS = frozenset({"read", "wait", "write", "interrupt", "kill"}) class _TerminalSessionManager: """管理 Agent 后台终端会话的生命周期。""" @@ -65,6 +60,9 @@ class _TerminalSessionManager: self._starting = 0 self._starts_idle = asyncio.Event() self._starts_idle.set() + self._owner_starts: dict[TerminalScope, int] = {} + self._owner_idle: dict[TerminalScope, asyncio.Event] = {} + self._grants: dict[TerminalScope, dict[str, frozenset[str]]] = {} @staticmethod def _normalize_bool(value: Any, default: bool = True) -> bool: @@ -120,8 +118,9 @@ class _TerminalSessionManager: login: Optional[bool] = None, ) -> dict[str, Any]: """启动后台命令,在首个输出、完成或首次等待预算到期时交付会话。""" + owner = require_terminal_scope() self._validate_command(command, confirmed=confirm_dangerous) - self._validate_output_budget(max_output_chars) + validate_output_budget(max_output_chars) if since_offset is not None and (type(since_offset) is not int or since_offset != 0): raise TerminalOutputError("新会话的 since_offset 只能为 0 或 null") initial_wait = self._normalize_yield_timeout(yield_time_ms) @@ -131,6 +130,8 @@ class _TerminalSessionManager: should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix" async with self._lock: + if owner.closed: + raise TerminalAccessError() if self._closed: raise RuntimeError("终端会话管理器已关闭") self._cleanup_finished_sessions_locked() @@ -143,33 +144,29 @@ class _TerminalSessionManager: ) self._starting += 1 self._starts_idle.clear() + self._owner_starts[owner] = self._owner_starts.get(owner, 0) + 1 + self._owner_idle.setdefault(owner, asyncio.Event()).clear() session: Optional[_TerminalSession] = None - reject_session = False slot_released = False - session_released = False - try: - session = ( - await self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy) - if should_use_pty - else await self._start_pipe_session( - command, normalized_cwd, normalized_env, shell_policy=shell_policy - ) + launch = asyncio.create_task( + self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy) + if should_use_pty else self._start_pipe_session( + command, normalized_cwd, normalized_env, shell_policy=shell_policy, ) - + ) + try: + session = await asyncio.shield(launch) + session.owner = owner async with self._lock: - reject_session = self._closed - if not reject_session: - self._sessions[session.session_id] = session - self._starting -= 1 - slot_released = True - if self._starting == 0: - self._starts_idle.set() - - if reject_session: - await self._terminate_session(session) - session_released = True - raise RuntimeError("终端会话管理器已关闭") + if self._closed: + raise RuntimeError("终端会话管理器已关闭") + if owner.closed: + raise TerminalAccessError() + self._sessions[session.session_id] = session + self._release_start_locked(owner) + slot_released = True + self._check_access(session, owner, "start") logger.info( f"启动后台终端会话: session_id={session.session_id}, pid={session.pid}, " f"use_pty={session.use_pty}, command={command}" @@ -177,32 +174,59 @@ class _TerminalSessionManager: payload = await self._wait_for_output( session, timeout_ms=initial_wait, since_seq=0, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars, - extra_fields={"yield_time_ms": initial_wait}, + extra_fields={"yield_time_ms": initial_wait}, scope=owner, action="start", ) - if self._closed: - raise RuntimeError("终端会话管理器已关闭") + self._check_access(session, owner, "start") return payload except BaseException: - if session is not None and not session_released: - cleanup_task = asyncio.create_task(self._discard_started_session(session)) + cleanup_task = asyncio.create_task(self._recover_started_session(launch, session, owner)) + while True: try: await asyncio.shield(cleanup_task) + break except asyncio.CancelledError: - await cleanup_task + # 再次取消调用者也不能把启动预留与未交付的真实进程分离。 + if cleanup_task.done(): + break + continue raise finally: if not slot_released: async with self._lock: - self._starting -= 1 - if self._starting == 0: - self._starts_idle.set() + self._release_start_locked(owner) + + async def _recover_started_session( + self, launch: asyncio.Task[_TerminalSession], session: Optional[_TerminalSession], owner: TerminalScope, + ) -> None: + """启动预留持有迟到进程;先回收再取得登记锁,避免取消被登记阻塞。""" + if session is None: + try: + session = await launch + except Exception: + return + session.owner = owner + await self._discard_started_session(session) + + def _release_start_locked(self, owner: TerminalScope) -> None: + """同一登记锁中释放全局与任务启动预留,关闭者才可确认快照完整。""" + self._starting -= 1 + if self._starting == 0: + self._starts_idle.set() + remaining = self._owner_starts[owner] - 1 + if remaining: + self._owner_starts[owner] = remaining + else: + self._owner_starts.pop(owner) + self._owner_idle.pop(owner).set() async def _discard_started_session(self, session: _TerminalSession) -> None: """首次返回前取消时回收无从寻址的进程,并撤销其会话登记。""" - await self._terminate_session(session) + converged = await self._terminate_session(session) async with self._lock: - if self._sessions.get(session.session_id) is session: + if converged and self._sessions.get(session.session_id) is session: self._sessions.pop(session.session_id) + elif not converged: + self._sessions[session.session_id] = session async def _start_pty_session( self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None, @@ -359,8 +383,8 @@ class _TerminalSessionManager: max_output_chars: Optional[int] = None, ) -> dict[str, Any]: """读取会话当前保留的增量输出。""" - session = self.get_session(session_id) - return self._read_payload( + session, _ = self._get_accessible_session(session_id, "read") + return read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, max_output_chars=max_output_chars, ) @@ -375,11 +399,12 @@ class _TerminalSessionManager: max_output_chars: Optional[int] = None, ) -> dict[str, Any]: """等待未读输出或输出最终收尾;零预算只取快照,不终止后台命令。""" - session = self.get_session(session_id) + session, scope = self._get_accessible_session(session_id, "wait") normalized_timeout = self._normalize_wait_timeout(timeout_ms) payload = await self._wait_for_output( session, timeout_ms=normalized_timeout, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, max_output_chars=max_output_chars, extra_fields={"wait_timeout_ms": normalized_timeout}, + scope=scope, action="wait", ) return payload @@ -388,13 +413,15 @@ class _TerminalSessionManager: since_offset: Optional[int] = None, max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, preserve_output_error: bool = False, max_output_chars: Optional[int] = None, extra_fields: Optional[dict[str, Any]] = None, + scope: TerminalScope, action: str, ) -> dict[str, Any]: """先捕获通知再查输出,无数据到 await 之间的变化也能唤醒所有等待者。""" loop = asyncio.get_running_loop() deadline = loop.time() + timeout_ms / 1000 while True: + self._check_access(session, scope, action) changed = session.changed_event - payload = self._read_payload( + payload = read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=preserve_output_error, max_output_chars=max_output_chars, extra_fields={**(extra_fields or {}), "wait_reason": "completed"}, @@ -411,11 +438,23 @@ class _TerminalSessionManager: if remaining <= 0: payload["wait_reason"] = "timeout" return payload - try: - await asyncio.wait_for(changed.wait(), timeout=remaining) - except asyncio.TimeoutError: - # 到期时再取一次一致快照,避免刚到达的数据只体现在高水位里。 - pass + await self._wait_for_change(session, scope, changed, remaining) + + @staticmethod + async def _wait_for_change( + session: _TerminalSession, scope: TerminalScope, changed: asyncio.Event, timeout: float, + ) -> None: + """输出和双方作用域封口任一变化都结束等待,取消时收齐短命通知任务。""" + events = {changed, scope.changed} + if session.owner is not None: + events.add(session.owner.changed) + waiters = [asyncio.create_task(event.wait()) for event in events] + try: + await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED) + finally: + for task in waiters: + task.cancel() + await asyncio.gather(*waiters, return_exceptions=True) async def write( self, *, session_id: str, input_text: str, since_seq: Optional[int] = None, @@ -423,9 +462,9 @@ class _TerminalSessionManager: max_output_chars: Optional[int] = None, close_stdin: bool = False, ) -> dict[str, Any]: """串行写入输入和可选管道 EOF;关闭 stdin 不影响输出读取器。""" - session = self.get_session(session_id) - self._validate_output_budget(max_output_chars) - self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + session, scope = self._get_accessible_session(session_id, "write") + validate_output_budget(max_output_chars) + resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) if type(close_stdin) is not bool: raise ValueError("close_stdin 必须为布尔值") if close_stdin and session.use_pty: @@ -433,6 +472,7 @@ class _TerminalSessionManager: data = (input_text or "").encode("utf-8") written = 0 async with session.input_lock: + self._check_access(session, scope, "write") if session.stdin_closed: if data or not close_stdin: raise RuntimeError("会话 stdin 已关闭,不能再写入输入") @@ -440,6 +480,7 @@ class _TerminalSessionManager: raise RuntimeError(f"会话已结束,当前状态: {session.status}") elif session.use_pty: while written < len(data): + self._check_access(session, scope, "write") if session.master_fd is None: raise RuntimeError("PTY 已关闭") try: @@ -452,18 +493,20 @@ class _TerminalSessionManager: raise BrokenPipeError("PTY 未接受后续输入") written += count else: - await self._write_pipe_input(session, data, close_stdin=close_stdin) + await self._write_pipe_input(session, scope, data, close_stdin=close_stdin) written = len(data) + self._check_access(session, scope, "write") session.updated_at = time.time() - payload = self._read_payload( + payload = read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars, extra_fields={"written_bytes": written}, ) return payload - @staticmethod - async def _write_pipe_input(session: _TerminalSession, data: bytes, *, close_stdin: bool) -> None: + async def _write_pipe_input( + self, session: _TerminalSession, scope: TerminalScope, data: bytes, *, close_stdin: bool, + ) -> None: """在会话输入锁内先排空末段,再半关闭并等待写端收尾。""" writer = session.process.stdin if session.process else None if writer is None: @@ -471,7 +514,9 @@ class _TerminalSessionManager: if data: writer.write(data) await writer.drain() + self._check_access(session, scope, "write") if close_stdin: + self._check_access(session, scope, "write") writer.close() session.stdin_closed = True await writer.wait_closed() @@ -481,9 +526,9 @@ class _TerminalSessionManager: max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, max_output_chars: Optional[int] = None, ) -> dict[str, Any]: """只发送一次真实中断信号,不改变终止意图,也不等待或升级成强杀。""" - session = self.get_session(session_id) - self._validate_output_budget(max_output_chars) - self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + session, _ = self._get_accessible_session(session_id, "interrupt") + validate_output_budget(max_output_chars) + resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) event = getattr(signal, "CTRL_BREAK_EVENT", None) sender = getattr(session.process, "send_signal", None) if os.name != "posix" and (event is None or not callable(sender)): @@ -498,7 +543,7 @@ class _TerminalSessionManager: sent = True except ProcessLookupError: pass - return self._read_payload( + return read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars, extra_fields={"signal": "SIGINT" if os.name == "posix" else "CTRL_BREAK_EVENT", "signal_sent": sent}, @@ -515,78 +560,150 @@ class _TerminalSessionManager: max_output_chars: Optional[int] = None, ) -> dict[str, Any]: """向会话进程组发送信号并等待短暂清理。""" - session = self.get_session(session_id) - self._validate_output_budget(max_output_chars) - self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + session, scope = self._get_accessible_session(session_id, "kill") + validate_output_budget(max_output_chars) + resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) signal_number = self._resolve_signal(sig) if session.status == "running": session.kill_requested = True self._send_signal(session, signal_number) if not await self._wait_for_exit(session): + self._check_access(session, scope, "kill") self._send_signal(session, _KILL_SIGNAL) - return self._read_payload( + self._check_access(session, scope, "kill") + return read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars, ) async def close(self) -> None: - """停止所有后台终端会话并释放 PTY、读取任务和会话记录。""" + """停止全部会话,未确认进程和读取器收尾的记录保留给下次关闭。""" async with self._close_lock: async with self._lock: self._closed = True + for owner in {*self._owner_starts, *(session.owner for session in self._sessions.values())}: + if owner is not None: + owner.seal() + for child in self._grants: + child.seal() + self._grants.clear() await self._starts_idle.wait() async with self._lock: sessions = list(self._sessions.values()) - await asyncio.gather( + results = await asyncio.gather( *(self._terminate_session(session) for session in sessions), return_exceptions=True, ) async with self._lock: - for session in sessions: - session.close_pty() - self._sessions.clear() + for session, converged in zip(sessions, results): + if converged is True: + self._sessions.pop(session.session_id, None) - async def _terminate_session(self, session: _TerminalSession) -> None: - """以有限等待停止进程,并在必要时升级为 SIGKILL。""" - if session.status == "running": - session.kill_requested = True - self._send_signal(session, signal.SIGTERM) + async def close_owner(self, owner: TerminalScope) -> bool: + """先封口并撤销能力,再只收敛本任务的启动和进程;未收敛时保留事实供重试。""" + owner.seal() + async with self._lock: + revoked = set(self._grants.pop(owner, {})) + owned = {key for key, session in self._sessions.items() if session.owner is owner} + for grants in self._grants.values(): + for key in owned: + grants.pop(key, None) + for key in revoked | owned: + if key in self._sessions: + self._sessions[key].notify_changed() + starting = self._owner_idle.get(owner) + if starting is not None: + try: + await asyncio.wait_for(starting.wait(), timeout=TERMINAL_KILL_GRACE_SECONDS) + except asyncio.TimeoutError: + return False + async with self._lock: + sessions = [session for session in self._sessions.values() if session.owner is owner] + results = await asyncio.gather( + *(self._terminate_session(session) for session in sessions), return_exceptions=True, + ) + async with self._lock: + for session, converged in zip(sessions, results): + if converged is True: + self._sessions.pop(session.session_id, None) + return not self._owner_starts.get(owner) and not any( + session.owner is owner for session in self._sessions.values() + ) - if not await self._wait_for_exit(session): - self._send_signal(session, _KILL_SIGNAL) + async def _terminate_session(self, session: _TerminalSession) -> bool: + """同一终端的关闭串行执行,真实收尾前不丢弃进程或输出读取器。""" + async with session.termination_lock: + if session.status == "running": + session.kill_requested = True + self._send_signal(session, signal.SIGTERM) if not await self._wait_for_exit(session): - logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}") - - for task in session.reader_tasks: - if not task.done(): - task.cancel() - if session.reader_tasks: - await asyncio.gather(*session.reader_tasks, return_exceptions=True) - session.finish_output() - session.close_pty() + if session.status == "running": + self._send_signal(session, _KILL_SIGNAL) + if not await self._wait_for_exit(session): + logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}") + return False + if any(not task.done() for task in session.reader_tasks): + return False + session.finish_output() + session.close_pty() + return True @staticmethod async def _wait_for_exit(session: _TerminalSession) -> bool: """复用终止与关闭的有界等待,超时不取消会话原本的进程收尾任务。""" - if session.wait_task is None or session.wait_task.done(): - return True - try: - await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS) - except asyncio.TimeoutError: - return False - return True + if session.wait_task is not None: + try: + await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS) + except asyncio.TimeoutError: + return False + except asyncio.CancelledError: + if not session.wait_task.cancelled(): + raise + return False + except Exception: + return False + return session.status in {"exited", "killed"} and ( + session.process is None or session.process.returncode is not None + ) - def get_session(self, session_id: str) -> _TerminalSession: - """按 ID 获取会话,不存在时抛出清晰错误。""" + def _get_accessible_session(self, session_id: str, action: str) -> tuple[_TerminalSession, TerminalScope]: + """所有用户动作共用归属查表,未知句柄与无权访问返回同一最小错误。""" + scope = require_terminal_scope() session = self._sessions.get(session_id) - if not session: - raise KeyError(f"终端会话不存在: {session_id}") - return session + if session is None: + raise TerminalAccessError() + self._check_access(session, scope, action) + return session, scope + + def _check_access(self, session: _TerminalSession, scope: TerminalScope, action: str) -> None: + """每个可暂停边界重新核对原调用者,封口或撤销后不再泄露输出或继续写入。""" + if ( + self._closed or scope.closed or not scope.user_id or not scope.task_id + or session.owner is None or session.owner.closed + or self._sessions.get(session.session_id) is not session + or (session.owner is not scope and action not in self._grants.get(scope, {}).get( + session.session_id, frozenset(), + )) + ): + raise TerminalAccessError() + + def share(self, parent: TerminalScope, child: TerminalScope, grants: dict[str, frozenset[str]]) -> None: + """宿主原子授予同用户指定任务的有限动作;受授者不能继续转授。""" + if ( + require_terminal_scope() is not parent or parent is child or child.closed + or not child.task_id or not child.user_id or parent.user_id != child.user_id + ): + raise TerminalAccessError() + for key, actions in grants.items(): + session, _ = self._get_accessible_session(key, "read") + if session.owner is not parent or not isinstance(actions, frozenset) or not actions <= _SHARE_ACTIONS: + raise TerminalAccessError() + self._grants.setdefault(child, {}).update(grants) @staticmethod def _normalize_wait_timeout(timeout_ms: Optional[int]) -> int: @@ -608,159 +725,11 @@ class _TerminalSessionManager: raise ValueError("yield_time_ms 必须为非负整数") return min(yield_time_ms, TERMINAL_YIELD_MAX_MS) - @staticmethod - def _validate_output_budget(max_output_chars: Optional[int]) -> None: - """宿主内部预算必须容纳有界命令预览和完整恢复元数据。""" - if max_output_chars is not None and (type(max_output_chars) is not int or max_output_chars < 4096): - raise ValueError("max_output_chars 必须为空或至少 4096 的整数") - @staticmethod - def _normalize_read_limit(max_bytes: Optional[int]) -> int: - """限制单次读取返回的输出大小。""" - try: - normalized = int(max_bytes or TERMINAL_DEFAULT_READ_BYTES) - except (TypeError, ValueError): - normalized = TERMINAL_DEFAULT_READ_BYTES - if normalized <= 0: - return TERMINAL_DEFAULT_READ_BYTES - return min(normalized, TERMINAL_MAX_READ_BYTES) - @staticmethod - def _resolve_cursor( - session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int], - ) -> tuple[int, int, bool]: - """保留旧序号含义,验证下一分片偏移,并显式恢复已过保留窗口的游标。""" - for name, value in (("since_seq", since_seq), ("since_offset", since_offset)): - if value is not None and (type(value) is not int or value < 0): - raise TerminalOutputError(f"{name} 必须为非负整数") - if since_seq is None and since_offset: - raise TerminalOutputError("非零 since_offset 必须同时提供 since_seq") - seq = session.retained_from_seq - 1 if since_seq is None else since_seq - offset = since_offset or 0 - if seq > session.next_seq - 1: - raise TerminalOutputError("since_seq 超过当前输出高水位") - if seq < session.retained_from_seq - 1: - return session.retained_from_seq - 1, 0, True - if not offset: - return seq, 0, session.output_lost - chunk = next((item for item in session.chunks if item.seq == seq + 1), None) - if chunk is None or offset > chunk.byte_size: - raise TerminalOutputError("since_offset 超出下一输出分片") - try: - chunk.text.encode("utf-8")[:offset].decode("utf-8") - except UnicodeDecodeError as error: - raise TerminalOutputError("since_offset 必须位于完整 UTF-8 字符边界") from error - if offset == chunk.byte_size: - return chunk.seq, 0, session.output_lost - return seq, offset, session.output_lost - @staticmethod - def _slice_output(encoded: bytes, limit: int, *, partial: bool) -> bytes: - """遵守页预算和完整字符边界;零进展明确报错,不能伪装成成功分页。""" - if len(encoded) <= limit: - return encoded - if not partial: - raise TerminalOutputError( - "当前页无法容纳完整分片;增大 max_bytes 或传 since_offset=0 后继续 read", - code="read_limit_too_small", minimum_read_bytes=len(encoded), - ) - text = encoded[:limit].decode("utf-8", errors="ignore") - if not text: - minimum = len(encoded.decode("utf-8")[0].encode("utf-8")) - raise TerminalOutputError( - "当前页无法容纳下一个完整 UTF-8 字符;增大 max_bytes 后继续 read", - code="read_limit_too_small", minimum_read_bytes=minimum, - ) - return text.encode("utf-8") - def _collect_output( - self, - session: _TerminalSession, - *, - since_seq: Optional[int], - since_offset: Optional[int] = None, - max_bytes: Optional[int], - ) -> dict[str, Any]: - """按完整分片序号及下一分片字节偏移返回实际交付的输出页。""" - read_limit = self._normalize_read_limit(max_bytes) - seq, offset, lost = self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) - selected_chunks = [chunk for chunk in session.chunks if chunk.seq > seq] - output_parts: list[str] = [] - output_bytes = 0 - for chunk in selected_chunks: - encoded = chunk.text.encode("utf-8")[offset:] - remaining = read_limit - output_bytes - if remaining == 0: - break - try: - piece = self._slice_output(encoded, remaining, partial=since_offset is not None) - except TerminalOutputError: - if not output_parts: - raise - break - output_parts.append(piece.decode("utf-8")) - output_bytes += len(piece) - if len(piece) < len(encoded): - offset += len(piece) - break - seq, offset = chunk.seq, 0 - return { - "output": "".join(output_parts), "output_until_seq": seq, "output_until_offset": offset, - "output_truncated": lost or seq < session.next_seq - 1, "output_lost": lost, - } - def _read_payload( - self, session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int], - max_bytes: Optional[int], preserve_output_error: bool = False, - max_output_chars: Optional[int] = None, extra_fields: Optional[dict[str, Any]] = None, - ) -> dict[str, Any]: - """动作已发生时保留会话句柄和未消费游标,只把分页错误作为附加恢复信息。""" - self._validate_output_budget(max_output_chars) - try: - page = self._collect_output(session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes) - except TerminalOutputError as error: - if not preserve_output_error or error.code != "read_limit_too_small": - raise - seq, offset, lost = self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) - page = { - "output": "", "output_until_seq": seq, "output_until_offset": offset, - "output_truncated": True, "output_lost": lost, - "output_error": { - "code": error.code, "message": str(error), "minimum_read_bytes": error.minimum_read_bytes, - }, - } - payload = {**self._session_payload(session, page), **(extra_fields or {})} - if max_output_chars is None or len(json.dumps(payload, ensure_ascii=False, indent=2)) <= max_output_chars: - return payload - low, high = 1, self._normalize_read_limit(max_bytes) - 1 - best = None - while low <= high: - middle = (low + high) // 2 - try: - candidate = self._read_payload( - session, since_seq=since_seq, since_offset=since_offset, max_bytes=middle, extra_fields=extra_fields, - ) - except TerminalOutputError: - low = middle + 1 - continue - if len(json.dumps(candidate, ensure_ascii=False, indent=2)) <= max_output_chars: - best, low = candidate, middle + 1 - else: - high = middle - 1 - if best is not None: - return best - budget_error = TerminalOutputError( - "Agent 结果预算无法容纳完整分片;传 since_offset=0 并调整 max_bytes 后继续 read,勿重复执行动作", - code="read_limit_too_small", - ) - if not preserve_output_error: - raise budget_error - payload = self._read_payload( - session, since_seq=since_seq, since_offset=since_offset, max_bytes=1, - preserve_output_error=True, extra_fields=extra_fields, - ) - payload["output_error"]["message"] = str(budget_error) - return payload @staticmethod def _resolve_signal(sig: Optional[str | int]) -> int: @@ -814,54 +783,7 @@ class _TerminalSessionManager: session = self._sessions.pop(session_id) session.close_pty() - @staticmethod - def _text_preview(value: str, limit: int = 1024) -> str: - """按 JSON 实际转义开销限制元数据预览,极长命令不能挤掉输出游标。""" - low, high = 0, min(len(value), limit) - while low < high: - middle = (low + high + 1) // 2 - if len(json.dumps(value[:middle], ensure_ascii=False)) <= limit: - low = middle - else: - high = middle - 1 - return value[:low] - @staticmethod - def _session_payload( - session: _TerminalSession, - page: dict[str, Any], - ) -> dict[str, Any]: - """生成工具返回的结构化会话状态。""" - command = _TerminalSessionManager._text_preview(session.command) - cwd = _TerminalSessionManager._text_preview(session.cwd) - error = _TerminalSessionManager._text_preview(session.error, 512) if session.error else session.error - shell = session.shell_policy.executable if session.shell_policy else None - shell_preview = _TerminalSessionManager._text_preview(shell, 256) if shell else shell - if session.status == "running": - outcome = "pending" - elif session.status == "error": - outcome = "failed" - elif session.exit_code is None: - outcome = "unknown" - else: - outcome = "succeeded" if session.exit_code == 0 and session.status != "killed" else "failed" - return { - "session_id": session.session_id, - "command": command, "command_truncated": command != session.command, - "command_total_chars": len(session.command), "cwd": cwd, "cwd_truncated": cwd != session.cwd, - "pid": session.pid, - "status": session.status, - "exit_code": session.exit_code, - "execution_outcome": outcome, - "use_pty": session.use_pty, - "shell": shell_preview, "shell_truncated": shell_preview != shell, - "login": session.shell_policy.login if session.shell_policy else None, "stdin_closed": session.stdin_closed, - "last_seq": session.next_seq - 1, - "retained_from_seq": session.retained_from_seq, - "output_complete": session.output_complete, - "error": error, "error_truncated": error != session.error, - **page, - } terminal_session_manager = _TerminalSessionManager() @@ -870,6 +792,9 @@ terminal_session_manager = _TerminalSessionManager() def get_terminal_session_manager() -> _TerminalSessionManager: """返回当前进程的终端会话管理器,避免复用已完成关停的实例。""" global terminal_session_manager - if terminal_session_manager._closed: + if ( + terminal_session_manager._closed and not terminal_session_manager._sessions + and terminal_session_manager._starting == 0 + ): terminal_session_manager = _TerminalSessionManager() return terminal_session_manager diff --git a/app/agent/terminal/output.py b/app/agent/terminal/output.py new file mode 100644 index 000000000..0556683ec --- /dev/null +++ b/app/agent/terminal/output.py @@ -0,0 +1,231 @@ +"""终端输出分页、游标校验与有界结果投影。""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from app.agent.terminal.session import _TerminalSession + +TERMINAL_DEFAULT_READ_BYTES = 10 * 1024 +TERMINAL_MAX_READ_BYTES = 64 * 1024 + +class TerminalOutputError(ValueError): + """携带稳定错误码和最小页预算的可恢复输出读取错误。""" + + def __init__(self, message: str, *, code: str = "invalid_output_cursor", minimum_read_bytes: Optional[int] = None) -> None: + """保留结构化恢复提示,调用方不必解析中文消息。""" + super().__init__(message) + self.code = code + self.minimum_read_bytes = minimum_read_bytes + + + +def validate_output_budget(max_output_chars: Optional[int]) -> None: + """宿主内部预算必须容纳有界命令预览和完整恢复元数据。""" + if max_output_chars is not None and (type(max_output_chars) is not int or max_output_chars < 4096): + raise ValueError("max_output_chars 必须为空或至少 4096 的整数") + + + +def normalize_read_limit(max_bytes: Optional[int]) -> int: + """限制单次读取返回的输出大小。""" + try: + normalized = int(max_bytes or TERMINAL_DEFAULT_READ_BYTES) + except (TypeError, ValueError): + normalized = TERMINAL_DEFAULT_READ_BYTES + if normalized <= 0: + return TERMINAL_DEFAULT_READ_BYTES + return min(normalized, TERMINAL_MAX_READ_BYTES) + + + +def resolve_cursor( + session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int], +) -> tuple[int, int, bool]: + """保留旧序号含义,验证下一分片偏移,并显式恢复已过保留窗口的游标。""" + for name, value in (("since_seq", since_seq), ("since_offset", since_offset)): + if value is not None and (type(value) is not int or value < 0): + raise TerminalOutputError(f"{name} 必须为非负整数") + if since_seq is None and since_offset: + raise TerminalOutputError("非零 since_offset 必须同时提供 since_seq") + seq = session.retained_from_seq - 1 if since_seq is None else since_seq + offset = since_offset or 0 + if seq > session.next_seq - 1: + raise TerminalOutputError("since_seq 超过当前输出高水位") + if seq < session.retained_from_seq - 1: + return session.retained_from_seq - 1, 0, True + if not offset: + return seq, 0, session.output_lost + chunk = next((item for item in session.chunks if item.seq == seq + 1), None) + if chunk is None or offset > chunk.byte_size: + raise TerminalOutputError("since_offset 超出下一输出分片") + try: + chunk.text.encode("utf-8")[:offset].decode("utf-8") + except UnicodeDecodeError as error: + raise TerminalOutputError("since_offset 必须位于完整 UTF-8 字符边界") from error + if offset == chunk.byte_size: + return chunk.seq, 0, session.output_lost + return seq, offset, session.output_lost + + + +def _slice_output(encoded: bytes, limit: int, *, partial: bool) -> bytes: + """遵守页预算和完整字符边界;零进展明确报错,不能伪装成成功分页。""" + if len(encoded) <= limit: + return encoded + if not partial: + raise TerminalOutputError( + "当前页无法容纳完整分片;增大 max_bytes 或传 since_offset=0 后继续 read", + code="read_limit_too_small", minimum_read_bytes=len(encoded), + ) + text = encoded[:limit].decode("utf-8", errors="ignore") + if not text: + minimum = len(encoded.decode("utf-8")[0].encode("utf-8")) + raise TerminalOutputError( + "当前页无法容纳下一个完整 UTF-8 字符;增大 max_bytes 后继续 read", + code="read_limit_too_small", minimum_read_bytes=minimum, + ) + return text.encode("utf-8") + + + +def collect_output( + session: _TerminalSession, + *, + since_seq: Optional[int], + since_offset: Optional[int] = None, + max_bytes: Optional[int], +) -> dict[str, Any]: + """按完整分片序号及下一分片字节偏移返回实际交付的输出页。""" + read_limit = normalize_read_limit(max_bytes) + seq, offset, lost = resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + selected_chunks = [chunk for chunk in session.chunks if chunk.seq > seq] + output_parts: list[str] = [] + output_bytes = 0 + for chunk in selected_chunks: + encoded = chunk.text.encode("utf-8")[offset:] + remaining = read_limit - output_bytes + if remaining == 0: + break + try: + piece = _slice_output(encoded, remaining, partial=since_offset is not None) + except TerminalOutputError: + if not output_parts: + raise + break + output_parts.append(piece.decode("utf-8")) + output_bytes += len(piece) + if len(piece) < len(encoded): + offset += len(piece) + break + seq, offset = chunk.seq, 0 + return { + "output": "".join(output_parts), "output_until_seq": seq, "output_until_offset": offset, + "output_truncated": lost or seq < session.next_seq - 1, "output_lost": lost, + } + + +def read_payload( + session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int], + max_bytes: Optional[int], preserve_output_error: bool = False, + max_output_chars: Optional[int] = None, extra_fields: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """动作已发生时保留会话句柄和未消费游标,只把分页错误作为附加恢复信息。""" + validate_output_budget(max_output_chars) + try: + page = collect_output(session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes) + except TerminalOutputError as error: + if not preserve_output_error or error.code != "read_limit_too_small": + raise + seq, offset, lost = resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + page = { + "output": "", "output_until_seq": seq, "output_until_offset": offset, + "output_truncated": True, "output_lost": lost, + "output_error": { + "code": error.code, "message": str(error), "minimum_read_bytes": error.minimum_read_bytes, + }, + } + payload = {**_session_payload(session, page), **(extra_fields or {})} + if max_output_chars is None or len(json.dumps(payload, ensure_ascii=False, indent=2)) <= max_output_chars: + return payload + low, high = 1, normalize_read_limit(max_bytes) - 1 + best = None + while low <= high: + middle = (low + high) // 2 + try: + candidate = read_payload( + session, since_seq=since_seq, since_offset=since_offset, max_bytes=middle, extra_fields=extra_fields, + ) + except TerminalOutputError: + low = middle + 1 + continue + if len(json.dumps(candidate, ensure_ascii=False, indent=2)) <= max_output_chars: + best, low = candidate, middle + 1 + else: + high = middle - 1 + if best is not None: + return best + budget_error = TerminalOutputError( + "Agent 结果预算无法容纳完整分片;传 since_offset=0 并调整 max_bytes 后继续 read,勿重复执行动作", + code="read_limit_too_small", + ) + if not preserve_output_error: + raise budget_error + payload = read_payload( + session, since_seq=since_seq, since_offset=since_offset, max_bytes=1, + preserve_output_error=True, extra_fields=extra_fields, + ) + payload["output_error"]["message"] = str(budget_error) + return payload + + + +def _text_preview(value: str, limit: int = 1024) -> str: + """按 JSON 实际转义开销限制元数据预览,极长命令不能挤掉输出游标。""" + low, high = 0, min(len(value), limit) + while low < high: + middle = (low + high + 1) // 2 + if len(json.dumps(value[:middle], ensure_ascii=False)) <= limit: + low = middle + else: + high = middle - 1 + return value[:low] + + + +def _session_payload( + session: _TerminalSession, + page: dict[str, Any], +) -> dict[str, Any]: + """生成工具返回的结构化会话状态。""" + command = _text_preview(session.command) + cwd = _text_preview(session.cwd) + error = _text_preview(session.error, 512) if session.error else session.error + shell = session.shell_policy.executable if session.shell_policy else None + shell_preview = _text_preview(shell, 256) if shell else shell + if session.status == "running": + outcome = "pending" + elif session.status == "error": + outcome = "failed" + elif session.exit_code is None: + outcome = "unknown" + else: + outcome = "succeeded" if session.exit_code == 0 and session.status != "killed" else "failed" + return { + "session_id": session.session_id, + "command": command, "command_truncated": command != session.command, + "command_total_chars": len(session.command), "cwd": cwd, "cwd_truncated": cwd != session.cwd, + "pid": session.pid, + "status": session.status, + "exit_code": session.exit_code, + "execution_outcome": outcome, + "use_pty": session.use_pty, + "shell": shell_preview, "shell_truncated": shell_preview != shell, + "login": session.shell_policy.login if session.shell_policy else None, "stdin_closed": session.stdin_closed, + "last_seq": session.next_seq - 1, + "retained_from_seq": session.retained_from_seq, + "output_complete": session.output_complete, + "error": error, "error_truncated": error != session.error, + **page, + } diff --git a/app/agent/terminal/ownership.py b/app/agent/terminal/ownership.py new file mode 100644 index 000000000..58beadd9c --- /dev/null +++ b/app/agent/terminal/ownership.py @@ -0,0 +1,105 @@ +"""由宿主持有的终端任务身份及异步调用上下文,不接受模型声明归属。""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Optional + +TERMINAL_SCOPE_CLOSE_TIMEOUT_SECONDS = 6.0 + + +class TerminalAccessError(RuntimeError): + """以相同错误隐藏不存在、失效或不属于当前任务的终端记录。""" + + def __init__(self) -> None: + """固定消息不携带终端命令、输出、进程号或其他任务身份。""" + super().__init__("当前任务无法访问此终端;请核对任务实际状态,勿自动重跑命令") + + +@dataclass(eq=False, frozen=True, slots=True) +class TerminalScope: + """用宿主对象身份区分同名任务的不同代次,关闭后不能重新激活。""" + + user_id: str + task_id: str + kind: str + _closed: bool = field(default=False, init=False, repr=False) + changed: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False) + _active_runs: int = field(default=0, init=False, repr=False) + runs_idle: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False) + + def __post_init__(self) -> None: + """初始化一次性命令的空闲边界,供作用域收口等待真实进程结束。""" + self.runs_idle.set() + + @property + def closed(self) -> bool: + """公开只读封口事实,普通模型参数不能重新打开同一任务。""" + return self._closed + + def seal(self) -> None: + """在任何异步清理之前同步封口,并唤醒等待撤销的调用。""" + object.__setattr__(self, "_closed", True) + self.changed.set() + + def begin_run(self) -> None: + """登记一次性命令,即使尚未取得并发槽也不能被作用域清理遗漏。""" + if self.closed: + raise TerminalAccessError() + object.__setattr__(self, "_active_runs", self._active_runs + 1) + self.runs_idle.clear() + + def finish_run(self) -> None: + """发布一次性命令已经完成,允许作用域收口返回真实终态。""" + remaining = max(0, self._active_runs - 1) + object.__setattr__(self, "_active_runs", remaining) + if remaining == 0: + self.runs_idle.set() + + async def wait_runs(self) -> bool: + """等待作用域下的 run 全部退出,超出有界回收时间则保留未收敛事实。""" + try: + await asyncio.wait_for(self.runs_idle.wait(), timeout=TERMINAL_SCOPE_CLOSE_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + return False + return self._active_runs == 0 + + +_terminal_scope: ContextVar[Optional[TerminalScope]] = ContextVar("agent_terminal_scope", default=None) + + +@contextmanager +def bind_terminal_scope(scope: Optional[TerminalScope]) -> Iterator[None]: + """只在当前异步调用链绑定宿主身份,异常和嵌套调用始终还原上层身份。""" + token = _terminal_scope.set(scope) + try: + yield + finally: + _terminal_scope.reset(token) + + +def current_terminal_scope() -> Optional[TerminalScope]: + """供宿主查看当前绑定对象,不创建默认用户或全局任务。""" + return _terminal_scope.get() + + +def require_terminal_scope() -> TerminalScope: + """缺少可信主体、任务身份或已经封口时拒绝进入终端能力。""" + scope = current_terminal_scope() + if scope is None or scope.closed or not scope.user_id or not scope.task_id: + raise TerminalAccessError() + return scope + + +async def close_terminal_scope(scope: TerminalScope) -> bool: + """只收敛已装配的终端管理器,无终端任务封口时不物化进程能力。""" + scope.seal() + module = sys.modules.get("app.agent.terminal.manager") + manager = getattr(module, "terminal_session_manager", None) + manager_closed = True if manager is None else bool(await manager.close_owner(scope)) + return manager_closed and await scope.wait_runs() diff --git a/app/agent/terminal/session.py b/app/agent/terminal/session.py index e749ae185..dc19c7753 100644 --- a/app/agent/terminal/session.py +++ b/app/agent/terminal/session.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field from typing import Any, Optional from app.agent.shell import AgentShell +from app.agent.terminal.ownership import TerminalScope TERMINAL_RETENTION_SECONDS = 30 * 60 TERMINAL_MAX_RETAINED_BYTES = 1024 * 1024 @@ -36,8 +37,10 @@ class _TerminalSession: pid: int use_pty: bool shell_policy: Optional[AgentShell] = None + owner: Optional[TerminalScope] = None stdin_closed: bool = False input_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + termination_lock: asyncio.Lock = field(default_factory=asyncio.Lock) created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) status: str = "running" diff --git a/app/agent/tools/impl/execute_command.py b/app/agent/tools/impl/execute_command.py index b8718ea3b..03633d7e0 100644 --- a/app/agent/tools/impl/execute_command.py +++ b/app/agent/tools/impl/execute_command.py @@ -17,13 +17,12 @@ from pydantic import BaseModel, Field from app.agent.shell import build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell from app.agent.terminal.manager import ( - TERMINAL_DEFAULT_READ_BYTES, - TERMINAL_MAX_READ_BYTES, TERMINAL_WAIT_DEFAULT_MS, TERMINAL_YIELD_DEFAULT_MS, - TerminalOutputError, get_terminal_session_manager, ) +from app.agent.terminal.output import TERMINAL_DEFAULT_READ_BYTES, TERMINAL_MAX_READ_BYTES, TerminalOutputError +from app.agent.terminal.ownership import TerminalAccessError, TerminalScope, require_terminal_scope from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS, MoviePilotTool from app.agent.tools.impl._command_safety import validate_command_safety from app.agent.tools.tags import ToolTag @@ -479,9 +478,12 @@ class ExecuteCommandTool(MoviePilotTool): cwd: Optional[str] = None, shell: Optional[str] = None, login: bool = False, + scope_cancelled: bool = False, ) -> str: """分开返回机器可判定的执行状态与有界输出,不能靠完成提示推断成功。""" - if exit_code is None: + if scope_cancelled: + result = "命令因任务作用域关闭而取消,未确认业务动作是否完成" + elif exit_code is None: result = "无法确认命令进程已结束,请先核对实际状态" elif timed_out: result = f"命令执行超时 (限制: {timeout}秒,已终止进程)" @@ -503,11 +505,15 @@ class ExecuteCommandTool(MoviePilotTool): result += "\n\n...(仅展示前后各 16KB 内容)" if not output.combined_preview: result += "\n\n(无输出内容)" - succeeded = exit_code == 0 and not timed_out - outcome = "unknown" if exit_code is None else ("succeeded" if succeeded else "failed") + succeeded = exit_code == 0 and not timed_out and not scope_cancelled + outcome = "failed" if scope_cancelled else ( + "unknown" if exit_code is None else ("succeeded" if succeeded else "failed") + ) return ExecuteCommandTool._dump({ "action": "run", "success": succeeded, "execution_outcome": outcome, - "status": "unknown" if exit_code is None else ("timed_out" if timed_out else "exited"), + "status": "cancelled" if scope_cancelled else ( + "unknown" if exit_code is None else ("timed_out" if timed_out else "exited") + ), "exit_code": exit_code, "timed_out": timed_out, "timeout": timeout, "cwd": cwd, "shell": shell, "login": login, "stdin_closed": True, "output_truncated": output.preview_truncated, "output_file": output.temp_file_path, @@ -527,12 +533,69 @@ class ExecuteCommandTool(MoviePilotTool): ) -> str: """一次性执行命令并返回结构化终态;退出路径都必须释放读取任务和归档句柄。""" self._validate_command(command, confirmed=confirm_dangerous) + scope = require_terminal_scope() + scope.begin_run() + try: + return await self._run_once_with_scope( + scope=scope, command=command, timeout=timeout, cwd=cwd, env=env, + shell=shell, login=login, confirm_dangerous=confirm_dangerous, + ) + finally: + scope.finish_run() + + @staticmethod + async def _acquire_command_slot(scope: TerminalScope) -> None: + """并发槽等待期间响应作用域封口,禁止取消后迟到启动一次性进程。""" + acquire_task = asyncio.create_task(_command_semaphore.acquire()) + closed_task = asyncio.create_task(scope.changed.wait()) + acquired = False + released = False + try: + done, _ = await asyncio.wait( + {acquire_task, closed_task}, return_when=asyncio.FIRST_COMPLETED, + ) + if closed_task in done and acquire_task not in done: + acquire_task.cancel() + await asyncio.gather(acquire_task, return_exceptions=True) + raise TerminalAccessError() + await acquire_task + acquired = True + if scope.closed: + _command_semaphore.release() + released = True + acquired = False + raise TerminalAccessError() + finally: + if not acquire_task.done(): + acquire_task.cancel() + await asyncio.gather(acquire_task, return_exceptions=True) + if acquire_task.done() and not acquire_task.cancelled() and not acquired and not released: + _command_semaphore.release() + if not closed_task.done(): + closed_task.cancel() + await asyncio.gather(closed_task, return_exceptions=True) + + async def _run_once_with_scope( + self, + *, + scope: TerminalScope, + command: str, + timeout: Optional[int], + cwd: Optional[str] = None, + env: Optional[dict[str, Any]] = None, + shell: Optional[str] = None, + login: Optional[bool] = None, + confirm_dangerous: bool = False, + ) -> str: + """在已登记作用域下运行一次命令,并对封口和进程收尾保持可观察。""" normalized_timeout, timeout_note = self._normalize_timeout(timeout) normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting("ROOT_PATH")) normalized_env = build_agent_subprocess_env(env) shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd) - async with _command_semaphore: + await self._acquire_command_slot(scope) + try: + require_terminal_scope() process = await asyncio.create_subprocess_exec( *shell_policy.build_argv(command), cwd=normalized_cwd, env=normalized_env, **self._subprocess_kwargs(), @@ -545,31 +608,41 @@ class ExecuteCommandTool(MoviePilotTool): ] timed_out = False + scope_cancelled = False + scope_task = asyncio.create_task(scope.changed.wait()) try: - await asyncio.wait_for( - asyncio.shield(wait_task), timeout=normalized_timeout + done, _ = await asyncio.wait( + {wait_task, scope_task}, timeout=normalized_timeout, + return_when=asyncio.FIRST_COMPLETED, ) - except asyncio.TimeoutError: - timed_out = True - await self._cleanup_process(process, wait_task) + scope_cancelled = scope_task in done + if wait_task not in done: + timed_out = not scope_cancelled + await self._cleanup_process(process, wait_task) except asyncio.CancelledError: await self._cleanup_process(process, wait_task) raise finally: + if not scope_task.done(): + scope_task.cancel() + await asyncio.gather(scope_task, return_exceptions=True) try: await self._finish_reader_tasks(reader_tasks) finally: output.close() - return self._format_run_result( - exit_code=process.returncode, - output=output, - timeout=normalized_timeout, - timed_out=timed_out, - timeout_note=timeout_note, - cwd=normalized_cwd, shell=shell_policy.executable, login=shell_policy.login, - ) + return self._format_run_result( + exit_code=process.returncode, + output=output, + timeout=normalized_timeout, + timed_out=timed_out, + scope_cancelled=scope_cancelled, + timeout_note=timeout_note, + cwd=normalized_cwd, shell=shell_policy.executable, login=shell_policy.login, + ) + finally: + _command_semaphore.release() async def run( self, @@ -601,6 +674,7 @@ class ExecuteCommandTool(MoviePilotTool): ) try: + require_terminal_scope() terminal_session_manager = get_terminal_session_manager() output_budget = DEFAULT_TOOL_RESULT_MAX_CHARS if self.result_max_chars and self.result_max_chars > 0: @@ -692,6 +766,11 @@ class ExecuteCommandTool(MoviePilotTool): ) raise ValueError(f"不支持的 action: {action}") + except TerminalAccessError as err: + return self._dump({ + "error": str(err), "status": "error", "action": normalized_action, + "success": False, "execution_outcome": "failed", "code": "terminal_access_denied", + }) except TerminalOutputError as err: return self._dump({ "error": str(err), "status": "error", "action": normalized_action, diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index 6f82a1e94..78dd9023b 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -6,6 +6,12 @@ import threading import uuid from typing import TYPE_CHECKING, Any, Dict, List, Optional +from app.agent.terminal.ownership import ( + TerminalScope, + bind_terminal_scope, + close_terminal_scope, + current_terminal_scope, +) from app.runtime.log import logger if TYPE_CHECKING: @@ -21,6 +27,7 @@ class ToolDefinition: """ def __init__(self, name: str, description: str, input_schema: Dict[str, Any]): + """保存严格工具目录对外展示的名称、说明和参数合同。""" self.name = name self.description = description self.input_schema = input_schema @@ -34,7 +41,7 @@ class MoviePilotToolsManager: def __init__( self, user_id: str = "api_user", - session_id: str = uuid.uuid4(), + session_id: Optional[str] = None, is_admin: bool = True, policy_orchestrator: Optional[AgentToolPolicyOrchestrator] = None, data: Optional[AgentDataContext] = None, @@ -47,7 +54,8 @@ class MoviePilotToolsManager: session_id: 会话ID """ self.user_id = user_id - self.session_id = session_id + self.session_id = session_id if session_id is not None else uuid.uuid4().hex + self._terminal_scope = TerminalScope(user_id=user_id, task_id=self.session_id, kind="operator") self.is_admin = is_admin self.policy_orchestrator = policy_orchestrator self._data = data @@ -423,7 +431,9 @@ class MoviePilotToolsManager: # 调用工具的run方法。HTTP/MCP 工具调用不会经过 BaseTool._arun, # 因此这里也必须复用同一套返回值格式化和兜底截断逻辑。 - result = await tool_instance.run_with_timeout(**normalized_arguments) + # 嵌套宿主调用保留任务身份;独立内部入口使用该管理器的专属作用域。 + with bind_terminal_scope(current_terminal_scope() or self._terminal_scope): + result = await tool_instance.run_with_timeout(**normalized_arguments) str_result = format_tool_result_for_agent( result, tool_name=tool_name, @@ -463,6 +473,10 @@ class MoviePilotToolsManager: ) return str_result + async def close(self) -> bool: + """封闭本内部调用方的终端作用域,真实进程未收敛时允许调用方重试。""" + return await close_terminal_scope(self._terminal_scope) + @staticmethod def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]: """ diff --git a/docs/agent-codex-parity-plan.md b/docs/agent-codex-parity-plan.md new file mode 100644 index 000000000..793287b0b --- /dev/null +++ b/docs/agent-codex-parity-plan.md @@ -0,0 +1,56 @@ +# MoviePilot Agent 与 Codex Harness 对齐路线 + +更新时间:2026-09-11 + +这份文档是 Agent 能力对齐的交付路线和每轮验收合同。它记录当前证据与未完成目标,不把一次成功的模型调用当成“已经和 Codex 一样聪明”。最终判断必须同时看模型行为、工具执行、任务生命周期、业务终态和失败后的收敛结果。 + +## 当前目标 + +让 MoviePilot Agent 在 MoviePilot 的真实业务边界内具备可复核的 Codex 级 Harness 能力:模型能看到准确的工具合同,工具能完成完整的命令行、浏览器、终端输入输出、子代理和长任务协作闭环,宿主能隔离任务身份、收口进程并在新消息到达时继续推进。高影响业务动作仍由宿主的授权和确认策略控制。 + +真实模型评测的固定档位使用 **luna-max**:模型标识为 `gpt-5.6-luna`,推理预算为 `max`。报告必须同时记录这两个字段;更换模型、推理预算、提示集或判定器后,结果属于新的基线,不能与旧报告直接合并。 + +## 交付顺序 + +| 目标 | 状态 | 验收重点 | +| --- | --- | --- | +| C1.4 终端工具完整闭环 | 已完成基线 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试 | +| C1.3 终端任务作用域 | 本轮进行中 | 终端归属由宿主对象身份决定;定时运行、会话、子任务和内部工具管理器隔离;封口先于清理,排队或运行中的命令都不会在任务结束后迟到启动 | +| S2.3 运行中消息排队与 WebAgent 输入 | 下一目标 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 能报告 queued/applied,停止后不再派发后续工具 | +| 浏览器能力对齐 | S2.3 后续 | 导航、页面读取、点击/输入、等待、截图和失败收口使用真实浏览器状态;工具清单、权限、超时、重试和会话生命周期与命令行能力同样可观测 | +| S3.1 通用子代理 | S2.3 后 | 主 Agent 按任务动态派发通用子代理;保留专用画像必须有独立收益证据。子代理的授权、工具角色、终端分享和副作用边界不能因画像切换而放宽 | + +## 每轮硬门禁 + +每个影响 Agent、工具、会话、浏览器、子代理或提示词的代码轮次都要执行下面四层检查,并把结果与最终提交 SHA 绑定: + +1. **确定性回归**:运行受影响的 pytest、全量 Agent 测试、类型/格式/架构 ratchet 和必要的全量测试。任何已知基线失败都要在未改动基线复现后再归因。 +2. **真实 MoviePilot 运行**:使用 `gpt-5.6-luna` + `max`,在隔离 worker 中跑固定场景,保留真实模型请求、工具轨迹、耗时、token、终态和进程收尾证据。业务副作用只能进入评测假世界,不能把测试凭据或私有响应写入报告。 +3. **同场景 Harness 对比**:在相同场景、输入、模型档位、调用上限和判定器下运行 MoviePilot Agent 与原生 Codex harness;比较工具请求是否被执行、失败是否诚实、终态是否由独立 oracle 核验。原生 harness 不可用时报告 `blocked`,不能以离线脚本或单元测试替代。 +4. **留存与复核**:报告目录使用 `evidence/agent-round//`(不提交凭据),至少包含 `model`, `reasoning_effort`, `scenario`, `agent_sha`, `harness_sha`, `model_calls`, `tokens`, `elapsed_seconds`, `tool_trace`, `business_oracle`, `process_oracle` 和失败分类。重复运行同一轮时保留每次报告,不能覆盖异常样本。 + +当前评测入口: + +```bash +UV_PROJECT_ENVIRONMENT=/Users/jxxghp/MPProjects/MoviePilot/.venv \ +uv run --locked --no-sync python -m scripts.evaluation \ + --live --scenario unknown_download \ + --model gpt-5.6-luna --reasoning-effort max \ + --output evidence/agent-round//moviepilot-unknown_download.json +``` + +同一场景的原生 harness 使用 `--native`,报告必须和 `--live` 放在同一个提交目录下。真实评测不能在 pytest 中隐式联网;命令失败、模型拒绝、token 不完整或 oracle 未核验都标记为未完成,不得记为通过。 + +## 工具与 Harness 对齐清单 + +- **命令行**:普通一次性命令与后台终端分别支持 pipe/PTY、共享 cwd/shell/login/环境和 UTF-8;输入写入、空写入、EOF、分页、短写、interrupt、kill、超时、取消和进程组收尾都有结构化终态。 +- **浏览器**:工具目录必须明确导航、读取、交互、等待和截图的动作与权限;浏览器会话、页面状态和失败重试由宿主持有,不能由模型字符串冒领。 +- **子代理**:主 Agent 自动选择通用子代理;专用画像只有在 held-out 任务上证明提高成功率、减少调用或降低副作用风险时才保留。子代理不能自行发送消息、执行高影响写操作或继承兄弟任务句柄。 +- **长任务与新消息**:入站消息在任务运行时仍可接受并进入有界队列;应用到下一模型边界时必须保留 tool-call/tool-result 配对和取消语义,不能只把文本拼到系统提示词。 +- **观察证据**:最终答案只能引用宿主记录的工具结果和业务 oracle;“模型说完成”不能替代数据库、下载器、浏览器或进程状态核验。 + +## 通过标准与未完成边界 + +“对齐”至少需要多个固定场景、多个重复轮次和一组未见场景在同一模型档位下持续通过,并且命令行、浏览器、消息排队、子代理和取消路径都具备失败证据。一次 live pilot、单个场景或单元测试通过只能证明局部合同成立,不能证明与 Codex 的整体智能相等。 + +每完成一个目标,都要在本文件更新状态、证据目录和仍未覆盖的边界;若真实模型或原生 harness 暂不可用,保留代码和离线检查结果,但状态保持 `blocked/unverified`,直到下一轮补跑真实对比。 diff --git a/docs/agent.md b/docs/agent.md index 50d8009d7..f13a47f85 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,6 +1,6 @@ # Agent 复杂任务执行与恢复 -MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务。本次增强关注长任务的连续性:明确目标和检查结果,按需补充工具,并在执行中断后保留已知事实。 +MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务。本次增强关注长任务的连续性:明确目标和检查结果,按需补充工具,并在执行中断后保留已知事实。能力对齐路线与每轮真实运行验收见 [Agent 与 Codex Harness 对齐路线](agent-codex-parity-plan.md)。 ## 任务计划 @@ -34,6 +34,12 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务 管道会话可用 `write(input_text="末段输入", close_stdin=true)` 在交付末段后关闭输入,或用空输入显式关闭;输出仍通过 `read/wait` 读取,`stdin_closed` 表示输入已关闭。普通空 `write` 不发送 EOF;输入关闭后不能再追加数据,重复空关闭可确认已有状态。`run` 的 stdin 始终为 EOF。PTY 的输入和输出共用端点,因此拒绝 `close_stdin=true`,也不会先写入附带输入或关闭输出;PTY 控制字节的行为取决于终端模式,pipe 中 `\u0003`、`\u0004` 只是普通字节。 +终端句柄绑定宿主创建的用户及任务作用域,知道 `session_id` 不代表可以操作它。对话正常多轮和图重建保留同一作用域;每次定时执行按真实 `run_id` 单独持有终端,执行结束、失败或取消后回收。清空对话、空闲回收或同会话更换用户时封闭旧作用域;只清理它拥有的进程,未收敛时保留记录供重试。停止当前交互推理仍保留已交付的后台终端,后续对话可继续读取或显式终止。 + +父任务使用 `task` 或 `subagent_task` 的 `terminal_sessions=[{"session_id":"term_…","actions":["read","wait"]}]` 显式分享指定终端给该子任务。批次及管道分别在每个任务定义里声明;提示词中提到句柄不会授权,兄弟任务也不会继承。子任务保有独立作用域,结束时撤销分享但不终止父进程;当前只读子代理策略继续限制动作。宿主控制授权与工具角色权限分别校验,共享不能扩大角色权限。 + +缺少、已关闭或无权访问的终端均返回 `terminal_access_denied`,不回显其他任务的命令和输出。进程重启后不恢复旧句柄,也不会自动重跑命令。内部 `MoviePilotToolsManager` 每个实例独立持有作用域,通过 `close()` 收口;直接调用命令工具必须由宿主绑定真实上下文,工具 JSON 不能声明或覆盖身份。 + `interrupt` 仅发送一次 POSIX `SIGINT` 或支持的 Windows `CTRL_BREAK_EVENT`,不等待升级强杀,也不把会话标记为主动终止;实际命令可以处理信号后继续运行,也可能自行退出。回包含实际 `signal` 和 `signal_sent`。不具备该控制能力时明确失败;`kill` 仍负责终止并在需要时强杀,但未知名称、无效编号和没有真实映射的信号会在任何状态修改前拒绝。两种输入控制均不增加只读子代理权限。 后台命令 `start` 默认最多等待 250ms 的首次输出,可用 `yield_time_ms=0` 立即返回。后续 `read/wait/write/interrupt/kill` 用返回的 `output_until_seq` 和 `output_until_offset` 一起续读:前者是完整交付的最后一个分片,后者是下一分片中已交付的 UTF-8 字节位置。首次传 `since_offset=0` 开启分片内分页;不传 offset 时保持完整分片模式,小页装不下一个分片会明确提示调整限额。`last_seq` 只表示已经产生的输出,不能用于跳过未读内容。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0720101fe..f4f52f395 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -749,15 +749,15 @@ flowchart LR SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。 - 延迟导入不被接受为隐藏循环依赖的手段。 -### 10.1 2026-09-03 当前收口状态与后续边界 +### 10.1 2026-09-11 当前收口状态与后续边界 当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自 `tests/fixtures/architecture/`,更新基线前必须先审查语义变化: | 指标 | 当前值 | |---|---:| -| Python 模块 | 1001 | -| 内部导入边 | 8,493 | +| Python 模块 | 1004 | +| 内部导入边 | 8,513 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 0cb1d0452..1fe82ac3b 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1001 / 8,493 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与 AMLL 歌词模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1004 / 8,513 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,397 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,396 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | Ruff 历史诊断 | 527 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 16a493822..ec82ef4d2 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -552,6 +552,11 @@ PTY 会在写入之前拒绝 half-close,空 `write` 不代表 EOF,控制字 新增 `interrupt` 只发送一次平台支持的中断并返回 `signal/signal_sent`,不会升级强杀; `kill` 保留终止语义但提前拒绝无效信号。上述能力仍为内置管理员 Agent 工具,不扩大外部 MCP 目录。 +后台句柄按宿主用户和真实任务作用域隔离;相同对话的不同定时 `run_id` 也不会共享终端。 +正常对话可跨轮继续,清空/更换用户或定时运行收尾只回收自身进程。子任务仅在父任务明确提供 +`terminal_sessions` 时获得指定 `read/wait` 授权,不能凭句柄或任务文字访问其他终端。 +缺失、封闭或无权限均返回 `terminal_access_denied`,不会回显其他任务状态;宿主重启不恢复旧句柄。 + 后台命令的 `start` 新增 `yield_time_ms`(默认 250、上限 10000,0 不等待)。 `read/wait/write/interrupt/kill` 接受 `since_seq` 和 `since_offset`:一起传回上次响应的 `output_until_seq/output_until_offset`,后者表示下一分片内的 UTF-8 字节位置; diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 4eaecd605..a1932552c 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -95,7 +95,8 @@ to make the directory tree look symmetrical. | `app/agent/tools/result.py` | Pure interpretation of explicit tool outcomes and portable tool-image history; image observation copies never replace original user attachments | | `app/agent/api/arguments.py` | Canonical API request fingerprints from the generated operation schema and the executor's GET projection; no endpoint imports or live discovery | | `app/agent/shell.py` | Shared command interpreter, login mode, launch directory and subprocess text-encoding policy for run, pipe and PTY; preserves Windows default priority and UTF-8 behavior | -| `app/agent/terminal/` | `session.py` owns terminal state, input serialization, retained output and UTF-8 stream decoding; `manager.py` owns process launch, read/write/EOF, signals, paging and bounded shutdown. Package root contains no implementation exports | +| `app/agent/terminal/` | `ownership.py` owns host task identity, invocation context and lazy scope closure; `session.py` owns process state, input serialization and UTF-8 capture; `output.py` owns pure paging and bounded projections; `manager.py` owns launch, access grants and process lifecycle. Package root contains no implementation exports | +| `app/agent/middleware/terminal.py` | Explicit child terminal grants and per-invocation scope binding; cached child graphs never receive mutable task identities, and sharing never bypasses tool role permissions | | `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input | | `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization | | `app/agent/tools/impl/service.py` | Admin-only external MCP wrappers for downloader, media-server and database Skill scripts; synchronous scripts run only through the Agent blocking executor | @@ -1091,7 +1092,7 @@ driven workflow registration. | `app/agent/tasks.py` | Background prompt, scheduled task and heartbeat execution owner | | `app/agent/orchestrator.py` | Per-session `MoviePilotAgent` execution and LLM/tool/middleware orchestration only | | `app/agent/shell.py` | Agent command interpreter/login/directory and subprocess UTF-8 policy shared by run, pipe and PTY | -| `app/agent/terminal/` | Terminal session state and process/input/output lifecycle; lazy shutdown resolves the materialized manager module without importing it | +| `app/agent/terminal/` | Terminal ownership, explicit sharing and independent task cleanup; pure output paging stays outside the process manager, while lazy scope and global shutdown resolve only an already materialized manager | | `app/agent/loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` | | `app/agent/__init__.py` | Implementation-free package root; exact historical Agent symbols are supplied by the Compat overlay only, while host callers import `orchestrator.py` or the relevant owner directly | | `app/agent/llm/__init__.py` | Implementation-free package root; only the verified historical `LLMHelper` symbol is supplied by exact Compat routing | diff --git a/tests/conftest.py b/tests/conftest.py index 0a22ff573..bd9a7ae29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -706,3 +706,13 @@ def pytest_sessionfinish(session, exitstatus): raise RuntimeError("log writer did not converge") except Exception as err: _report_session_cleanup_error(session, "logger manager", err) + + +@pytest.fixture +def terminal_scope(): + """仅由终端合同测试显式选用的宿主身份,结束时还原上下文。""" + from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope + + scope = TerminalScope(user_id="terminal-test-owner", task_id="terminal-test", kind="test") + with bind_terminal_scope(scope): + yield scope diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index e1f88c9d6..34b95d527 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -164,7 +164,8 @@ "owners": { "_TerminalSessionManager._start_pipe_session": 3, "_TerminalSessionManager._start_pty_session": 2, - "_TerminalSessionManager.start": 1 + "_TerminalSessionManager._wait_for_change": 1, + "_TerminalSessionManager.start": 2 }, "target": "asyncio.create_task" }, @@ -182,7 +183,8 @@ }, "app/agent/tools/impl/execute_command.py:asyncio.create_task": { "owners": { - "ExecuteCommandTool._run_once": 3 + "ExecuteCommandTool._acquire_command_slot": 2, + "ExecuteCommandTool._run_once_with_scope": 4 }, "target": "asyncio.create_task" }, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 579971238..0b5dd5fc9 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8493, - "edge_sha256": "cf90c1e6dbf33bbb0f9c6fee9bc62f7cf5be26f70658da0cab394627913ee69d", + "edge_count": 8513, + "edge_sha256": "7871419d809ba1a220809e382a64428c4c7a28e7ead4107c62185cb2a00ae794", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -1534,6 +1534,7 @@ "app.agent.middleware.subagents -> app.agent.middleware", "app.agent.middleware.subagents -> app.agent.middleware.policy", "app.agent.middleware.subagents -> app.agent.middleware.summarization", + "app.agent.middleware.subagents -> app.agent.middleware.terminal", "app.agent.middleware.subagents -> app.agent.middleware.utils", "app.agent.middleware.subagents -> app.agent.middleware.vision", "app.agent.middleware.subagents -> app.agent.policy", @@ -1550,6 +1551,10 @@ "app.agent.middleware.summarization -> app.agent.middleware.usage", "app.agent.middleware.summarization -> app.runtime", "app.agent.middleware.summarization -> app.runtime.log", + "app.agent.middleware.terminal -> app.agent", + "app.agent.middleware.terminal -> app.agent.terminal", + "app.agent.middleware.terminal -> app.agent.terminal.manager", + "app.agent.middleware.terminal -> app.agent.terminal.ownership", "app.agent.middleware.usage -> app.runtime", "app.agent.middleware.usage -> app.runtime.log", "app.agent.middleware.vision -> app.agent", @@ -1590,6 +1595,8 @@ "app.agent.orchestrator -> app.agent.policy.sanitizer", "app.agent.orchestrator -> app.agent.prompt", "app.agent.orchestrator -> app.agent.runtime", + "app.agent.orchestrator -> app.agent.terminal", + "app.agent.orchestrator -> app.agent.terminal.ownership", "app.agent.orchestrator -> app.agent.tools", "app.agent.orchestrator -> app.agent.tools.catalog", "app.agent.orchestrator -> app.agent.tools.impl", @@ -1656,6 +1663,8 @@ "app.agent.session -> app.agent.contracts", "app.agent.session -> app.agent.memory", "app.agent.session -> app.agent.orchestrator", + "app.agent.session -> app.agent.terminal", + "app.agent.session -> app.agent.terminal.ownership", "app.agent.session -> app.application", "app.agent.session -> app.application.agent", "app.agent.session -> app.chain", @@ -1706,6 +1715,8 @@ "app.agent.terminal.manager -> app.agent", "app.agent.terminal.manager -> app.agent.shell", "app.agent.terminal.manager -> app.agent.terminal", + "app.agent.terminal.manager -> app.agent.terminal.output", + "app.agent.terminal.manager -> app.agent.terminal.ownership", "app.agent.terminal.manager -> app.agent.terminal.session", "app.agent.terminal.manager -> app.agent.tools", "app.agent.terminal.manager -> app.agent.tools.impl", @@ -1713,8 +1724,13 @@ "app.agent.terminal.manager -> app.runtime", "app.agent.terminal.manager -> app.runtime.log", "app.agent.terminal.manager -> app.runtime.settings", + "app.agent.terminal.output -> app.agent", + "app.agent.terminal.output -> app.agent.terminal", + "app.agent.terminal.output -> app.agent.terminal.session", "app.agent.terminal.session -> app.agent", "app.agent.terminal.session -> app.agent.shell", + "app.agent.terminal.session -> app.agent.terminal", + "app.agent.terminal.session -> app.agent.terminal.ownership", "app.agent.tools.base -> app.agent", "app.agent.tools.base -> app.agent.callback", "app.agent.tools.base -> app.agent.policy", @@ -1843,6 +1859,8 @@ "app.agent.tools.impl.execute_command -> app.agent.shell", "app.agent.tools.impl.execute_command -> app.agent.terminal", "app.agent.tools.impl.execute_command -> app.agent.terminal.manager", + "app.agent.tools.impl.execute_command -> app.agent.terminal.output", + "app.agent.tools.impl.execute_command -> app.agent.terminal.ownership", "app.agent.tools.impl.execute_command -> app.agent.tools", "app.agent.tools.impl.execute_command -> app.agent.tools.base", "app.agent.tools.impl.execute_command -> app.agent.tools.impl", @@ -1946,6 +1964,8 @@ "app.agent.tools.manager -> app.agent.policy.contracts", "app.agent.tools.manager -> app.agent.policy.orchestrator", "app.agent.tools.manager -> app.agent.policy.sanitizer", + "app.agent.tools.manager -> app.agent.terminal", + "app.agent.tools.manager -> app.agent.terminal.ownership", "app.agent.tools.manager -> app.agent.tools", "app.agent.tools.manager -> app.agent.tools.base", "app.agent.tools.manager -> app.agent.tools.catalog", @@ -9571,7 +9591,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1001, + "module_count": 1004, "modules": [ "app", "app.adapters", @@ -9661,6 +9681,7 @@ "app.agent.middleware.skills", "app.agent.middleware.subagents", "app.agent.middleware.summarization", + "app.agent.middleware.terminal", "app.agent.middleware.usage", "app.agent.middleware.utils", "app.agent.middleware.vision", @@ -9683,6 +9704,8 @@ "app.agent.tasks", "app.agent.terminal", "app.agent.terminal.manager", + "app.agent.terminal.output", + "app.agent.terminal.ownership", "app.agent.terminal.session", "app.agent.tools", "app.agent.tools.base", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index ba0669e9a..58d5946ee 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -342,7 +342,6 @@ "union-attr": 1 }, "app/agent/tools/manager.py": { - "assignment": 1, "attr-defined": 2, "type-arg": 1 }, diff --git a/tests/test_agent_command_io.py b/tests/test_agent_command_io.py index 4eb66b691..6bb16ac05 100644 --- a/tests/test_agent_command_io.py +++ b/tests/test_agent_command_io.py @@ -19,6 +19,8 @@ from app.agent.terminal.manager import _TerminalSessionManager from app.agent.tools.impl import execute_command as command_module from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOutput +pytestmark = pytest.mark.usefixtures("terminal_scope") + def _python(code: str) -> str: """使用当前解释器和逐参数转义,真实程序仅接触临时目录及标准流。""" @@ -219,7 +221,7 @@ async def test_lazy_shutdown_closes_the_materialized_terminal_package_owner(comm action="start", command=_python("print('READY', flush=True); input()"), use_pty=False, env={"SHELL": "/bin/sh"}, since_offset=0, )) - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] assert session.process is not None and session.process.returncode is None await close_materialized_terminal_sessions() assert manager._closed is True and manager._sessions == {} diff --git a/tests/test_agent_command_outcome.py b/tests/test_agent_command_outcome.py index 26bcf86b3..2cb293cd8 100644 --- a/tests/test_agent_command_outcome.py +++ b/tests/test_agent_command_outcome.py @@ -29,6 +29,8 @@ from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOut from app.agent.tools.manager import MoviePilotToolsManager from app.agent.tools.result import EXECUTION_OUTCOME_KEY, inspect_tool_result +pytestmark = pytest.mark.usefixtures("terminal_scope") + def _command(code: str) -> str: """固定使用当前虚拟环境解释器,并对每个 shell 参数独立转义。""" diff --git a/tests/test_agent_command_scope.py b/tests/test_agent_command_scope.py new file mode 100644 index 000000000..86750b747 --- /dev/null +++ b/tests/test_agent_command_scope.py @@ -0,0 +1,163 @@ +"""真实命令入口必须使用宿主作用域,工具参数和内部调用者不能冒领其他终端。""" + +import asyncio +import json +import shlex +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.agent.terminal import manager as terminal_module +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope, close_terminal_scope +from app.agent.tools.impl import execute_command as command_module +from app.agent.tools.impl.execute_command import ExecuteCommandTool +from app.agent.tools.manager import MoviePilotToolsManager + + +def _caller() -> MoviePilotToolsManager: + """仅替换目录/回执边界,使用真实内部调用入口、工具和终端管理器。""" + caller = MoviePilotToolsManager(user_id="owner") + tool = ExecuteCommandTool(session_id=caller.session_id, user_id="owner") + caller.get_strict_tool = lambda _name: tool + caller._ensure_policy_runtime = lambda: ( + SimpleNamespace(start=lambda **_kwargs: None), SimpleNamespace(agent_context={}), + ) + return caller + + +@pytest.mark.asyncio +async def test_unbound_tool_cannot_launch_or_claim_an_owner(monkeypatch): + """模型自行填写身份字段不能替代宿主,拒绝发生在获取进程管理器之前。""" + create = AsyncMock() + monkeypatch.setattr(command_module, "get_terminal_session_manager", create) + tool = ExecuteCommandTool(session_id="conversation", user_id="owner") + result = json.loads(await tool.run(action="start", command="echo not-started", owner="owner", task_id="task")) + assert result["code"] == "terminal_access_denied" + assert result["execution_outcome"] == "failed" + create.assert_not_called() + + +@pytest.mark.asyncio +async def test_internal_callers_own_distinct_terminals_and_close_independently(monkeypatch): + """同用户的两个内部调用者不能靠真实 handle 串读,关闭 A 不终止 B。""" + manager = _TerminalSessionManager() + monkeypatch.setattr(terminal_module, "terminal_session_manager", manager) + monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager) + first, second = _caller(), _caller() + assert first.session_id != second.session_id + command = shlex.join([sys.executable, "-u", "-c", "print('PRIVATE-OUTPUT', flush=True); input()"]) + try: + a = json.loads(await first.call_tool("execute_command", {"action": "start", "command": command, "use_pty": False})) + b = json.loads(await second.call_tool("execute_command", {"action": "start", "command": command, "use_pty": False})) + denied = json.loads(await second.call_tool("execute_command", {"action": "read", "session_id": a["session_id"]})) + assert denied["code"] == "terminal_access_denied" + assert "PRIVATE-OUTPUT" not in json.dumps(denied) + assert await asyncio.wait_for(first.close(), 5) + own = json.loads(await second.call_tool("execute_command", {"action": "read", "session_id": b["session_id"]})) + assert own["status"] == "running" + assert manager._sessions[b["session_id"]].process.returncode is None + assert await asyncio.wait_for(second.close(), 5) + finally: + await manager.close() + + +@pytest.mark.asyncio +async def test_nested_internal_call_keeps_current_host_identity(monkeypatch): + """图内转调工具管理器时继承当前任务,不能静默改成内部调用者的独立身份。""" + manager = _TerminalSessionManager() + monkeypatch.setattr(terminal_module, "terminal_session_manager", manager) + monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager) + caller = _caller() + host = TerminalScope(user_id="owner", task_id="scheduled-run-1", kind="scheduled") + try: + with bind_terminal_scope(host): + result = json.loads(await caller.call_tool("execute_command", {"action": "start", "command": "echo nested", "use_pty": False})) + assert manager._sessions[result["session_id"]].owner is host + denied = json.loads(await caller.call_tool("execute_command", {"action": "read", "session_id": result["session_id"]})) + assert denied["code"] == "terminal_access_denied" + finally: + await manager.close() + + +@pytest.mark.asyncio +async def test_run_scope_sealed_while_waiting_for_command_slot_never_starts_process(monkeypatch): + """一次性命令排队期间任务被关闭时,不能迟到创建子进程。""" + scope = TerminalScope(user_id="owner", task_id="queued-run", kind="scheduled") + slot = asyncio.Semaphore(0) + created = AsyncMock() + monkeypatch.setattr(command_module, "_command_semaphore", slot) + monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", created) + tool = ExecuteCommandTool(session_id="queued-run", user_id="owner") + + with bind_terminal_scope(scope): + pending = asyncio.create_task(tool.run(action="run", command="echo never")) + for _ in range(20): + if scope._active_runs: + break + await asyncio.sleep(0) + scope.seal() + result = json.loads(await asyncio.wait_for(pending, 2)) + assert result["code"] == "terminal_access_denied" + created.assert_not_awaited() + assert await scope.wait_runs() + + +@pytest.mark.asyncio +async def test_run_cancelled_while_waiting_for_command_slot_releases_waiter(monkeypatch): + """调用方取消并发槽等待时,不得留下迟到获取槽位的后台任务。""" + scope = TerminalScope(user_id="owner", task_id="cancelled-run", kind="scheduled") + slot = asyncio.Semaphore(0) + created = AsyncMock() + monkeypatch.setattr(command_module, "_command_semaphore", slot) + monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", created) + tool = ExecuteCommandTool(session_id="cancelled-run", user_id="owner") + + with bind_terminal_scope(scope): + pending = asyncio.create_task(tool.run(action="run", command="echo never")) + for _ in range(20): + if scope._active_runs: + break + await asyncio.sleep(0) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + assert await scope.wait_runs() + slot.release() + await asyncio.sleep(0) + created.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_scope_close_waits_for_real_process_and_returns_failed(monkeypatch): + """运行中的一次性命令响应封口并真实收尾,作用域不能提前报告成功。""" + manager = _TerminalSessionManager() + monkeypatch.setattr(terminal_module, "terminal_session_manager", manager) + started = asyncio.Event() + original_create = command_module.asyncio.create_subprocess_exec + + async def create(*args, **kwargs): + """观察真实 subprocess 创建边界,不替换子进程本身。""" + process = await original_create(*args, **kwargs) + started.set() + return process + + monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create) + scope = TerminalScope(user_id="owner", task_id="running-run", kind="scheduled") + tool = ExecuteCommandTool(session_id="running-run", user_id="owner") + command = shlex.join([sys.executable, "-u", "-c", "import time; print('READY', flush=True); time.sleep(30)"]) + try: + with bind_terminal_scope(scope): + running = asyncio.create_task(tool.run(action="run", command=command, timeout=60)) + await asyncio.wait_for(started.wait(), 5) + closing = asyncio.create_task(close_terminal_scope(scope)) + result = json.loads(await asyncio.wait_for(running, 10)) + assert result["success"] is False + assert result["status"] == "cancelled" + assert result["execution_outcome"] == "failed" + assert await asyncio.wait_for(closing, 2) + assert scope._active_runs == 0 + finally: + await manager.close() diff --git a/tests/test_agent_scheduled_tasks.py b/tests/test_agent_scheduled_tasks.py index 13fb53fde..70d9478e9 100644 --- a/tests/test_agent_scheduled_tasks.py +++ b/tests/test_agent_scheduled_tasks.py @@ -1029,6 +1029,7 @@ async def test_agent_manager_executes_task_with_broadcast_delivery( assert kwargs["reply_mode"] == ReplyMode.DISPATCH assert kwargs["allow_message_tools"] is True assert kwargs["wait_for_completion"] is True + assert kwargs["scheduled_run_id"] == AgentTaskOper().get(task.id).last_run_id assert "搜索示例电影是否已有资源" in kwargs["message"] post_message.assert_not_awaited() @@ -1215,8 +1216,8 @@ async def test_cached_agent_clears_channel_for_background_task() -> None: @pytest.mark.anyio -async def test_cached_agent_overwrites_channel_admin_with_explicit_false() -> None: - """复用会话 Agent 时,明确非管理员结论必须覆盖上一轮管理员身份。""" +async def test_cached_agent_overwrites_channel_admin_with_explicit_false(monkeypatch) -> None: + """同会话换用户必须清理旧实例,以新实例装配明确的非管理员身份。""" manager = AgentManager() agent = MoviePilotAgent( session_id="channel-admin-cached-session", @@ -1238,12 +1239,17 @@ async def test_cached_agent_overwrites_channel_admin_with_explicit_false() -> No is_channel_admin=False, ) + monkeypatch.setattr(MoviePilotAgent, "_process", AsyncMock(return_value="完成")) result = await manager._process_message_internal(task) + replacement = manager.active_agents[agent.session_id] assert result == "完成" - assert agent.user_id == "user-2" - assert agent.username == "admin" - assert agent.is_channel_admin is False + assert replacement is not agent + assert agent.user_id == "user-1" + assert agent._terminal_scope.closed is True + assert replacement.user_id == "user-2" + assert replacement.username == "admin" + assert replacement.is_channel_admin is False @pytest.mark.anyio diff --git a/tests/test_agent_shell.py b/tests/test_agent_shell.py index cf9684c1f..d47432bcb 100644 --- a/tests/test_agent_shell.py +++ b/tests/test_agent_shell.py @@ -27,6 +27,8 @@ from app.agent.tools.impl.execute_command import ExecuteCommandTool from app.agent.tools.impl.service import _run_service_script from app.schemas.agent import AgentMcpServerConfig +pytestmark = pytest.mark.usefixtures("terminal_scope") + def _finder(paths: dict[str, str]): """构造只返回测试声明命令路径的 which 替身。""" diff --git a/tests/test_agent_side_effect_boundaries.py b/tests/test_agent_side_effect_boundaries.py index 606493cd9..a44686a7e 100644 --- a/tests/test_agent_side_effect_boundaries.py +++ b/tests/test_agent_side_effect_boundaries.py @@ -22,10 +22,13 @@ from app.agent.policy.contracts import ( ) from app.agent.policy.orchestrator import DEFAULT_TOOL_POLICY_ORCHESTRATOR from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.ownership import current_terminal_scope from app.agent.terminal.session import _TerminalSession from app.agent.tools.base import MoviePilotTool from app.agent.tools.catalog import ToolCatalogSnapshot +pytestmark = pytest.mark.usefixtures("terminal_scope") + class _SlowWriteTool(MoviePilotTool): """模拟超时后外部写操作仍可能继续的工具。""" @@ -103,7 +106,7 @@ async def test_terminal_manager_close_terminates_running_pipe_session() -> None: command=_shell_command("import time; time.sleep(30)"), use_pty=False, ) - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] await manager.close() @@ -120,6 +123,7 @@ async def test_terminal_manager_close_waits_for_starting_session() -> None: start_entered = asyncio.Event() allow_start = asyncio.Event() session = _TerminalSession( + owner=current_terminal_scope(), session_id="term-starting", command="sleep", cwd=".", @@ -134,7 +138,7 @@ async def test_terminal_manager_close_waits_for_starting_session() -> None: return session manager._start_pipe_session = _start_session - manager._terminate_session = AsyncMock() + manager._terminate_session = AsyncMock(return_value=True) start_task = asyncio.create_task(manager.start(command="sleep", use_pty=False)) await start_entered.wait() @@ -172,6 +176,7 @@ async def test_terminal_manager_cancellation_terminates_unregistered_session() - release_registration = asyncio.Event() termination_started = asyncio.Event() session = _TerminalSession( + owner=current_terminal_scope(), session_id="term-cancelled", command="sleep", cwd=".", @@ -192,9 +197,10 @@ async def test_terminal_manager_cancellation_terminates_unregistered_session() - await registration_locked.wait() return session - async def _terminate_session(_session: _TerminalSession) -> None: + async def _terminate_session(_session: _TerminalSession) -> bool: """标记取消收尾已开始,避免用固定等待猜测执行顺序。""" termination_started.set() + return True lock_holder = asyncio.create_task(_hold_registration_lock()) manager._start_pipe_session = _start_session diff --git a/tests/test_agent_subagent_terminal_scope.py b/tests/test_agent_subagent_terminal_scope.py new file mode 100644 index 000000000..0a6c8ceee --- /dev/null +++ b/tests/test_agent_subagent_terminal_scope.py @@ -0,0 +1,396 @@ +"""子任务终端授权必须按真实调用隔离,缓存图和已知句柄不产生隐式权限。""" + +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 diff --git a/tests/test_agent_subagents.py b/tests/test_agent_subagents.py index 7ed7e377a..1d0901cb2 100644 --- a/tests/test_agent_subagents.py +++ b/tests/test_agent_subagents.py @@ -339,7 +339,7 @@ def test_control_tool_starts_tasks_concurrently_and_waits(): both_started = asyncio.Event() allow_finish = asyncio.Event() - async def _fake_run_task(self, *, description, subagent_type, task_id=None): + async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None): running_descriptions.append(description) if len(running_descriptions) == 2: both_started.set() @@ -407,7 +407,7 @@ def test_control_tool_pipeline_passes_previous_results_to_next_step(): ) calls = [] - async def _fake_run_task(self, *, description, subagent_type, task_id=None): + async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None): calls.append( { "description": description, @@ -480,7 +480,7 @@ def test_control_tool_pipeline_stops_after_failed_step(): calls = [] secret_marker = "subagent-runtime-secret-9042" - async def _fake_run_task(self, *, description, subagent_type, task_id=None): + async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None): calls.append(subagent_type) if subagent_type == "download-diagnostician": raise RuntimeError( @@ -543,7 +543,7 @@ def test_control_tool_pipeline_timeout_is_bounded_when_task_ignores_cancel(): release = asyncio.Event() cancelled = asyncio.Event() - async def _ignore_cancel(self, *, description, subagent_type, task_id=None): + async def _ignore_cancel(self, *, description, subagent_type, task_id=None, terminal_sessions=None): try: await asyncio.Future() except asyncio.CancelledError: @@ -591,7 +591,7 @@ def test_after_agent_cancels_unfinished_tasks(): ) task_started = asyncio.Event() - async def _fake_run_task(self, *, description, subagent_type, task_id=None): + async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None): task_started.set() await asyncio.Event().wait() diff --git a/tests/test_agent_terminal_cursor.py b/tests/test_agent_terminal_cursor.py index 09f5d2849..6653c776f 100644 --- a/tests/test_agent_terminal_cursor.py +++ b/tests/test_agent_terminal_cursor.py @@ -9,15 +9,19 @@ from typing import Any, Optional import pytest from app.agent.terminal import session as terminal_state -from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.output import TerminalOutputError +from app.agent.terminal.ownership import current_terminal_scope from app.agent.terminal.session import _TerminalSession from app.agent.tools.result import inspect_tool_result +pytestmark = pytest.mark.usefixtures("terminal_scope") + def _world(*, use_pty: bool = True) -> tuple[_TerminalSessionManager, _TerminalSession]: """仅登记内存记录,不创建 OS 进程,也不调用终止这些虚构 PID 的动作。""" manager = _TerminalSessionManager() - session = _TerminalSession(session_id="term-cursor-test", command="memory-only", cwd=".", pid=0, use_pty=use_pty) + session = _TerminalSession(owner=current_terminal_scope(), session_id="term-cursor-test", command="memory-only", cwd=".", pid=0, use_pty=use_pty) manager._sessions[session.session_id] = session return manager, session diff --git a/tests/test_agent_terminal_input.py b/tests/test_agent_terminal_input.py index 669220cea..6da7d9524 100644 --- a/tests/test_agent_terminal_input.py +++ b/tests/test_agent_terminal_input.py @@ -17,9 +17,13 @@ import pytest_asyncio from app.agent.shell import AgentShell from app.agent.terminal import manager as terminal -from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.output import TerminalOutputError +from app.agent.terminal.ownership import current_terminal_scope from app.agent.terminal.session import _TerminalSession +pytestmark = pytest.mark.usefixtures("terminal_scope") + def _command(code: str) -> str: """使用当前虚拟环境,POSIX exec 让被测程序直接拥有会话进程与信号处理器。""" @@ -88,7 +92,7 @@ async def test_pipe_last_input_and_half_close_deliver_all_bytes(manager: _Termin async def test_pty_half_close_rejects_before_input_and_keeps_output_readable(manager: _TerminalSessionManager) -> None: """不支持的组合不能先写入 BAD,也不能通过关闭 master 破坏输出。""" payload = await _ready(manager, "import sys,tty\ntty.setraw(0)\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read(1)\nprint('GOT:'+str(data[0]),flush=True)", use_pty=True) - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] descriptor = session.master_fd with pytest.raises(ValueError, match="PTY 不支持"): await manager.write(session_id=session.session_id, input_text="BAD", close_stdin=True, **_cursor(payload)) @@ -118,7 +122,7 @@ async def test_large_raw_pty_input_retries_short_writes_without_losing_tail( f"os.read({control_read},1)\nos.close({control_read})\ndata=sys.stdin.buffer.read({len(data)})\n" "print('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)") payload = await _ready(manager, code, use_pty=True) - descriptor = manager.get_session(payload["session_id"]).master_fd + descriptor = manager._sessions[payload["session_id"]].master_fd def observe_write(fd: int, content: Any) -> int: """只观察该测试 PTY 的真实写入计数与回压,不替换内核返回值。""" @@ -161,7 +165,7 @@ async def test_interrupt_calls_handler_once_and_program_remains_interactive(mana assert result["signal"] == "SIGINT" and result["signal_sent"] is True output, after = await _read_to(manager, result, "INTERRUPTED") assert output.count("INTERRUPTED") == 1 - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] assert session.status == "running" and session.kill_requested is False reply = await manager.write(session_id=session.session_id, input_text="quit\n", **_cursor(after)) output, final = await _read_to(manager, reply) @@ -173,7 +177,7 @@ async def test_interrupt_calls_handler_once_and_program_remains_interactive(mana async def test_invalid_signal_has_no_os_or_kill_intent_effect(value: Any, monkeypatch: pytest.MonkeyPatch) -> None: """未知信号、处理器常量和无效编号必须在任何状态变化之前拒绝。""" current = _TerminalSessionManager() - session = _TerminalSession(session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False) + session = _TerminalSession(owner=current_terminal_scope(), session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False) current._sessions[session.session_id] = session send = Mock() monkeypatch.setattr(current, "_send_signal", send) @@ -189,7 +193,7 @@ async def test_concurrent_pipe_write_then_close_serializes_final_segment( ) -> None: """真实管道首段尚未 drain 时,close 请求必须等待输入锁并完整交付末段。""" payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)") - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] writer = session.process.stdin original_drain = writer.drain entered, release = asyncio.Event(), asyncio.Event() @@ -237,7 +241,7 @@ async def test_concurrent_input_after_close_is_rejected_before_writer_receives_i await release.wait() writer = SimpleNamespace(write=values.append, drain=AsyncMock(), close=Mock(), wait_closed=wait_closed) - session = _TerminalSession(session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False, + session = _TerminalSession(owner=current_terminal_scope(), session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False, process=SimpleNamespace(stdin=writer)) current._sessions[session.session_id] = session close = asyncio.create_task(current.write(session_id=session.session_id, input_text="tail", close_stdin=True)) @@ -259,7 +263,7 @@ async def test_concurrent_input_after_close_is_rejected_before_writer_receives_i async def test_input_control_preserves_cursor_validation_and_action_output_error(manager: _TerminalSessionManager) -> None: """非法游标先于 stdin 动作,已发生 half-close 后的小页错误保留会话和输入事实。""" payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)") - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] with pytest.raises(TerminalOutputError): await manager.write(session_id=session.session_id, input_text="wrong", close_stdin=True, since_seq=999) assert session.stdin_closed is False @@ -274,7 +278,7 @@ async def test_windows_interrupt_uses_actual_break_event_without_terminate(monke """Windows 控制事件与终止 API 分开,仅报告实际调用的 CTRL_BREAK_EVENT。""" current = _TerminalSessionManager() process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock()) - session = _TerminalSession(session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) + session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) current._sessions[session.session_id] = session with monkeypatch.context() as scoped: scoped.setattr(terminal.os, "name", "nt") @@ -291,7 +295,7 @@ async def test_windows_missing_control_event_and_unmapped_kill_never_terminate(m """缺少控制事件或请求未映射的信号时明确失败,不能静默执行 terminate。""" current = _TerminalSessionManager() process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock()) - session = _TerminalSession(session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) + session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) current._sessions[session.session_id] = session with monkeypatch.context() as scoped: scoped.setattr(terminal.os, "name", "nt") @@ -338,4 +342,4 @@ async def test_pipe_shell_policy_metadata_matches_explicit_non_login_execution(m payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nsys.stdin.read()") if os.name == "posix": assert payload["shell"] == "/bin/sh" and payload["login"] is False - assert manager.get_session(payload["session_id"]).shell_policy is not None + assert manager._sessions[payload["session_id"]].shell_policy is not None diff --git a/tests/test_agent_terminal_output.py b/tests/test_agent_terminal_output.py index d669dd110..916b1c64e 100644 --- a/tests/test_agent_terminal_output.py +++ b/tests/test_agent_terminal_output.py @@ -12,9 +12,12 @@ from unittest.mock import AsyncMock import pytest import pytest_asyncio +from app.agent.terminal import manager as terminal_module from app.agent.terminal.manager import _TerminalSessionManager from app.agent.terminal.session import _TerminalSession +pytestmark = pytest.mark.usefixtures("terminal_scope") + _DEADLINE = 5 _INTERACTIVE = """import sys print('READY', flush=True) @@ -61,7 +64,7 @@ async def _start_ready(manager: _TerminalSessionManager, code: str = _INTERACTIV env={"PYTHONIOENCODING": "utf-8"}, ), timeout=_DEADLINE) assert "READY" in payload["output"] - session = manager.get_session(payload["session_id"]) + session = manager._sessions[payload["session_id"]] assert session.process is not None and session.process.returncode is None return payload, session @@ -197,7 +200,7 @@ async def test_new_output_wakes_wait_without_process_exit(monkeypatch, terminal_ async def test_output_between_empty_snapshot_and_await_is_not_lost(monkeypatch, terminal_manager): """同步插入恰好发生在空快照之后的输出,等待必须消费已经触发的旧通知事件。""" initial, session = await _start_ready(terminal_manager) - original_read = terminal_manager._read_payload + original_read = terminal_module.read_payload inserted = False def read(current: _TerminalSession, **kwargs: Any) -> dict[str, Any]: @@ -209,7 +212,7 @@ async def test_output_between_empty_snapshot_and_await_is_not_lost(monkeypatch, current.append_output("stdout", b"BETWEEN-CHECK-AND-WAIT\n") return payload - monkeypatch.setattr(terminal_manager, "_read_payload", read) + monkeypatch.setattr(terminal_module, "read_payload", read) payload = await asyncio.wait_for(terminal_manager.wait( session_id=session.session_id, timeout_ms=10000, **_cursor(initial), ), timeout=_DEADLINE) @@ -306,7 +309,7 @@ async def test_start_small_legacy_page_preserves_session_handle(terminal_manager payload = await asyncio.wait_for(terminal_manager.start( command=_command(_INTERACTIVE), use_pty=False, yield_time_ms=10000, max_bytes=1, ), timeout=_DEADLINE) - session = terminal_manager.get_session(payload["session_id"]) + session = terminal_manager._sessions[payload["session_id"]] assert session.process.returncode is None and payload["status"] == "running" assert payload["output"] == "" and _position(payload) == (0, 0) assert payload["output_error"]["code"] @@ -392,7 +395,7 @@ async def test_long_command_truncates_only_display_and_preserves_real_session(te payload = await asyncio.wait_for(terminal_manager.start( command=command, use_pty=False, yield_time_ms=10000, since_offset=0, max_output_chars=4096, ), timeout=_DEADLINE) - session = terminal_manager.get_session(payload["session_id"]) + session = terminal_manager._sessions[payload["session_id"]] assert session.command == command assert payload["command_truncated"] is True and payload["command_total_chars"] == len(command) assert len(json.dumps(payload["command"], ensure_ascii=False)) <= 1024 diff --git a/tests/test_agent_terminal_ownership.py b/tests/test_agent_terminal_ownership.py new file mode 100644 index 000000000..0d8081793 --- /dev/null +++ b/tests/test_agent_terminal_ownership.py @@ -0,0 +1,275 @@ +"""终端句柄归属、明确共享和关闭竞态的真实进程验收。""" + +import asyncio +import os +import shlex +import subprocess +import sys +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +from app.agent.terminal import manager as terminal +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.ownership import ( + TerminalAccessError, + TerminalScope, + bind_terminal_scope, + close_terminal_scope, + current_terminal_scope, + require_terminal_scope, +) + + +@pytest_asyncio.fixture +async def manager() -> AsyncIterator[_TerminalSessionManager]: + """每例仅拥有自己创建的进程;断言失败同样回收测试资源。""" + instance = _TerminalSessionManager() + try: + yield instance + finally: + await asyncio.wait_for(instance.close(), 15) + + +def _scope(user: str = "alice", task: str = "task") -> TerminalScope: + """相同可见字段仍创建不同的宿主任务代次。""" + return TerminalScope(user_id=user, task_id=task, kind="test") + + +async def _start(manager: _TerminalSessionManager, owner: TerminalScope, *, use_pty: bool = False) -> dict[str, Any]: + """启动受控程序,以真实输出 READY 确认它正在等待唯一输入。""" + code = ( + "import sys\n" + + ("import tty;tty.setraw(0)\n" if use_pty else "") + + "print('PRIVATE_READY',flush=True)\ndata=sys.stdin.buffer.read(1)\n" + + "print('GOT:'+str(data[0]),flush=True)\n" + ) + args = [sys.executable, "-u", "-c", code] + command = subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args) + options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {} + with bind_terminal_scope(owner): + payload = await manager.start(command=command, use_pty=use_pty, yield_time_ms=10000, **options) + assert "PRIVATE_READY" in payload["output"] + return payload + + +async def _finish(manager: _TerminalSessionManager, owner: TerminalScope, handle: str) -> str: + """只消费工具交付游标,确认真实进程读取字节后退出。""" + with bind_terminal_scope(owner): + payload = await manager.write(session_id=handle, input_text="Z") + output = payload["output"] + async with asyncio.timeout(5): + while not payload["output_complete"]: + payload = await manager.wait( + session_id=handle, since_seq=payload["output_until_seq"], + since_offset=payload["output_until_offset"], timeout_ms=1000, + ) + output += payload["output"] + assert payload["exit_code"] == 0 + return output + + +@pytest.mark.asyncio +@pytest.mark.parametrize("use_pty", [False, pytest.param(True, marks=pytest.mark.skipif(os.name != "posix", reason="PTY 仅 POSIX"))]) +@pytest.mark.parametrize("other_user", ["alice", "bob"]) +async def test_real_handle_is_not_authority(manager: _TerminalSessionManager, use_pty: bool, other_user: str) -> None: + """同名任务的新代次和另一用户均不能通过真实句柄读取、投递输入或控制进程。""" + owner, outsider = _scope(), _scope(other_user) + payload = await _start(manager, owner, use_pty=use_pty) + handle = payload["session_id"] + operations = [ + (manager.read, {}), (manager.wait, {"timeout_ms": 0}), + (manager.write, {"input_text": "BAD"}), + (manager.write, {"input_text": "", "close_stdin": True}), + (manager.interrupt, {}), (manager.kill, {}), + ] + with bind_terminal_scope(outsider): + for operation, options in operations: + with pytest.raises(TerminalAccessError) as actual: + await operation(session_id=handle, **options) + with pytest.raises(TerminalAccessError) as missing: + await operation(session_id="term_missing", **options) + assert str(actual.value) == str(missing.value) + assert "PRIVATE_READY" not in str(actual.value) + assert manager._sessions[handle].status == "running" + assert "GOT:90" in await _finish(manager, owner, handle) + + +@pytest.mark.asyncio +async def test_identity_and_empty_scope_are_explicit(manager: _TerminalSessionManager) -> None: + """绑定空上下文允许非终端调用,终端要求可信用户、任务和未封口代次。""" + outer = _scope() + with bind_terminal_scope(outer): + with bind_terminal_scope(None): + assert current_terminal_scope() is None + with pytest.raises(TerminalAccessError): + require_terminal_scope() + assert require_terminal_scope() is outer + for owner in [_scope(user=""), _scope(task=""), outer]: + if owner is outer: + owner.seal() + with bind_terminal_scope(owner), pytest.raises(TerminalAccessError): + await manager.start(command="echo must-not-start") + assert not manager._sessions and manager._starting == 0 + + +@pytest.mark.asyncio +async def test_share_is_atomic_specific_and_not_transitive(manager: _TerminalSessionManager) -> None: + """被授予子任务只可读取明确句柄,无效批量不产生部分授权且不能转授给兄弟。""" + owner, child, sibling = _scope(), _scope(task="child"), _scope(task="sibling") + handle = (await _start(manager, owner))["session_id"] + with bind_terminal_scope(owner), pytest.raises(TerminalAccessError): + manager.share(owner, child, {handle: frozenset({"read"}), "missing": frozenset({"read"})}) + assert child not in manager._grants + with bind_terminal_scope(owner): + manager.share(owner, child, {handle: frozenset({"read", "wait"})}) + with pytest.raises(TerminalAccessError): + manager.share(owner, _scope("bob"), {handle: frozenset({"read"})}) + with pytest.raises(TerminalAccessError): + manager.share(owner, sibling, {handle: frozenset({"start"})}) + with bind_terminal_scope(child): + assert "PRIVATE_READY" in (await manager.read(session_id=handle))["output"] + with pytest.raises(TerminalAccessError): + await manager.write(session_id=handle, input_text="BAD") + with pytest.raises(TerminalAccessError): + manager.share(child, sibling, {handle: frozenset({"read"})}) + assert await manager.close_owner(child) + assert manager._sessions[handle].status == "running" + assert "GOT:90" in await _finish(manager, owner, handle) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("close_parent", [False, True]) +async def test_long_wait_wakes_when_either_scope_seals(manager: _TerminalSessionManager, close_parent: bool) -> None: + """等待无输出时封住任一端,不必等满一分钟才反馈授权失效。""" + owner, child = _scope(), _scope(task="child") + payload = await _start(manager, owner) + with bind_terminal_scope(owner): + manager.share(owner, child, {payload["session_id"]: frozenset({"wait"})}) + with bind_terminal_scope(child): + pending = asyncio.create_task(manager.wait( + session_id=payload["session_id"], timeout_ms=60000, + since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"], + )) + await asyncio.sleep(0) + (owner if close_parent else child).seal() + with pytest.raises(TerminalAccessError): + await asyncio.wait_for(pending, 1) + + +@pytest.mark.asyncio +async def test_queued_input_rechecks_grant_after_lock(manager: _TerminalSessionManager) -> None: + """排队写入在取得输入锁之前已撤销时,不向父进程投递任何字节。""" + owner, child = _scope(), _scope(task="child") + handle = (await _start(manager, owner))["session_id"] + session = manager._sessions[handle] + with bind_terminal_scope(owner): + manager.share(owner, child, {handle: frozenset({"write"})}) + await session.input_lock.acquire() + with bind_terminal_scope(child): + pending = asyncio.create_task(manager.write(session_id=handle, input_text="BAD", close_stdin=True)) + await asyncio.sleep(0) + assert await manager.close_owner(child) + session.input_lock.release() + with pytest.raises(TerminalAccessError): + await pending + assert not session.stdin_closed and "GOT:90" in await _finish(manager, owner, handle) + + +@pytest.mark.asyncio +async def test_close_owner_during_start_retains_other_owner(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None: + """启动已预留但尚未登记时关闭任务,迟到进程被回收且另一个任务仍可继续。""" + owner, other = _scope(), _scope(task="other") + other_handle = (await _start(manager, other))["session_id"] + entered, release = asyncio.Event(), asyncio.Event() + original = manager._start_pipe_session + + async def delayed(*args: Any, **kwargs: Any) -> Any: + """把真实进程创建卡在预留之后,提供确定性的关闭交错。""" + entered.set() + await release.wait() + return await original(*args, **kwargs) + + monkeypatch.setattr(manager, "_start_pipe_session", delayed) + pending = asyncio.create_task(_start(manager, owner)) + await entered.wait() + closing = asyncio.create_task(manager.close_owner(owner)) + await asyncio.sleep(0) + assert owner.closed and not closing.done() + assert "GOT:90" in await _finish(manager, other, other_handle) + release.set() + with pytest.raises(TerminalAccessError): + await pending + assert await closing + assert not manager._owner_starts and manager._starting == 0 + assert all(session.owner is other for session in manager._sessions.values()) + + +@pytest.mark.asyncio +async def test_cancelled_start_keeps_reservation_until_process_reaped(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None: + """两次取消首次等待也不会丢失尚未返回的进程,清理等待启动完成后才释放预留。""" + owner = _scope() + entered, release = asyncio.Event(), asyncio.Event() + original = manager._start_pipe_session + created = [] + + async def delayed(*args: Any, **kwargs: Any) -> Any: + """记录实际已创建的子进程后,阻止句柄提前返回给 start。""" + session = await original(*args, **kwargs) + created.append(session) + entered.set() + await release.wait() + return session + + monkeypatch.setattr(manager, "_start_pipe_session", delayed) + pending = asyncio.create_task(_start(manager, owner)) + await entered.wait() + pending.cancel() + await asyncio.sleep(0) + pending.cancel() + await asyncio.sleep(0) + assert manager._starting == 1 and not pending.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await pending + assert not manager._sessions and manager._starting == 0 + assert created[0].process.returncode is not None + + +@pytest.mark.asyncio +async def test_close_nonconvergence_preserves_record_for_retry(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None: + """收尾任务尚未结束不能抹除 owner;重试在进程真实收尾后才确认完成。""" + owner = _scope() + handle = (await _start(manager, owner))["session_id"] + session = manager._sessions[handle] + with monkeypatch.context() as patch: + patch.setattr(manager, "_wait_for_exit", AsyncMock(return_value=False)) + patch.setattr(manager, "_send_signal", lambda *_args: None) + assert not await manager.close_owner(owner) + assert manager._sessions[handle] is session and session.owner is owner and owner.closed + assert session.status == "running" + assert await manager.close_owner(owner) + assert session.process.returncode is not None and handle not in manager._sessions + + +@pytest.mark.asyncio +async def test_scope_cleanup_is_lazy_and_new_manager_rejects_old_handle(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None: + """无终端作用域清理不加载实现,重建后的管理器不会凭旧句柄接管或重跑进程。""" + unused = _scope(task="unused") + with monkeypatch.context() as patch: + patch.delitem(sys.modules, "app.agent.terminal.manager") + assert await close_terminal_scope(unused) + assert "app.agent.terminal.manager" not in sys.modules + assert unused.closed + owner = _scope() + handle = (await _start(manager, owner))["session_id"] + fresh = _TerminalSessionManager() + with bind_terminal_scope(owner), pytest.raises(TerminalAccessError): + await fresh.read(session_id=handle) + assert not fresh._sessions + monkeypatch.setattr(terminal, "terminal_session_manager", manager) + assert await close_terminal_scope(owner) + assert handle not in manager._sessions diff --git a/tests/test_agent_terminal_scope.py b/tests/test_agent_terminal_scope.py new file mode 100644 index 000000000..ba1df3195 --- /dev/null +++ b/tests/test_agent_terminal_scope.py @@ -0,0 +1,322 @@ +"""验证真实 Agent 与会话 worker 的终端归属装配;模型、外部服务均停在调用边界。""" + +import asyncio +import os +import shlex +import subprocess +import sys +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, Mock + +import pytest +import pytest_asyncio + +from app.agent import orchestrator, session +from app.agent.manager import AgentManager +from app.agent.orchestrator import MoviePilotAgent +from app.agent.session import AgentManagerUnavailableError, _MessageTask +from app.agent.terminal import manager as terminal +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.ownership import ( + TerminalAccessError, + TerminalScope, + bind_terminal_scope, + current_terminal_scope, + require_terminal_scope, +) + + +@pytest.fixture +def closed_scopes(monkeypatch) -> list[TerminalScope]: + """只替换进程收口边界,保留真实的宿主 scope 和 worker 生命周期。""" + closed = [] + + async def close(scope: TerminalScope) -> bool: + """记录真实对象的封口,避免测试触及其他测试持有的全局终端。""" + scope.seal() + closed.append(scope) + return True + + monkeypatch.setattr(orchestrator, "close_terminal_scope", close) + monkeypatch.setattr(session, "close_terminal_scope", close) + return closed + + +@pytest_asyncio.fixture +async def manager(closed_scopes) -> AsyncIterator[AgentManager]: + """使用真实消息队列并为每个用例独立收口 worker,记忆边界保持无 I/O。""" + memory = AsyncMock() + memory.clear_memory = Mock() + owner = AgentManager(memory=memory) + owner._accepting_tasks = True + try: + yield owner + finally: + await owner.close() + + +@pytest.mark.asyncio +async def test_conversation_scope_survives_turn_and_graph_rebuild(monkeypatch, closed_scopes) -> None: + """正常多轮、图失效均复用同一身份,调用结束恢复上层异步上下文。""" + agent = MoviePilotAgent("conversation", user_id="alice") + seen = [] + + async def process(_message: str, **_kwargs) -> str: + """在推理入口读取实际 ContextVar,并使出执行权检验异步传播。""" + seen.append(require_terminal_scope()) + await asyncio.sleep(0) + assert require_terminal_scope() is seen[-1] + return "完成" + + monkeypatch.setattr(agent, "_process", process) + outer = TerminalScope("operator", "parent", "operator") + with bind_terminal_scope(outer): + assert await agent.process("启动") == "完成" + assert current_terminal_scope() is outer + assert await agent._invalidate_cached_agent() is True + assert await agent.process("继续") == "完成" + assert current_terminal_scope() is outer + assert seen == [agent._terminal_scope, agent._terminal_scope] + assert not closed_scopes + assert await agent.cleanup() is True + with pytest.raises(TerminalAccessError): + await agent.process("清理后不得重新启动") + + +@pytest.mark.asyncio +async def test_no_user_agent_can_reason_but_cannot_enter_terminal(monkeypatch) -> None: + """无可信用户的内部 Agent 可执行非终端逻辑,不能获得默认 api_user 身份。""" + agent = MoviePilotAgent("anonymous") + + async def process(_message: str, **_kwargs) -> str: + """在真正的 wrapper 中验证缺身份拒绝点。""" + assert current_terminal_scope().user_id == "" + with pytest.raises(TerminalAccessError): + require_terminal_scope() + return "无需终端" + + monkeypatch.setattr(agent, "_process", process) + assert await agent.process("纯推理") == "无需终端" + with pytest.raises(TerminalAccessError): + await agent.process("冒用", terminal_scope=TerminalScope("alice", "run", "scheduled")) + + +@pytest.mark.asyncio +async def test_cleanup_seals_all_scopes_and_retries_both_resource_owners(monkeypatch) -> None: + """子代理清理失败不能跳过终端;终端未收敛则保持精确归属对象供重试。""" + agent = MoviePilotAgent("conversation", user_id="alice") + scheduled = TerminalScope("alice", "run-1", "scheduled") + agent._scheduled_terminal_scopes.add(scheduled) + children = AsyncMock(side_effect=[False, True, True]) + monkeypatch.setattr(agent, "_invalidate_cached_agent", children) + calls = [] + + async def close(scope: TerminalScope) -> bool: + """检查任何第一次异步清理前全部身份均已同步封口。""" + assert agent._terminal_scope.closed and scheduled.closed + calls.append(scope) + return scope is agent._terminal_scope or children.await_count == 3 + + monkeypatch.setattr(orchestrator, "close_terminal_scope", close) + assert await agent.cleanup() is False + assert calls == [agent._terminal_scope, scheduled] + assert scheduled in agent._scheduled_terminal_scopes + assert await agent.cleanup() is False + assert await agent.cleanup() is True + assert not agent._scheduled_terminal_scopes + + +@pytest.mark.asyncio +async def test_worker_keeps_conversation_and_distinguishes_scheduled_runs( + manager, monkeypatch, closed_scopes, +) -> None: + """真实队列相同会话的交互轮次共享 owner,两次 scheduled run 各自独立收口。""" + seen = [] + + async def process(_self, message: str, **_kwargs) -> str: + """在生产 process wrapper 内记录当前实际任务身份。""" + seen.append(require_terminal_scope()) + return message + + monkeypatch.setattr(MoviePilotAgent, "_process", process) + for message, run_id in [("交互1", None), ("定时1", "run-1"), ("定时2", "run-2"), ("交互2", None)]: + assert await manager.process_message( + session_id="shared", user_id="alice", message=message, + wait_for_completion=True, scheduled_run_id=run_id, + ) == message + assert seen[0] is seen[3] + assert seen[1] is not seen[2] and seen[1] is not seen[0] + assert [scope.task_id for scope in seen[1:3]] == ["run-1", "run-2"] + assert [scope.kind for scope in seen[1:3]] == ["scheduled", "scheduled"] + assert seen[1].closed and seen[2].closed + assert not seen[0].closed + assert closed_scopes == seen[1:3] + assert not manager.active_agents["shared"]._scheduled_terminal_scopes + + +@pytest.mark.asyncio +async def test_cancelled_queued_run_is_sealed_and_never_enters_reasoning(manager, monkeypatch) -> None: + """取消等待中的定时 run 不影响当前交互推理,也不能稍后从队列复活。""" + started = asyncio.Event() + release = asyncio.Event() + messages = [] + + async def process(_self, message: str, **_kwargs) -> str: + """仅阻塞第一条交互请求,保留后续定时请求的真实排队窗口。""" + messages.append(message) + if message == "交互": + started.set() + await release.wait() + return message + + monkeypatch.setattr(MoviePilotAgent, "_process", process) + interactive = asyncio.create_task(manager.process_message( + session_id="shared", user_id="alice", message="交互", wait_for_completion=True, + )) + await asyncio.wait_for(started.wait(), 2) + scheduled = asyncio.create_task(manager.process_message( + session_id="shared", user_id="alice", message="取消的定时", scheduled_run_id="run-1", + wait_for_completion=True, + )) + await asyncio.sleep(0) + queued = manager._session_queues["shared"]._queue[0] + scheduled.cancel() + with pytest.raises(asyncio.CancelledError): + await scheduled + assert queued.terminal_scope.closed + assert not manager.active_agents["shared"]._terminal_scope.closed + release.set() + assert await interactive == "交互" + await asyncio.wait_for(manager._session_queues["shared"].join(), 2) + assert messages == ["交互"] + + +@pytest.mark.asyncio +async def test_cancel_active_wait_closes_same_scope_and_retains_failed_cleanup( + manager, monkeypatch, +) -> None: + """等待者取消先封活动 scope;不收敛时对象留在 Agent,worker 最终再尝试回收。""" + started = asyncio.Event() + release = asyncio.Event() + seen = [] + closes = [] + + async def process(_self, _message: str, **_kwargs) -> str: + """模拟已进入模型且稍后才结束的执行,不让 caller 取消传播到会话 worker。""" + seen.append(require_terminal_scope()) + started.set() + await release.wait() + with pytest.raises(TerminalAccessError): + require_terminal_scope() + return "收尾" + + async def close(scope: TerminalScope) -> bool: + """第一次终端收口失败,实际 worker 结束后再报告已收敛。""" + scope.seal() + closes.append(scope) + return release.is_set() + + monkeypatch.setattr(MoviePilotAgent, "_process", process) + monkeypatch.setattr(orchestrator, "close_terminal_scope", close) + scheduled = asyncio.create_task(manager.process_message( + session_id="shared", user_id="alice", message="定时", scheduled_run_id="run-1", + wait_for_completion=True, + )) + await asyncio.wait_for(started.wait(), 2) + scheduled.cancel() + with pytest.raises(asyncio.CancelledError): + await scheduled + agent = manager.active_agents["shared"] + assert seen[0].closed + assert closes == seen + assert seen[0] in agent._scheduled_terminal_scopes + assert not agent._terminal_scope.closed + release.set() + await asyncio.wait_for(manager._session_queues["shared"].join(), 2) + assert closes == [seen[0], seen[0]] + assert not agent._scheduled_terminal_scopes + + +@pytest.mark.asyncio +async def test_switch_user_retains_old_agent_until_cleanup_converges(manager, monkeypatch) -> None: + """同 session 换主体必须等待旧资源收口,不能只修改旧 Agent 的 user_id。""" + old = MoviePilotAgent("shared", user_id="alice") + manager.active_agents["shared"] = old + cleanup = AsyncMock(side_effect=[False, True]) + monkeypatch.setattr(old, "cleanup", cleanup) + process = AsyncMock(return_value="bob 完成") + monkeypatch.setattr(MoviePilotAgent, "_process", process) + task = _MessageTask(session_id="shared", user_id="bob", message="新用户") + with pytest.raises(AgentManagerUnavailableError): + await manager._process_message_internal(task) + assert manager.active_agents["shared"] is old + assert old.user_id == "alice" + process.assert_not_awaited() + assert await manager._process_message_internal(task) == "bob 完成" + replacement = manager.active_agents["shared"] + assert replacement is not old and replacement.user_id == "bob" + assert replacement._terminal_scope is not old._terminal_scope + + +@pytest.mark.asyncio +async def test_real_worker_terminal_survives_stop_and_other_user_cleanup(monkeypatch) -> None: + """真实 pipe 经生产 worker 装配 owner,停止推理保留进程,换主体清理才回收。""" + terminals = _TerminalSessionManager() + monkeypatch.setattr(terminal, "terminal_session_manager", terminals) + memory = AsyncMock() + memory.clear_memory = Mock() + owner = AgentManager(memory=memory) + owner._accepting_tasks = True + ready = asyncio.Event() + handles = [] + argv = [sys.executable, "-u", "-c", "import sys; print('READY', flush=True); sys.stdin.read()"] + command = subprocess.list2cmdline(argv) if os.name == "nt" else "exec " + shlex.join(argv) + + async def process(_self, message: str, **_kwargs) -> str: + """模型边界使用本地固定程序,其余执行、授权和资源回收均为真实实现。""" + if message == "启动并继续推理": + payload = await terminals.start( + command=command, use_pty=False, yield_time_ms=1000, since_offset=0, + ) + handles.append(payload["session_id"]) + ready.set() + await asyncio.Event().wait() + if message == "新用户": + return "新用户就绪" + return (await terminals.read(session_id=handles[0], since_seq=0, since_offset=0))["output"] + + monkeypatch.setattr(MoviePilotAgent, "_process", process) + execution = asyncio.create_task(owner.process_message( + session_id="alice-session", user_id="alice", message="启动并继续推理", wait_for_completion=True, + )) + try: + await asyncio.wait_for(ready.wait(), 5) + alice = owner.active_agents["alice-session"] + process_record = terminals._sessions[handles[0]] + assert await owner.stop_current_task("alice-session") is True + with pytest.raises(asyncio.CancelledError): + await execution + assert not alice._terminal_scope.closed + assert process_record.process.returncode is None + assert await alice._invalidate_cached_agent() is True + assert "READY" in await owner.process_message( + session_id="alice-session", user_id="alice", message="读取", wait_for_completion=True, + ) + with pytest.raises(TerminalAccessError): + await owner.process_message( + session_id="bob-session", user_id="bob", message="读取", wait_for_completion=True, + ) + await owner.clear_session("bob-session", "bob") + assert process_record.process.returncode is None + assert await owner.process_message( + session_id="alice-session", user_id="bob", message="新用户", wait_for_completion=True, + ) == "新用户就绪" + assert alice._terminal_scope.closed + assert process_record.process.returncode is not None + assert handles[0] not in terminals._sessions + finally: + if not execution.done(): + execution.cancel() + await asyncio.gather(execution, return_exceptions=True) + await owner.close() + await terminals.close() diff --git a/tests/test_agent_terminal_tool.py b/tests/test_agent_terminal_tool.py index 13d33267e..7918c8dac 100644 --- a/tests/test_agent_terminal_tool.py +++ b/tests/test_agent_terminal_tool.py @@ -12,11 +12,14 @@ from app.agent.middleware.output import ToolOutputMiddleware from app.agent.middleware.policy import AgentPolicyMiddleware from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.ownership import current_terminal_scope from app.agent.terminal.session import _TerminalSession from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS from app.agent.tools.impl import execute_command as command_module from app.agent.tools.impl.execute_command import ExecuteCommandInput, ExecuteCommandTool +pytestmark = pytest.mark.usefixtures("terminal_scope") + class _PageModel(FakeMessagesListChatModel): """只固定两轮工具协议,真实分页和外层预算均由生产实现执行。""" @@ -31,7 +34,7 @@ class _PageModel(FakeMessagesListChatModel): async def test_terminal_page_or_error_reaches_model_without_outer_truncation(monkeypatch, partial): """大转义正文保留真实消费游标,小页错误也必须保持失败状态及恢复参数。""" manager = _TerminalSessionManager() - session = _TerminalSession(session_id="term-page-test", command="completed command", cwd=".", pid=123456789, use_pty=False) + session = _TerminalSession(owner=current_terminal_scope(), session_id="term-page-test", command="completed command", cwd=".", pid=123456789, use_pty=False) text = "\\\n\"\t" * 20000 session.append_output("stdout", text.encode("utf-8")) session.mark_finished(0) diff --git a/tests/test_execute_command_tool.py b/tests/test_execute_command_tool.py index 7d41cdb7d..ba4e8af68 100644 --- a/tests/test_execute_command_tool.py +++ b/tests/test_execute_command_tool.py @@ -18,7 +18,7 @@ from app.agent.terminal.manager import _TerminalSessionManager from app.agent.tools.impl import execute_command as command_module from app.agent.tools.impl.execute_command import MAX_OUTPUT_PREVIEW_BYTES, ExecuteCommandTool -pytestmark = pytest.mark.asyncio +pytestmark = [pytest.mark.asyncio, pytest.mark.usefixtures("terminal_scope")] def _python_command(code: str) -> str: