diff --git a/core/main_terminal.py b/core/main_terminal.py index 361526e2..e08bc4cb 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -129,6 +129,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi terminal_display_size=TERMINAL_DISPLAY_SIZE, broadcast_callback=None, # CLI模式不需要广播 container_session=container_session, + network_permission_getter=self.get_network_permission, ) # 让 run_command 复用终端容器,保持环境一致 self.terminal_ops.attach_terminal_manager(self.terminal_manager) @@ -264,7 +265,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi if default_permission not in {"restricted", "full", "none"}: default_permission = "restricted" self.host_network_permission = default_permission - os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = default_permission + # 注意:不再写入 os.environ——网络权限的唯一真相是本实例属性; + # 进程级 env 会被多个 terminal 实例互相覆盖,曾导致后台指令/ + # 持久终端读到其他实例的权限(不生效或跨工作区串扰)。 def get_network_permission(self) -> str: return getattr(self, "host_network_permission", "restricted") @@ -274,7 +277,6 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi if normalized not in {"restricted", "full", "none"}: raise ValueError("无效网络权限,仅支持 restricted / full / none") self.host_network_permission = normalized - os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = normalized return normalized def get_pending_runtime_modes(self) -> Dict[str, Optional[str]]: diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index eee33a73..96d51f98 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1683,6 +1683,7 @@ class MainTerminalToolsExecutionMixin: timeout=timeout_value, conversation_id=getattr(self.context_manager, "current_conversation_id", None), wait_seconds=5.0, + network_permission=network_permission, ) else: if timeout_value is None or float(timeout_value) <= 0: diff --git a/core/web_terminal.py b/core/web_terminal.py index b572c3e6..becacecf 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -120,6 +120,9 @@ class WebTerminal(MainTerminal): # 导致每次创建对话级 terminal 都输出“关闭所有终端会话”噪音日志。 if self.terminal_manager is not None: self.terminal_manager.broadcast = message_callback + # 复用父类实例时也要确保 getter 已注入(父类创建时已传入,防御旧实例) + if getattr(self.terminal_manager, "network_permission_getter", None) is None: + self.terminal_manager.network_permission_getter = self.get_network_permission else: self.terminal_manager = TerminalManager( project_path=project_path, @@ -127,7 +130,8 @@ class WebTerminal(MainTerminal): terminal_buffer_size=TERMINAL_BUFFER_SIZE, terminal_display_size=TERMINAL_DISPLAY_SIZE, broadcast_callback=message_callback, - container_session=self.container_session + container_session=self.container_session, + network_permission_getter=self.get_network_permission, ) # 让 run_command 与实时终端共享同一容器环境 self.terminal_ops.attach_terminal_manager(self.terminal_manager) diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index c55b0b49..193ffe6b 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -41,6 +41,7 @@ class BackgroundCommandManager: timeout: Optional[int], conversation_id: Optional[str], wait_seconds: float = 5.0, + network_permission: Optional[str] = None, ) -> Dict[str, Any]: """启动后台命令;先等待一小段时间返回已有输出。""" if timeout is None or float(timeout) <= 0: @@ -114,6 +115,9 @@ class BackgroundCommandManager: "session": session_override or getattr(terminal_ops, "container_session", None), "python_env": getattr(terminal_ops, "_python_env", None) or {}, "host_execution_mode": getattr(terminal_ops, "host_execution_mode", "sandbox"), + # 网络权限必须按调用方(对话级 terminal)快照传入,不能读进程级 + # 环境变量——env 会被其他 terminal 实例覆盖,导致权限不生效或跨工作区串扰。 + "network_permission": network_permission, }, name=f"bg-run-command-{command_id}", daemon=True, @@ -181,6 +185,7 @@ class BackgroundCommandManager: session, python_env: Dict[str, str], host_execution_mode: str = "sandbox", + network_permission: Optional[str] = None, ) -> None: start_ts = time.time() process: Optional[subprocess.Popen] = None @@ -230,8 +235,13 @@ class BackgroundCommandManager: if use_shell: use_host_sandbox = host_execution_mode != "direct" if use_host_sandbox and host_sandbox_enabled(): - network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") - plan = build_host_sandbox_plan(command, work_path, env, network_permission=network_permission) + # 优先使用创建时快照的权限;缺省才回落环境变量(兼容旧调用方)。 + effective_network_permission = ( + str(network_permission).strip().lower() + if network_permission + else os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") + ) + plan = build_host_sandbox_plan(command, work_path, env, network_permission=effective_network_permission) cmd_args = plan.command pass_fds = () seccomp_fd = None diff --git a/modules/persistent_terminal/base.py b/modules/persistent_terminal/base.py index 0eaa7a2c..a3532f1d 100644 --- a/modules/persistent_terminal/base.py +++ b/modules/persistent_terminal/base.py @@ -77,6 +77,7 @@ class PersistentTerminalBase: project_path: Optional[str] = None, sandbox_mode: Optional[str] = None, sandbox_options: Optional[Dict] = None, + network_permission_getter: Optional[Callable] = None, ): """ 初始化持久化终端 @@ -97,6 +98,9 @@ class PersistentTerminalBase: self.broadcast = broadcast_callback self.max_buffer_size = max_buffer_size self.display_size = display_size + # 网络权限来源:优先由所属 WebTerminal 注入 getter 实时取值; + # 不可用时才回落进程级环境变量(兼容 toolbox_container 等独立使用方)。 + self.network_permission_getter = network_permission_getter # 进程相关 self.process = None @@ -163,6 +167,18 @@ class PersistentTerminalBase: self._sandbox_bin_path = None self._owns_container = False + def _resolve_network_permission(self) -> str: + """取当前网络权限设置:getter 实时值优先,环境变量兼容回落。""" + getter = getattr(self, "network_permission_getter", None) + if callable(getter): + try: + value = getter() + if value: + return str(value).strip().lower() + except Exception: + pass + return os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") + def __del__(self): """析构函数,确保进程被关闭""" if hasattr(self, 'is_running') and self.is_running: diff --git a/modules/persistent_terminal/start.py b/modules/persistent_terminal/start.py index fdda31cb..f4f689fa 100644 --- a/modules/persistent_terminal/start.py +++ b/modules/persistent_terminal/start.py @@ -152,7 +152,8 @@ class StartMixin: env['LC_ALL'] = 'en_US.UTF-8' try: - network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") + # 实时读取所属终端实例的权限设置,而非进程级 env(多 terminal 并存时 env 会被覆盖) + network_permission = self._resolve_network_permission() plan = build_host_sandbox_shell_plan( self.working_dir, env, network_permission=network_permission ) diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index d26022df..048502a8 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -75,6 +75,7 @@ class TerminalManager: sandbox_mode: Optional[str] = None, sandbox_options: Optional[Dict] = None, container_session: Optional["ContainerHandle"] = None, + network_permission_getter: Optional[Callable] = None, ): """ 初始化终端管理器 @@ -123,6 +124,9 @@ class TerminalManager: if sandbox_options and sandbox_options.get("container_name"): self.sandbox_mode = "docker" self._apply_container_session(container_session) + # 网络权限 getter(由所属 WebTerminal 注入):新建持久终端时透传, + # 使 shell 沙箱实时跟随该终端实例的权限设置而非进程级 env。 + self.network_permission_getter = network_permission_getter # 终端命令超时工具(兼容 macOS 无 coreutils 的情况) self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") @@ -248,7 +252,8 @@ class TerminalManager: display_size=self.terminal_display_size, project_path=str(self.project_path), sandbox_mode=self.sandbox_mode, - sandbox_options=sandbox_options + sandbox_options=sandbox_options, + network_permission_getter=self.network_permission_getter ) # 启动终端 diff --git a/static/src/stores/backgroundCommand.ts b/static/src/stores/backgroundCommand.ts index eb1d9191..09d1f72f 100644 --- a/static/src/stores/backgroundCommand.ts +++ b/static/src/stores/backgroundCommand.ts @@ -5,6 +5,7 @@ interface BackgroundCommand { command_id: string; status?: string; command?: string; + conversation_id?: string; notice_pending?: boolean; created_at?: number; updated_at?: number; @@ -60,6 +61,12 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', { return; } const query = convQuery(); + // 同步剔除不属于当前对话的残留记录:切对话/工作区后旧对话数据 + // 会先渲染一拍(「闪一下」)再被 fetch 覆盖清空,造成“指令被停了”的误解。 + // 属于当前对话的记录保留,避免布局「先收起再展开」闪烁。 + this.commands = this.commands.filter( + (item) => !item.conversation_id || item.conversation_id === convId + ); const resp = await fetch( `/api/background_commands?limit=200${query ? `&${query}` : ''}` ); diff --git a/static/src/stores/subAgent.ts b/static/src/stores/subAgent.ts index e6fe7eef..cb6e5055 100644 --- a/static/src/stores/subAgent.ts +++ b/static/src/stores/subAgent.ts @@ -61,6 +61,12 @@ export const useSubAgentStore = defineStore('subAgent', { this.subAgents = []; return; } + // 同步剔除不属于当前对话的残留记录:切对话/工作区后旧对话数据 + // 会先渲染一拍再被 fetch 覆盖,造成“任务被停了”的误解。 + // 属于当前对话的记录保留,避免布局「先收起再展开」闪烁。 + this.subAgents = this.subAgents.filter( + (item) => !item.conversation_id || item.conversation_id === convId + ); let resp; if (conversationStore.multiAgentMode) { resp = await fetch(`/api/multiagent/active_sub_agents?conversation_id=${encodeURIComponent(convId)}`);