From 6ee8cda2a7b9de476599e692ae324e5343671f93 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 10 May 2026 19:19:01 +0800 Subject: [PATCH] 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 --- .env.example | 8 +- config/terminal.py | 18 ++ core/main_terminal.py | 59 +++++ core/main_terminal_parts/context.py | 44 ++++ core/main_terminal_parts/tools_execution.py | 7 + core/web_terminal.py | 2 + modules/host_sandbox_runner.py | 181 +++++++++++++++ modules/persistent_terminal.py | 112 +++++---- modules/terminal_manager.py | 12 + modules/terminal_ops.py | 215 ++++++++++++++---- modules/user_container_manager.py | 32 ++- prompts/execution_mode.txt | 4 + server/chat.py | 42 ++++ static/src/App.vue | 4 + static/src/app/methods/conversation.ts | 1 + static/src/app/methods/resources.ts | 11 + static/src/app/methods/ui.ts | 63 +++++ static/src/app/state.ts | 16 ++ static/src/components/input/InputComposer.vue | 49 +++- .../styles/components/input/_composer.scss | 30 +++ 20 files changed, 803 insertions(+), 107 deletions(-) create mode 100644 modules/host_sandbox_runner.py create mode 100644 prompts/execution_mode.txt diff --git a/.env.example b/.env.example index 49d921a..243a0c7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/config/terminal.py b/config/terminal.py index 35c0b9f..69653e9 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -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", ] diff --git a/core/main_terminal.py b/core/main_terminal.py index 6cfe589..3a6dca9 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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": diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index 2980d87..42d72c5 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -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() diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 3e3963e..a104673 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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 ( diff --git a/core/web_terminal.py b/core/web_terminal.py index e52bf2b..92f67cc 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -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": { diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py new file mode 100644 index 0000000..33d82d1 --- /dev/null +++ b/modules/host_sandbox_runner.py @@ -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)) diff --git a/modules/persistent_terminal.py b/modules/persistent_terminal.py index a0e180d..a707782 100644 --- a/modules/persistent_terminal.py +++ b/modules/persistent_terminal.py @@ -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): """启动或连接容器化终端。""" diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index 36dca3b..993e4db 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -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: diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index bd490da..cc359c3 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -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, @@ -510,6 +596,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: @@ -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 diff --git a/modules/user_container_manager.py b/modules/user_container_manager.py index 3b775fe..2b3270c 100644 --- a/modules/user_container_manager.py +++ b/modules/user_container_manager.py @@ -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 diff --git a/prompts/execution_mode.txt b/prompts/execution_mode.txt new file mode 100644 index 0000000..ae35459 --- /dev/null +++ b/prompts/execution_mode.txt @@ -0,0 +1,4 @@ +## 执行环境:{execution_mode_label}({execution_mode}) + +### 当前规则 +{rules} diff --git a/server/chat.py b/server/chat.py index 20c3e62..b3a4a5f 100644 --- a/server/chat.py +++ b/server/chat.py @@ -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 diff --git a/static/src/App.vue b/static/src/App.vue index 8bb7407..ad792ee 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -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" diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 2c4d09e..ddb75f2 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -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(); diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index 8176af6..cce7a6a 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -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; } diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index f40b6f0..1546ea0 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -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(); diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 3ff3843..d92a4f3 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -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: [], diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 3751cf3..34ff5c7 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -181,18 +181,39 @@ 权限:{{ currentPermissionLabel }} -
- +
+
+
权限
+ +
+
+
执行环境
+ +
@@ -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 }>; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 8eedab8..d7ab685 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -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 {