fix(server,static): 网络权限实例级快照与快捷窗口跨对话残留消闪

网络权限此前以进程级 os.environ 为唯一真相:每个 WebTerminal 初始化
都把 env 重置为默认值(切 full 后任何新 terminal 创建即失效),多实例
并发互相覆盖;后台指令与持久终端 shell 直接读 env,导致权限不生效或
跨工作区串扰。

- 唯一真相改为 WebTerminal 实例属性,init/set 不再写 env
- 后台 run_command: create_background_command 快照传入执行线程
- 持久终端 shell: 注入 network_permission_getter 实时取所属终端实例的值
- 快捷窗口: backgroundCommand/subAgent store 在 fetch 前同步剔除
  conversation_id 不属于当前对话的残留记录,消除「旧对话数据闪一下
  再清空」造成的“任务被停了”误解(后端任务实际一直在运行)

验证: py_compile 通过;冒烟 6 用例 OK;前端构建成功
This commit is contained in:
JOJO 2026-08-08 23:59:45 +08:00
parent be061bec78
commit 165c771cc7
9 changed files with 59 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
)
# 启动终端

View File

@ -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}` : ''}`
);

View File

@ -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)}`);