diff --git a/core/main_terminal.py b/core/main_terminal.py index cd8222b1..0b1696ff 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -345,20 +345,27 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi if pending_execution: current_exec = self.get_execution_mode() if pending_execution != current_exec: - self.set_execution_mode(pending_execution) - if hasattr(self, "build_runtime_mode_switch_notice"): - notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution) - elif hasattr(self, "_build_execution_mode_switch_notice"): - notice_text = self._build_execution_mode_switch_notice(pending_execution) - else: - notice_text = f"执行环境被用户修改为 {pending_execution}" - notices.append({ - "text": notice_text, - "source": "execution_change", - "kind": "execution_mode", - "mode": pending_execution, - }) - updates["execution_mode"] = pending_execution + applied = False + try: + self.set_execution_mode(pending_execution) + applied = True + except Exception: + # 锁定拒绝(plan / readonly 下禁止 direct):丢弃该 pending,保持沙箱 + applied = False + if applied: + if hasattr(self, "build_runtime_mode_switch_notice"): + notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution) + elif hasattr(self, "_build_execution_mode_switch_notice"): + notice_text = self._build_execution_mode_switch_notice(pending_execution) + else: + notice_text = f"执行环境被用户修改为 {pending_execution}" + notices.append({ + "text": notice_text, + "source": "execution_change", + "kind": "execution_mode", + "mode": pending_execution, + }) + updates["execution_mode"] = pending_execution updates["pending_execution_mode"] = None self.pending_execution_mode = None @@ -424,6 +431,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi raise ValueError("计划模式下执行环境锁定为沙箱,请先切换运行模式") except AttributeError: pass + # 只读模式下执行环境同样锁死为沙箱(与 plan 锁并列): + # 切到 readonly 权限时由 set_permission_mode 联动强制沙箱, + # 这里拦直接调用(API/队列)防止只读期间切回 direct 绕过 OS 沙箱硬限制。 + if normalized == "direct" and getattr(self, "get_permission_mode", None): + try: + if self.get_permission_mode() == "readonly": + raise ValueError("只读模式下执行环境锁定为沙箱,请先切换权限模式") + except AttributeError: + pass self.host_execution_mode = normalized self._apply_execution_mode_to_runtime() return self.get_execution_mode_state() diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index da16510c..22c1ded8 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -358,8 +358,14 @@ class MainTerminalToolsPolicyMixin: raise ValueError("计划模式下权限模式锁定为只读,请先切换运行模式") except AttributeError: pass + previous = self.get_permission_mode() + entering_readonly = normalized == "readonly" and previous != "readonly" + leaving_readonly = previous == "readonly" and normalized != "readonly" self.current_permission_mode = normalized if not persist: + # 只读联动不依赖持久化:运行中 pending 切换路径(apply_pending_runtime_mode_changes) + # 也要内存级强制沙箱,只是不落 metadata(无记录则切离时保持沙箱,安全默认)。 + self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=False) return normalized conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) @@ -372,8 +378,57 @@ class MainTerminalToolsPolicyMixin: self.context_manager.conversation_metadata["permission_mode"] = normalized except Exception: pass + self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=True) return normalized + def _apply_readonly_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None: + """只读权限 ⇄ 执行环境联动:进入 readonly 强制切沙箱,切离恢复进入前执行环境。 + + 与 plan ⇄ readonly+sandbox 双锁逻辑对称:只读权限在宿主机依赖 OS 沙箱硬限制, + direct(完全访问)下无沙箱,只读形同虚设,必须一并锁回沙箱。 + 进入 readonly 时若执行环境为 direct,先存 pre_readonly_execution_mode 供切离时恢复; + 切离 readonly 时仅当有明确进入前记录才恢复(无记录保持 sandbox,安全默认)。 + """ + if entering: + try: + if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct": + if persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"pre_readonly_execution_mode": "direct"}) + except Exception: + pass + if hasattr(self, "set_execution_mode"): + self.set_execution_mode("sandbox") + if persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"execution_mode": "sandbox"}) + except Exception: + pass + except Exception: + pass + return + if leaving: + try: + meta = getattr(getattr(self, "context_manager", None), "conversation_metadata", None) or {} + pre_exec = str(meta.get("pre_readonly_execution_mode") or "").strip().lower() + except Exception: + pre_exec = "" + if pre_exec == "direct" and hasattr(self, "set_execution_mode"): + try: + self.set_execution_mode("direct") + if persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"execution_mode": "direct", "pre_readonly_execution_mode": None}) + except Exception: + pass + except Exception: + pass + elif persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"pre_readonly_execution_mode": None}) + except Exception: + pass + def set_tool_category_enabled(self, category: str, enabled: bool) -> None: """设置工具类别的启用状态 / Toggle tool category enablement.""" categories = self.tool_categories_map diff --git a/server/chat/permission.py b/server/chat/permission.py index 9b85a3d2..3620ff86 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -195,6 +195,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user "pending_mode": target_mode, "options": PERMISSION_MODE_OPTIONS, "conversation_id": getattr(terminal.context_manager, "current_conversation_id", None), + "state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None), "message": "权限模式将在当前工具执行完成后生效", }) @@ -226,6 +227,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user "pending_mode": None, "options": PERMISSION_MODE_OPTIONS, "conversation_id": getattr(terminal.context_manager, "current_conversation_id", None), + "state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None), "message": "权限模式已更新并立即生效", }) diff --git a/static/src/app/methods/ui/permission.ts b/static/src/app/methods/ui/permission.ts index 8eb8ea1a..ae0fb5f9 100644 --- a/static/src/app/methods/ui/permission.ts +++ b/static/src/app/methods/ui/permission.ts @@ -85,6 +85,11 @@ export const permissionMethods = { if (typeof payload?.mode === 'string') { this.currentPermissionMode = payload.mode; } + // readonly 联动:后端在切到只读时会强制执行环境切到沙箱,同步前端显示 + const execState = payload?.state || {}; + if (typeof execState.mode === 'string') { + this.currentExecutionMode = execState.mode; + } this.pendingPermissionMode = ''; this.uiPushToast({ title: '权限已更新', diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 4c8fdf36..bba0131c 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -494,11 +494,11 @@ type="button" class="permission-switcher__btn" :disabled="!isConnected" - :title="permissionLockedByPlan ? '计划模式下权限与执行环境已锁定,网络权限仍可调整' : ''" + :title="permissionLockedByPlan ? '计划模式下权限与执行环境已锁定,网络权限仍可调整' : (executionLockedByReadonly ? '只读模式下执行环境已锁定为沙箱' : '')" @click="$emit('toggle-permission-menu')" >
- 执行环境计划模式锁定 + 执行环境 + 计划模式锁定 + 只读模式锁定