feat(agent): isolate terminal task ownership

This commit is contained in:
jxxghp
2026-09-11 03:09:43 +08:00
parent 03ec668fe3
commit 66d288ffc2
36 changed files with 2270 additions and 404 deletions

View File

@@ -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.
</subagents>"""
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(

View File

@@ -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<terminal_sessions>\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</terminal_sessions>"
)
with bind_terminal_scope(child):
yield context
finally:
await close_terminal_scope(child)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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()

View File

@@ -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"

View File

@@ -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,

View File

@@ -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]:
"""

View File

@@ -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/<commit-sha>/`(不提交凭据),至少包含 `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/<commit-sha>/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`,直到下一轮补跑真实对比。

View File

@@ -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` 只表示已经产生的输出,不能用于跳过未读内容。

View File

@@ -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 |

View File

@@ -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 未进入包级覆盖率门禁 |

View File

@@ -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、上限 100000 不等待)。
`read/wait/write/interrupt/kill` 接受 `since_seq``since_offset`:一起传回上次响应的
`output_until_seq/output_until_offset`,后者表示下一分片内的 UTF-8 字节位置;

View File

@@ -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 |

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -342,7 +342,6 @@
"union-attr": 1
},
"app/agent/tools/manager.py": {
"assignment": 1,
"attr-defined": 2,
"type-arg": 1
},

View File

@@ -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 == {}

View File

@@ -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 参数独立转义。"""

View File

@@ -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()

View File

@@ -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

View File

@@ -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 替身。"""

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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不收敛时对象留在 Agentworker 最终再尝试回收。"""
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()

View File

@@ -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)

View File

@@ -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: