feat(security): harden host execution model with sandbox/direct controls
This commit introduces a substantial security and runtime architecture update for host mode, with three major goals: (1) enforce OS-level sandboxing by default, (2) support a controlled temporary direct-execution escape hatch for admin users, and (3) eliminate silent fallback behavior that could hide risk. Backend/runtime changes:\n- Added a unified host sandbox runner (macOS sandbox-exec / Linux bubblewrap+seccomp / Windows WSL2).\n- Converted host terminal creation to sandboxed interactive shells by default.\n- Kept run_command/run_python on the same execution policy and added host execution mode plumbing (sandbox|direct).\n- Added session-scoped direct mode with TTL auto-revert (default 10 minutes), configurable via environment.\n- Added execution mode APIs (GET/POST /api/execution-mode), host+admin gating, status propagation, and rate limiting.\n- Added runtime refresh hooks so tool calls honor TTL expiration and mode transitions consistently.\n- Removed docker->host silent fallback paths: docker startup/runtime failures now fail fast instead of degrading silently.\n- Added host sandbox prompt injection (host-only) to guide agent behavior under permission constraints. Configuration and policy changes:\n- Added HOST_EXECUTION_MODE_DEFAULT and HOST_EXECUTION_DIRECT_TTL_SECONDS.\n- Added HOST_SANDBOX_MACOS_WRITABLE_PATHS to support user-defined writable path allowlists.\n- Updated macOS sandbox profile generation to use minimal defaults (workspace + tmp + /dev/null) plus explicit allowlist extensions.\n- Updated .env.example documentation for new execution/sandbox controls. Frontend changes:\n- Extended permission popover to a two-column model (Permission + Execution Environment) for host admin sessions.\n- Added execution mode state/options to app state, fetching, and status synchronization flows.\n- Added execution mode switching action and user feedback toasts.\n- Kept warning emphasis via text styling only (removed warning border per UX request). Validation:\n- Python syntax checks passed for modified backend modules.\n- Existing smoke tests passed: python3 -m unittest test.test_server_refactor_smoke\n- Frontend production build passed: npm run build
This commit is contained in:
parent
71a2b4014e
commit
6ee8cda2a7
@ -41,8 +41,14 @@ TERMINAL_SANDBOX_BINDS=
|
||||
# 运行时路径及容器名称前缀
|
||||
TERMINAL_SANDBOX_BIN=docker
|
||||
TERMINAL_SANDBOX_NAME_PREFIX=agent-term
|
||||
# 启动失败时是否强制报错(1=强制容器,0=允许回退到宿主机)
|
||||
# 启动失败时是否强制报错(建议 1;当前实现中 docker 失败会直接报错)
|
||||
TERMINAL_SANDBOX_REQUIRE=0
|
||||
# 宿主机执行环境默认模式:sandbox / direct(建议 sandbox)
|
||||
HOST_EXECUTION_MODE_DEFAULT=sandbox
|
||||
# direct 模式自动回退秒数:600=10分钟,0 或 -1 表示不自动回退
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS=600
|
||||
# macOS 宿主机沙箱:额外可写白名单路径(逗号分隔,默认仅工作区+/tmp)
|
||||
HOST_SANDBOX_MACOS_WRITABLE_PATHS=
|
||||
# 注入到容器的额外环境变量(以 TERMINAL_SANDBOX_ENV_* 命名)
|
||||
# 例如:TERMINAL_SANDBOX_ENV_HTTP_PROXY=http://127.0.0.1:7890
|
||||
|
||||
|
||||
@ -23,6 +23,16 @@ def _parse_bindings(raw_value: str):
|
||||
return items
|
||||
|
||||
|
||||
def _parse_paths(raw_value: str):
|
||||
items = []
|
||||
for chunk in (raw_value or "").split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
items.append(chunk)
|
||||
return items
|
||||
|
||||
|
||||
_env_prefix = "TERMINAL_SANDBOX_ENV_"
|
||||
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
|
||||
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
|
||||
@ -43,6 +53,11 @@ TERMINAL_SANDBOX_REQUIRE = os.environ.get("TERMINAL_SANDBOX_REQUIRE", "0") not i
|
||||
LINUX_SAFETY = os.environ.get("LINUX_SAFETY", "0") not in {"0", "false", "False"}
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900"))
|
||||
MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8"))
|
||||
HOST_EXECUTION_MODE_DEFAULT = os.environ.get("HOST_EXECUTION_MODE_DEFAULT", "sandbox").strip().lower()
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS = int(os.environ.get("HOST_EXECUTION_DIRECT_TTL_SECONDS", "600"))
|
||||
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
|
||||
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAX_TERMINALS",
|
||||
@ -69,4 +84,7 @@ __all__ = [
|
||||
"LINUX_SAFETY",
|
||||
"TOOLBOX_TERMINAL_IDLE_SECONDS",
|
||||
"MAX_ACTIVE_USER_CONTAINERS",
|
||||
"HOST_EXECUTION_MODE_DEFAULT",
|
||||
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
|
||||
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
||||
]
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# core/main_terminal.py - 主终端(集成对话持久化)
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set, TYPE_CHECKING
|
||||
|
||||
try:
|
||||
@ -15,6 +16,8 @@ try:
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
MCP_TOOLS_ENABLED,
|
||||
HOST_EXECUTION_MODE_DEFAULT,
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -32,6 +35,8 @@ except ImportError:
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
MCP_TOOLS_ENABLED,
|
||||
HOST_EXECUTION_MODE_DEFAULT,
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
@ -199,9 +204,12 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
self.skill_strict_run_command_background_enabled: bool = False
|
||||
self.default_permission_mode: str = "unrestricted"
|
||||
self.current_permission_mode: str = "unrestricted"
|
||||
self.host_execution_mode: str = "sandbox"
|
||||
self.host_execution_direct_until: Optional[float] = None
|
||||
# 当前生效的启用 skills(用于强约束判定)
|
||||
self.enabled_skill_ids: Set[str] = set()
|
||||
self.apply_personalization_preferences()
|
||||
self._init_host_execution_mode()
|
||||
|
||||
# 新增:自动开始新对话
|
||||
self._ensure_conversation()
|
||||
@ -231,6 +239,57 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
else:
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
|
||||
def _init_host_execution_mode(self):
|
||||
default_mode = str(HOST_EXECUTION_MODE_DEFAULT or "sandbox").strip().lower()
|
||||
self.host_execution_mode = "direct" if default_mode == "direct" else "sandbox"
|
||||
self.host_execution_direct_until = None
|
||||
self._apply_execution_mode_to_runtime()
|
||||
|
||||
def _apply_execution_mode_to_runtime(self):
|
||||
mode = self.get_execution_mode()
|
||||
if getattr(self, "terminal_ops", None):
|
||||
self.terminal_ops.set_host_execution_mode(mode)
|
||||
if getattr(self, "terminal_manager", None):
|
||||
self.terminal_manager.set_host_execution_mode(mode)
|
||||
|
||||
def _refresh_execution_mode_by_ttl(self):
|
||||
if self.host_execution_mode != "direct":
|
||||
return
|
||||
until = self.host_execution_direct_until
|
||||
if not until:
|
||||
return
|
||||
if time.time() >= float(until):
|
||||
self.host_execution_mode = "sandbox"
|
||||
self.host_execution_direct_until = None
|
||||
self._apply_execution_mode_to_runtime()
|
||||
|
||||
def get_execution_mode(self) -> str:
|
||||
self._refresh_execution_mode_by_ttl()
|
||||
return "direct" if self.host_execution_mode == "direct" else "sandbox"
|
||||
|
||||
def get_execution_mode_state(self) -> Dict:
|
||||
mode = self.get_execution_mode()
|
||||
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
|
||||
return {
|
||||
"mode": mode,
|
||||
"default_mode": "direct" if str(HOST_EXECUTION_MODE_DEFAULT).lower() == "direct" else "sandbox",
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"direct_until": self.host_execution_direct_until,
|
||||
}
|
||||
|
||||
def set_execution_mode(self, mode: str) -> Dict:
|
||||
normalized = str(mode or "").strip().lower()
|
||||
if normalized not in {"sandbox", "direct"}:
|
||||
raise ValueError("无效执行环境,仅支持 sandbox / direct")
|
||||
self.host_execution_mode = normalized
|
||||
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
|
||||
if normalized == "direct":
|
||||
self.host_execution_direct_until = None if ttl_seconds in (0, -1) else (time.time() + max(1, ttl_seconds))
|
||||
else:
|
||||
self.host_execution_direct_until = None
|
||||
self._apply_execution_mode_to_runtime()
|
||||
return self.get_execution_mode_state()
|
||||
|
||||
def _is_host_mode(self) -> bool:
|
||||
"""判定当前是否运行在宿主机模式,用于豁免配额等限制。"""
|
||||
if self.container_session and getattr(self.container_session, "mode", None) != "docker":
|
||||
|
||||
@ -101,6 +101,11 @@ class MainTerminalContextMixin:
|
||||
"approval": "批准",
|
||||
}
|
||||
|
||||
_EXECUTION_MODE_LABEL = {
|
||||
"sandbox": "沙箱",
|
||||
"direct": "完全访问权限",
|
||||
}
|
||||
|
||||
def _build_permission_mode_message(self) -> Optional[str]:
|
||||
"""根据当前权限模式构建权限说明消息(从模板读取并替换占位符)"""
|
||||
template = self.load_prompt("permission_mode")
|
||||
@ -144,6 +149,42 @@ class MainTerminalContextMixin:
|
||||
detailed_rules=detailed_rules,
|
||||
)
|
||||
|
||||
def _build_execution_mode_message(self) -> Optional[str]:
|
||||
"""根据当前执行环境模式构建提示消息。"""
|
||||
# 仅宿主机模式注入;Docker 模式不需要该提示。
|
||||
try:
|
||||
if not getattr(self, "_is_host_mode", lambda: False)():
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
template = self.load_prompt("execution_mode")
|
||||
if not template:
|
||||
return None
|
||||
state = {}
|
||||
if hasattr(self, "get_execution_mode_state"):
|
||||
try:
|
||||
state = self.get_execution_mode_state() or {}
|
||||
except Exception:
|
||||
state = {}
|
||||
mode = str(state.get("mode") or "sandbox").strip().lower()
|
||||
mode_label = self._EXECUTION_MODE_LABEL.get(mode, mode)
|
||||
if mode == "sandbox":
|
||||
rules = (
|
||||
"- 所有命令默认在系统 OS 沙箱中执行\n"
|
||||
"- 若命令因系统权限或目录访问限制失败,请先给出替代方案(只读检查、分步验证);若该操作确属必须,再提示用户切换为“完全访问权限”或让用户在本地授权白名单后重试"
|
||||
)
|
||||
else:
|
||||
rules = (
|
||||
"- 当前为宿主机直接执行模式(完全访问权限)\n"
|
||||
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
||||
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
||||
)
|
||||
return template.format(
|
||||
execution_mode=mode,
|
||||
execution_mode_label=mode_label,
|
||||
rules=rules,
|
||||
)
|
||||
|
||||
def build_context(self) -> Dict:
|
||||
"""构建主终端上下文"""
|
||||
# 读取记忆
|
||||
@ -241,6 +282,9 @@ class MainTerminalContextMixin:
|
||||
permission_mode_message = self._build_permission_mode_message()
|
||||
if permission_mode_message:
|
||||
messages.append({"role": "system", "content": permission_mode_message})
|
||||
execution_mode_message = self._build_execution_mode_message()
|
||||
if execution_mode_message:
|
||||
messages.append({"role": "system", "content": execution_mode_message})
|
||||
|
||||
if self.tool_category_states.get("sub_agent", True):
|
||||
sub_agent_prompt = self.load_prompt("sub_agent_guidelines").strip()
|
||||
|
||||
@ -554,6 +554,13 @@ class MainTerminalToolsExecutionMixin:
|
||||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||||
"""处理工具调用(添加参数预检查和改进错误处理)"""
|
||||
logger.info("[handle_tool_call] 工具调用开始: tool_name=%s, arguments=%s", tool_name, arguments)
|
||||
try:
|
||||
if hasattr(self, "_refresh_execution_mode_by_ttl"):
|
||||
self._refresh_execution_mode_by_ttl()
|
||||
if hasattr(self, "_apply_execution_mode_to_runtime"):
|
||||
self._apply_execution_mode_to_runtime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 导入字符限制配置
|
||||
from config import (
|
||||
|
||||
@ -332,6 +332,8 @@ class WebTerminal(MainTerminal):
|
||||
"run_mode": self.run_mode,
|
||||
"model_key": getattr(self, "model_key", None),
|
||||
"permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted",
|
||||
"execution_mode": self.get_execution_mode_state() if hasattr(self, "get_execution_mode_state") else {"mode": "sandbox"},
|
||||
"execution_mode_enabled": bool(self._is_host_mode()) and getattr(self, "user_role", "user") == "admin",
|
||||
"has_images": getattr(self.context_manager, "has_images", False),
|
||||
"has_videos": getattr(self.context_manager, "has_videos", False),
|
||||
"context": {
|
||||
|
||||
181
modules/host_sandbox_runner.py
Normal file
181
modules/host_sandbox_runner.py
Normal file
@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
try:
|
||||
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
|
||||
except Exception:
|
||||
HOST_SANDBOX_MACOS_WRITABLE_PATHS = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxPlan:
|
||||
command: List[str]
|
||||
env: Dict[str, str]
|
||||
cwd: Optional[str] = None
|
||||
seccomp_bpf_path: Optional[str] = None
|
||||
|
||||
|
||||
class HostSandboxError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _truthy(name: str, default: str = "1") -> bool:
|
||||
return os.environ.get(name, default).strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def host_sandbox_enabled() -> bool:
|
||||
return _truthy("HOST_SANDBOX_ENABLED", "1")
|
||||
|
||||
|
||||
def build_host_sandbox_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return _build_macos_plan(command, work_path, env)
|
||||
if system == "Linux":
|
||||
return _build_linux_plan(command, work_path, env)
|
||||
if system == "Windows":
|
||||
return _build_windows_plan(command, work_path, env)
|
||||
raise HostSandboxError(f"不支持的宿主机系统: {system}")
|
||||
|
||||
def build_host_sandbox_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return _build_macos_shell_plan(work_path, env)
|
||||
if system == "Linux":
|
||||
return _build_linux_shell_plan(work_path, env)
|
||||
if system == "Windows":
|
||||
return _build_windows_shell_plan(work_path, env)
|
||||
raise HostSandboxError(f"不支持的宿主机系统: {system}")
|
||||
|
||||
|
||||
def _build_macos_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
sandbox_exec = shutil.which("sandbox-exec")
|
||||
if not sandbox_exec:
|
||||
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝执行宿主机命令。")
|
||||
profile = _macos_profile_for_workspace(work_path)
|
||||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||||
|
||||
def _build_macos_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
sandbox_exec = shutil.which("sandbox-exec")
|
||||
if not sandbox_exec:
|
||||
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝启动宿主机沙箱终端。")
|
||||
profile = _macos_profile_for_workspace(work_path)
|
||||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-i"]
|
||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||||
|
||||
def _macos_profile_for_workspace(work_path: Path) -> str:
|
||||
workspace = str(work_path.resolve())
|
||||
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
|
||||
for raw in HOST_SANDBOX_MACOS_WRITABLE_PATHS or []:
|
||||
try:
|
||||
expanded = str(Path(raw).expanduser().resolve())
|
||||
except Exception:
|
||||
continue
|
||||
if expanded not in writable_paths:
|
||||
writable_paths.append(expanded)
|
||||
write_rules: list[str] = []
|
||||
for entry in writable_paths:
|
||||
if entry == "/dev/null":
|
||||
write_rules.append('(literal "/dev/null")')
|
||||
else:
|
||||
write_rules.append(f'(subpath "{entry}")')
|
||||
write_expr = " ".join(write_rules)
|
||||
return (
|
||||
'(version 1) '
|
||||
'(deny default) '
|
||||
'(allow sysctl-read) '
|
||||
'(allow process*) '
|
||||
'(allow network*) '
|
||||
'(allow file-read*) '
|
||||
f'(allow file-write* {write_expr})'
|
||||
)
|
||||
|
||||
|
||||
def _build_linux_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
bwrap = shutil.which("bwrap")
|
||||
if not bwrap:
|
||||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝执行宿主机命令。")
|
||||
|
||||
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
|
||||
if not seccomp_bpf:
|
||||
raise HostSandboxError("Linux 缺少 HOST_SANDBOX_LINUX_SECCOMP_BPF,拒绝执行宿主机命令。")
|
||||
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
|
||||
if not seccomp_path.exists():
|
||||
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
||||
shell_cmd = ["/bin/bash", "-lc", command]
|
||||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
||||
|
||||
def _build_linux_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
bwrap = shutil.which("bwrap")
|
||||
if not bwrap:
|
||||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝启动宿主机沙箱终端。")
|
||||
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
|
||||
if not seccomp_bpf:
|
||||
raise HostSandboxError("Linux 缺少 HOST_SANDBOX_LINUX_SECCOMP_BPF,拒绝启动宿主机沙箱终端。")
|
||||
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
|
||||
if not seccomp_path.exists():
|
||||
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
||||
shell_cmd = ["/bin/bash", "-i"]
|
||||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
||||
|
||||
def _build_linux_common_plan(
|
||||
work_path: Path,
|
||||
env: Dict[str, str],
|
||||
shell_cmd: List[str],
|
||||
seccomp_path: Path,
|
||||
) -> SandboxPlan:
|
||||
bwrap = shutil.which("bwrap")
|
||||
if not bwrap:
|
||||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap)。")
|
||||
sandbox_root = str(work_path.resolve())
|
||||
cmd = [
|
||||
bwrap,
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--unshare-all",
|
||||
"--share-net",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--bind",
|
||||
sandbox_root,
|
||||
sandbox_root,
|
||||
"--chdir",
|
||||
sandbox_root,
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--seccomp",
|
||||
"__SECCOMP_FD__",
|
||||
*shell_cmd,
|
||||
]
|
||||
return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path))
|
||||
|
||||
|
||||
def _build_windows_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
wsl = shutil.which("wsl.exe")
|
||||
if not wsl:
|
||||
raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝执行宿主机命令。")
|
||||
work = shlex.quote(str(work_path))
|
||||
shell = f"cd {work} && {command}"
|
||||
cmd = [wsl, "bash", "-lc", shell]
|
||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||||
|
||||
def _build_windows_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||||
wsl = shutil.which("wsl.exe")
|
||||
if not wsl:
|
||||
raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝启动宿主机沙箱终端。")
|
||||
work = shlex.quote(str(work_path))
|
||||
shell = f"cd {work} && exec bash -i"
|
||||
cmd = [wsl, "bash", "-lc", shell]
|
||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||||
@ -15,6 +15,11 @@ from collections import deque
|
||||
import shutil
|
||||
import uuid
|
||||
import codecs
|
||||
from modules.host_sandbox_runner import (
|
||||
HostSandboxError,
|
||||
build_host_sandbox_shell_plan,
|
||||
host_sandbox_enabled,
|
||||
)
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
@ -149,6 +154,7 @@ class PersistentTerminal:
|
||||
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
|
||||
self.sandbox_options = sandbox_defaults
|
||||
self.sandbox_required = bool(self.sandbox_options.get("require"))
|
||||
self.allow_direct_host_execution = bool(self.sandbox_options.get("allow_direct_host_execution", False))
|
||||
self.sandbox_container_name = None
|
||||
self.execution_mode = "host"
|
||||
self.using_container = False
|
||||
@ -168,12 +174,8 @@ class PersistentTerminal:
|
||||
process = self._start_docker_terminal()
|
||||
except Exception as exc:
|
||||
message = f"容器终端启动失败: {exc}"
|
||||
if self.sandbox_required:
|
||||
print(f"{OUTPUT_FORMATS['error']} {message}")
|
||||
return False
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机终端。")
|
||||
process = None
|
||||
selected_mode = "host"
|
||||
print(f"{OUTPUT_FORMATS['error']} {message}")
|
||||
return False
|
||||
if process is None:
|
||||
process = self._start_host_terminal()
|
||||
selected_mode = "host"
|
||||
@ -230,6 +232,57 @@ class PersistentTerminal:
|
||||
|
||||
def _start_host_terminal(self):
|
||||
"""启动宿主机终端"""
|
||||
if self.allow_direct_host_execution:
|
||||
return self._start_plain_host_terminal()
|
||||
if not host_sandbox_enabled():
|
||||
raise RuntimeError("宿主机沙箱已禁用(HOST_SANDBOX_ENABLED=0),拒绝启动 host 终端。")
|
||||
self.using_container = False
|
||||
self._owns_container = False
|
||||
self.is_windows = sys.platform == "win32"
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
|
||||
try:
|
||||
plan = build_host_sandbox_shell_plan(self.working_dir, env)
|
||||
cmd_args = plan.command
|
||||
pass_fds = ()
|
||||
seccomp_fd = None
|
||||
if plan.seccomp_bpf_path:
|
||||
seccomp_fd = os.open(plan.seccomp_bpf_path, os.O_RDONLY)
|
||||
cmd_args = [str(seccomp_fd) if token == "__SECCOMP_FD__" else token for token in cmd_args]
|
||||
pass_fds = (seccomp_fd,)
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd_args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=plan.cwd or str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=plan.env,
|
||||
pass_fds=pass_fds
|
||||
)
|
||||
finally:
|
||||
if seccomp_fd is not None:
|
||||
try:
|
||||
os.close(seccomp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self.shell_command = "host-sandbox-shell"
|
||||
self.is_windows = False
|
||||
return process
|
||||
except HostSandboxError as exc:
|
||||
raise RuntimeError(str(exc))
|
||||
except FileNotFoundError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 无法找到宿主机沙箱终端运行时")
|
||||
return None
|
||||
|
||||
def _start_plain_host_terminal(self):
|
||||
self.using_container = False
|
||||
self._owns_container = False
|
||||
self.is_windows = sys.platform == "win32"
|
||||
@ -239,41 +292,22 @@ class PersistentTerminal:
|
||||
else:
|
||||
shell_cmd = shell_cmd or os.environ.get('SHELL', '/bin/bash')
|
||||
self.shell_command = shell_cmd
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
try:
|
||||
if self.is_windows:
|
||||
env['CHCP'] = '65001'
|
||||
process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
else:
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
return process
|
||||
except FileNotFoundError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 无法找到终端程序: {shell_cmd}")
|
||||
return None
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
return process
|
||||
|
||||
def _start_docker_terminal(self):
|
||||
"""启动或连接容器化终端。"""
|
||||
|
||||
@ -137,6 +137,7 @@ class TerminalManager:
|
||||
|
||||
# 终端工厂(跨平台支持)
|
||||
self.factory = TerminalFactory()
|
||||
self.host_execution_mode: str = "sandbox"
|
||||
|
||||
def _apply_container_session(self, session: Optional["ContainerHandle"]):
|
||||
"""根据容器句柄调整执行模式。"""
|
||||
@ -151,6 +152,7 @@ class TerminalManager:
|
||||
def _build_sandbox_options(self) -> Dict:
|
||||
"""构造当前终端应使用的沙箱参数。"""
|
||||
options = dict(self.sandbox_options)
|
||||
options["allow_direct_host_execution"] = self.sandbox_mode == "host" and self.host_execution_mode == "direct"
|
||||
if self.container_session and self.container_session.mode == "docker":
|
||||
options["container_name"] = self.container_session.container_name
|
||||
options["mount_path"] = self.container_session.mount_path
|
||||
@ -158,6 +160,16 @@ class TerminalManager:
|
||||
options.pop("container_name", None)
|
||||
return options
|
||||
|
||||
def set_host_execution_mode(self, mode: str) -> None:
|
||||
normalized = str(mode or "").strip().lower()
|
||||
target = "direct" if normalized == "direct" else "sandbox"
|
||||
if self.host_execution_mode == target:
|
||||
return
|
||||
self.host_execution_mode = target
|
||||
if self.sandbox_mode == "host" and self.terminals:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 执行环境已切换为 {target},正在关闭现有终端会话。")
|
||||
self.close_all()
|
||||
|
||||
@staticmethod
|
||||
def _same_container(a: Optional["ContainerHandle"], b: Optional["ContainerHandle"]) -> bool:
|
||||
if a is b:
|
||||
|
||||
@ -33,6 +33,11 @@ except ImportError:
|
||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||
)
|
||||
from modules.toolbox_container import ToolboxContainer
|
||||
from modules.host_sandbox_runner import (
|
||||
HostSandboxError,
|
||||
build_host_sandbox_plan,
|
||||
host_sandbox_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from modules.user_container_manager import ContainerHandle
|
||||
@ -52,6 +57,11 @@ class TerminalOperator:
|
||||
self.container_session: Optional["ContainerHandle"] = container_session
|
||||
# 记录 TerminalManager 引用,便于 CLI 场景复用同一容器
|
||||
self._terminal_manager: Optional["TerminalManager"] = None
|
||||
self.host_execution_mode: str = "sandbox"
|
||||
|
||||
def set_host_execution_mode(self, mode: str) -> None:
|
||||
normalized = str(mode or "").strip().lower()
|
||||
self.host_execution_mode = "direct" if normalized == "direct" else "sandbox"
|
||||
|
||||
def _reset_toolbox(self):
|
||||
"""强制关闭并重建工具终端,保证每次命令/脚本运行独立环境。"""
|
||||
@ -131,7 +141,31 @@ class TerminalOperator:
|
||||
|
||||
def _detect_preinstalled_python(self) -> Optional[str]:
|
||||
"""尝试定位预装虚拟环境的 python 可执行文件。"""
|
||||
# 允许显式指定可执行文件(优先级最高)
|
||||
explicit_python = os.environ.get("AGENT_PYTHON_BIN")
|
||||
if explicit_python:
|
||||
p = Path(explicit_python).expanduser()
|
||||
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
|
||||
try:
|
||||
return str(p.resolve())
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
candidates = []
|
||||
if sys.platform == "darwin":
|
||||
# 优先使用非 shim 的系统 Python 路径(避免 /usr/bin/python3 xcode shim)
|
||||
mac_python_candidates = [
|
||||
"/Library/Developer/CommandLineTools/usr/bin/python3",
|
||||
"/opt/homebrew/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
]
|
||||
for raw_py in mac_python_candidates:
|
||||
p = Path(raw_py)
|
||||
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
|
||||
try:
|
||||
return str(p.resolve())
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
# 环境变量优先(Dockerfile 已设置 AGENT_TOOLBOX_VENV=/opt/agent-venv)
|
||||
env_venv = os.environ.get("AGENT_TOOLBOX_VENV")
|
||||
@ -162,30 +196,48 @@ class TerminalOperator:
|
||||
|
||||
def _detect_system_python(self) -> str:
|
||||
"""在系统 PATH 中探测可用的 Python3 可执行文件。"""
|
||||
commands_to_try = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
commands_to_try = ["python", "py", "python3"]
|
||||
else:
|
||||
commands_to_try = ["python3", "python"]
|
||||
|
||||
commands_to_try = ["python", "py", "python3"] if sys.platform == "win32" else ["python3", "python"]
|
||||
for cmd in commands_to_try:
|
||||
if shutil.which(cmd):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cmd, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout + result.stderr
|
||||
if "Python 3" in output or "Python 2" not in output:
|
||||
return cmd
|
||||
except Exception:
|
||||
continue
|
||||
resolved = shutil.which(cmd)
|
||||
if not resolved:
|
||||
continue
|
||||
if self._is_blocked_python_candidate(resolved):
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[resolved, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout + result.stderr
|
||||
if "Python 3" in output or "Python 2" not in output:
|
||||
return resolved
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return "python" if sys.platform == "win32" else "python3"
|
||||
fallback = "python" if sys.platform == "win32" else "python3"
|
||||
fallback_resolved = shutil.which(fallback) or fallback
|
||||
if sys.platform == "darwin" and self._is_blocked_python_candidate(fallback_resolved):
|
||||
raise RuntimeError(
|
||||
"未找到可用的 Python3 可执行文件:检测到 /usr/bin/python3 (xcode shim) 在沙箱中不可用。"
|
||||
"请设置 AGENT_PYTHON_BIN 或安装非 shim 的 python3。"
|
||||
)
|
||||
return fallback
|
||||
|
||||
def _is_blocked_python_candidate(self, candidate: str) -> bool:
|
||||
"""过滤已知在当前沙箱下不可靠的 Python 候选。"""
|
||||
if sys.platform != "darwin":
|
||||
return False
|
||||
try:
|
||||
resolved = str(Path(candidate).expanduser().resolve())
|
||||
except Exception:
|
||||
resolved = candidate
|
||||
# macOS 的 /usr/bin/python3 常为 xcode shim,会调用 xcodebuild 并触发 /dev/null 拦截
|
||||
if resolved == "/usr/bin/python3":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_python_env(self, python_path: str) -> Dict[str, str]:
|
||||
"""构造与预装虚拟环境匹配的环境变量(仅作用于宿主机子进程)。"""
|
||||
@ -477,15 +529,49 @@ class TerminalOperator:
|
||||
env.update(self._python_env)
|
||||
|
||||
if use_shell:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(work_path),
|
||||
shell=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
use_host_sandbox = self.host_execution_mode != "direct"
|
||||
if use_host_sandbox and host_sandbox_enabled():
|
||||
plan = build_host_sandbox_plan(command, work_path, env)
|
||||
cmd_args, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
|
||||
plan.command,
|
||||
plan.seccomp_bpf_path,
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=plan.cwd,
|
||||
env=plan.env,
|
||||
start_new_session=True,
|
||||
pass_fds=pass_fds,
|
||||
)
|
||||
finally:
|
||||
if seccomp_fd is not None:
|
||||
try:
|
||||
os.close(seccomp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
elif use_host_sandbox:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "error",
|
||||
"error": "宿主机命令执行被拒绝:HOST_SANDBOX_ENABLED=0",
|
||||
"output": "",
|
||||
"return_code": -1,
|
||||
"timeout": timeout,
|
||||
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
||||
}
|
||||
else:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(work_path),
|
||||
shell=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*exec_cmd,
|
||||
@ -511,6 +597,24 @@ class TerminalOperator:
|
||||
stdout_task = asyncio.create_task(_read_stream(process.stdout, stdout_buf))
|
||||
stderr_task = asyncio.create_task(_read_stream(process.stderr, stderr_buf))
|
||||
|
||||
async def _finish_reader_tasks(force: bool = False) -> None:
|
||||
"""收口 reader 任务,避免因后台子进程持续占用管道而卡死。"""
|
||||
join_timeout = 0.8 if force else 3.0
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(stdout_task, stderr_task, return_exceptions=True),
|
||||
timeout=join_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
stdout_task.cancel()
|
||||
stderr_task.cancel()
|
||||
try:
|
||||
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=timeout)
|
||||
@ -546,22 +650,23 @@ class TerminalOperator:
|
||||
pass
|
||||
raise
|
||||
|
||||
# 确保读取协程结束
|
||||
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
||||
# 确保读取协程结束(超时场景强制收口,避免永久等待 EOF)
|
||||
await _finish_reader_tasks(force=timed_out)
|
||||
|
||||
# 兜底再读一次,防止剩余缓冲未被读取
|
||||
try:
|
||||
remaining_out = await process.stdout.read()
|
||||
if remaining_out:
|
||||
stdout_buf.append(remaining_out)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
remaining_err = await process.stderr.read()
|
||||
if remaining_err:
|
||||
stderr_buf.append(remaining_err)
|
||||
except Exception:
|
||||
pass
|
||||
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
|
||||
if not timed_out:
|
||||
try:
|
||||
remaining_out = await asyncio.wait_for(process.stdout.read(), timeout=0.2)
|
||||
if remaining_out:
|
||||
stdout_buf.append(remaining_out)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
remaining_err = await asyncio.wait_for(process.stderr.read(), timeout=0.2)
|
||||
if remaining_err:
|
||||
stderr_buf.append(remaining_err)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stdout = b"".join(stdout_buf)
|
||||
stderr = b"".join(stderr_buf)
|
||||
@ -601,6 +706,16 @@ class TerminalOperator:
|
||||
if stderr_text:
|
||||
response["stderr"] = stderr_text
|
||||
return response
|
||||
except HostSandboxError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "error",
|
||||
"error": f"宿主机沙箱不可用,拒绝执行: {str(exc)}",
|
||||
"output": "",
|
||||
"return_code": -1,
|
||||
"timeout": timeout,
|
||||
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
@ -812,3 +927,11 @@ class TerminalOperator:
|
||||
if self.process and self.process.returncode is None:
|
||||
self.process.kill()
|
||||
print(f"{OUTPUT_FORMATS['warning']} 进程已终止")
|
||||
@staticmethod
|
||||
def _materialize_seccomp_fd(plan_command: list[str], seccomp_path: Optional[str]) -> tuple[list[str], tuple[int, ...], Optional[int]]:
|
||||
if not seccomp_path:
|
||||
return plan_command, tuple(), None
|
||||
seccomp_fd = os.open(seccomp_path, os.O_RDONLY)
|
||||
fd_num = seccomp_fd
|
||||
cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command]
|
||||
return cmd, (fd_num,), seccomp_fd
|
||||
|
||||
@ -96,6 +96,8 @@ class UserContainerManager:
|
||||
self._stats_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._stats_log_path.exists():
|
||||
self._stats_log_path.touch()
|
||||
if self.sandbox_mode == "docker":
|
||||
self._assert_docker_runtime_ready()
|
||||
|
||||
# 用户要求:每次启动程序时自动关闭本项目相关容器,避免“程序退出后容器残留”。
|
||||
# 默认开启;如确实需要保留容器,可设置 CLEANUP_PROJECT_CONTAINERS_ON_START=0。
|
||||
@ -286,11 +288,7 @@ class UserContainerManager:
|
||||
|
||||
docker_path = shutil.which(self.sandbox_bin or "docker")
|
||||
if not docker_path:
|
||||
message = f"未找到容器运行时 {self.sandbox_bin}"
|
||||
if self.require:
|
||||
raise RuntimeError(message)
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机执行。")
|
||||
return self._host_handle(username, workspace)
|
||||
raise RuntimeError(f"未找到容器运行时 {self.sandbox_bin}")
|
||||
|
||||
if not self.image:
|
||||
raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置,无法启动容器。")
|
||||
@ -345,10 +343,7 @@ class UserContainerManager:
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip() or "容器启动失败"
|
||||
if self.require:
|
||||
raise RuntimeError(message)
|
||||
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机。")
|
||||
return self._host_handle(username, workspace)
|
||||
raise RuntimeError(message)
|
||||
|
||||
container_id = result.stdout.strip() or None
|
||||
print(f"{OUTPUT_FORMATS['success']} 启动用户容器: {container_name} ({username})")
|
||||
@ -370,6 +365,25 @@ class UserContainerManager:
|
||||
mount_path=workspace,
|
||||
)
|
||||
|
||||
def _assert_docker_runtime_ready(self):
|
||||
docker_path = shutil.which(self.sandbox_bin or "docker")
|
||||
if not docker_path:
|
||||
raise RuntimeError(f"Docker 模式启动失败:未找到容器运行时 {self.sandbox_bin}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[docker_path, "info"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=8,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise RuntimeError(f"Docker 模式启动失败:无法访问 Docker daemon: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr or result.stdout or "").strip() or "docker info 返回非零状态"
|
||||
raise RuntimeError(f"Docker 模式启动失败:{message}")
|
||||
|
||||
def _kill_container(self, container_name: Optional[str], docker_bin: Optional[str]):
|
||||
if not container_name or not docker_bin:
|
||||
return
|
||||
|
||||
4
prompts/execution_mode.txt
Normal file
4
prompts/execution_mode.txt
Normal file
@ -0,0 +1,4 @@
|
||||
## 执行环境:{execution_mode_label}({execution_mode})
|
||||
|
||||
### 当前规则
|
||||
{rules}
|
||||
@ -43,6 +43,7 @@ UPLOAD_FOLDER_NAME = "user_upload"
|
||||
chat_bp = Blueprint('chat', __name__)
|
||||
|
||||
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
|
||||
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
|
||||
|
||||
@chat_bp.route('/api/thinking-mode', methods=['POST'])
|
||||
@api_login_required
|
||||
@ -635,6 +636,47 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.route('/api/execution-mode', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
||||
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
||||
state = terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else {"mode": "sandbox"}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"enabled": can_manage,
|
||||
"state": state,
|
||||
"options": EXECUTION_MODE_OPTIONS,
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.route('/api/execution-mode', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("execution_mode_switch", 20, 60, scope="user")
|
||||
def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
||||
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
||||
if not can_manage:
|
||||
return jsonify({"success": False, "error": "仅宿主机管理员可切换执行环境"}), 403
|
||||
data = request.get_json() or {}
|
||||
target_mode = str(data.get("mode") or "").strip().lower()
|
||||
if target_mode not in EXECUTION_MODE_OPTIONS:
|
||||
return jsonify({"success": False, "error": "无效执行环境,仅支持 sandbox / direct"}), 400
|
||||
try:
|
||||
state = terminal.set_execution_mode(target_mode)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc), "message": "切换执行环境失败"}), 500
|
||||
status = terminal.get_status()
|
||||
socketio.emit('status_update', status, room=f"user_{username}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"state": state,
|
||||
"options": EXECUTION_MODE_OPTIONS,
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
@ -293,6 +293,9 @@
|
||||
:current-permission-mode="currentPermissionMode"
|
||||
:permission-menu-open="permissionMenuOpen"
|
||||
:permission-options="permissionModeOptions"
|
||||
:execution-mode-enabled="executionModeEnabled"
|
||||
:current-execution-mode="currentExecutionMode"
|
||||
:execution-mode-options="executionModeOptions"
|
||||
:current-context-tokens="currentContextTokens"
|
||||
:versioning-enabled="versioningEnabled"
|
||||
:runtime-queued-messages="runtimeQueuedMessages"
|
||||
@ -324,6 +327,7 @@
|
||||
@open-review="openReviewDialog"
|
||||
@toggle-permission-menu="togglePermissionMenu"
|
||||
@change-permission-mode="changePermissionMode"
|
||||
@change-execution-mode="changeExecutionMode"
|
||||
@open-versioning-dialog="openVersioningDialog"
|
||||
@guide-runtime-message="handleGuideRuntimeMessage"
|
||||
@delete-runtime-message="handleDeleteRuntimeMessage"
|
||||
|
||||
@ -374,6 +374,7 @@ export const conversationMethods = {
|
||||
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
|
||||
await this.fetchAndDisplayHistory();
|
||||
this.fetchPermissionMode();
|
||||
this.fetchExecutionMode();
|
||||
await this.fetchVersioningStatus(conversationId, { silent: true });
|
||||
await this.maybePromptVersioningMismatch(result.versioning);
|
||||
this.fetchPendingToolApprovals();
|
||||
|
||||
@ -142,6 +142,17 @@ export const resourceMethods = {
|
||||
if (status && typeof status.permission_mode === 'string') {
|
||||
this.currentPermissionMode = status.permission_mode;
|
||||
}
|
||||
if (status?.execution_mode && typeof status.execution_mode === 'object') {
|
||||
if (typeof status.execution_mode.mode === 'string') {
|
||||
this.currentExecutionMode = status.execution_mode.mode;
|
||||
}
|
||||
if (typeof status.execution_mode.ttl_seconds === 'number') {
|
||||
this.executionModeTtlSeconds = status.execution_mode.ttl_seconds;
|
||||
}
|
||||
this.executionModeDirectUntil = status.execution_mode.direct_until ?? null;
|
||||
this.executionModeEnabled =
|
||||
typeof status.execution_mode_enabled === 'boolean' ? status.execution_mode_enabled : true;
|
||||
}
|
||||
if (status && typeof status.has_images !== 'undefined') {
|
||||
this.conversationHasImages = !!status.has_images;
|
||||
}
|
||||
|
||||
@ -1120,6 +1120,11 @@ export const uiMethods = {
|
||||
const hit = options.find((item) => item.value === mode);
|
||||
return hit ? hit.label : mode || '未知';
|
||||
},
|
||||
getExecutionModeLabel(mode) {
|
||||
const options = Array.isArray(this.executionModeOptions) ? this.executionModeOptions : [];
|
||||
const hit = options.find((item) => item.value === mode);
|
||||
return hit ? hit.label : mode || '未知';
|
||||
},
|
||||
|
||||
togglePermissionMenu() {
|
||||
if (!this.isConnected) {
|
||||
@ -1171,6 +1176,42 @@ export const uiMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
async changeExecutionMode(mode) {
|
||||
const target = String(mode || '').trim().toLowerCase();
|
||||
if (!this.executionModeEnabled || !target || target === this.currentExecutionMode) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/execution-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: target })
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.message || payload?.error || '切换执行环境失败');
|
||||
}
|
||||
const state = payload?.state || {};
|
||||
this.currentExecutionMode = state.mode || target;
|
||||
this.executionModeDirectUntil = state.direct_until ?? null;
|
||||
this.executionModeTtlSeconds =
|
||||
typeof state.ttl_seconds === 'number' ? state.ttl_seconds : this.executionModeTtlSeconds;
|
||||
this.uiPushToast({
|
||||
title: '执行环境已切换',
|
||||
message: `当前执行环境:${this.getExecutionModeLabel(this.currentExecutionMode)}`,
|
||||
type: this.currentExecutionMode === 'direct' ? 'warning' : 'success',
|
||||
duration: 1800
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error || '切换执行环境失败');
|
||||
this.uiPushToast({
|
||||
title: '切换执行环境失败',
|
||||
message: msg,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async handleSwitchPermissionToUnrestricted(approvalId) {
|
||||
await this.changePermissionMode('unrestricted');
|
||||
if (approvalId) {
|
||||
@ -1193,6 +1234,27 @@ export const uiMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchExecutionMode() {
|
||||
try {
|
||||
const response = await fetch('/api/execution-mode');
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload?.success) {
|
||||
return;
|
||||
}
|
||||
this.executionModeEnabled = !!payload.enabled;
|
||||
const state = payload.state || {};
|
||||
if (typeof state.mode === 'string') {
|
||||
this.currentExecutionMode = state.mode;
|
||||
}
|
||||
this.executionModeDirectUntil = state.direct_until ?? null;
|
||||
if (typeof state.ttl_seconds === 'number') {
|
||||
this.executionModeTtlSeconds = state.ttl_seconds;
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPendingToolApprovals() {
|
||||
if (!this.currentConversationId) {
|
||||
this.pendingToolApprovals = [];
|
||||
@ -2432,6 +2494,7 @@ export const uiMethods = {
|
||||
this.thinkingMode = !!statusData.thinking_mode;
|
||||
this.applyStatusSnapshot(statusData);
|
||||
this.fetchPermissionMode();
|
||||
this.fetchExecutionMode();
|
||||
this.fetchPendingToolApprovals();
|
||||
// 立即更新配额和运行模式,避免等待其他慢接口
|
||||
this.fetchUsageQuota();
|
||||
|
||||
@ -97,6 +97,10 @@ export function dataState() {
|
||||
modelMenuOpen: false,
|
||||
permissionMenuOpen: false,
|
||||
currentPermissionMode: 'unrestricted',
|
||||
executionModeEnabled: false,
|
||||
currentExecutionMode: 'sandbox',
|
||||
executionModeDirectUntil: null,
|
||||
executionModeTtlSeconds: 600,
|
||||
versioningHostMode: false,
|
||||
hostWorkspaces: [],
|
||||
currentHostWorkspaceId: '',
|
||||
@ -139,6 +143,18 @@ export function dataState() {
|
||||
description: '保持当前默认行为,不额外拦截'
|
||||
}
|
||||
],
|
||||
executionModeOptions: [
|
||||
{
|
||||
value: 'sandbox',
|
||||
label: '沙箱',
|
||||
description: '所有指令会在系统沙箱中执行'
|
||||
},
|
||||
{
|
||||
value: 'direct',
|
||||
label: '完全访问权限',
|
||||
description: '所有指令会在宿主机直接执行'
|
||||
}
|
||||
],
|
||||
pendingToolApprovals: [],
|
||||
decidingApprovalIds: [],
|
||||
imageEntries: [],
|
||||
|
||||
@ -181,18 +181,39 @@
|
||||
<span>权限:{{ currentPermissionLabel }}</span>
|
||||
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
|
||||
</button>
|
||||
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
|
||||
<button
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentPermissionMode }"
|
||||
@click="$emit('change-permission-mode', option.value)"
|
||||
>
|
||||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
</button>
|
||||
<div v-if="permissionMenuOpen" class="permission-switcher__menu permission-switcher__menu--split">
|
||||
<div class="permission-switcher__group">
|
||||
<div class="permission-switcher__group-title">权限</div>
|
||||
<button
|
||||
v-for="option in permissionOptions"
|
||||
:key="`permission-${option.value}`"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentPermissionMode }"
|
||||
@click="$emit('change-permission-mode', option.value)"
|
||||
>
|
||||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="executionModeEnabled" class="permission-switcher__group">
|
||||
<div class="permission-switcher__group-title">执行环境</div>
|
||||
<button
|
||||
v-for="option in executionModeOptions"
|
||||
:key="`execution-${option.value}`"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentExecutionMode }"
|
||||
@click="$emit('change-execution-mode', option.value)"
|
||||
>
|
||||
<span
|
||||
class="permission-switcher__item-label"
|
||||
:class="{ 'permission-switcher__item-label--warn': option.value === 'direct' }"
|
||||
>{{ option.label }}</span
|
||||
>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -269,6 +290,7 @@ const emit = defineEmits([
|
||||
'open-review',
|
||||
'toggle-permission-menu',
|
||||
'change-permission-mode',
|
||||
'change-execution-mode',
|
||||
'open-versioning-dialog',
|
||||
'guide-runtime-message',
|
||||
'delete-runtime-message',
|
||||
@ -319,6 +341,9 @@ const props = defineProps<{
|
||||
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
|
||||
permissionMenuOpen: boolean;
|
||||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||||
executionModeEnabled?: boolean;
|
||||
currentExecutionMode?: 'sandbox' | 'direct';
|
||||
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
|
||||
currentContextTokens: number;
|
||||
versioningEnabled?: boolean;
|
||||
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
|
||||
|
||||
@ -265,6 +265,27 @@ body[data-theme='dark'] {
|
||||
box-shadow: var(--theme-shadow-mid);
|
||||
}
|
||||
|
||||
.permission-switcher__menu--split {
|
||||
width: 560px;
|
||||
max-width: min(560px, calc(100vw - 40px));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.permission-switcher__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.permission-switcher__group-title {
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.permission-switcher__caret {
|
||||
font-size: 14px;
|
||||
color: var(--claude-text-secondary);
|
||||
@ -308,6 +329,10 @@ body[data-theme='dark'] {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.permission-switcher__item-label--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.permission-switcher {
|
||||
left: 20px;
|
||||
@ -326,6 +351,11 @@ body[data-theme='dark'] {
|
||||
width: min(250px, calc(100vw - 40px));
|
||||
max-width: 250px;
|
||||
}
|
||||
.permission-switcher__menu--split {
|
||||
width: min(560px, calc(100vw - 40px));
|
||||
max-width: min(560px, calc(100vw - 40px));
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.stadium-shell {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user