agent-Specialization/server/chat/permission.py
JOJO bd06f96d07 fix(server,static): 修复执行环境/权限模式切换对当前对话不生效
对话级 terminal 隔离后,模式切换请求未携带 conversation_id,
落在工作区级服务 terminal 上;而任务实际运行在对话级 terminal,
且 load_conversation 每次都会从对话 metadata 恢复创建时冻结的模式,
导致旧对话模式永远无法改变、新对话继承服务 terminal 的瞬时模式。

- 前端 permission.ts:权限/执行环境/网络权限的 POST 与 GET
  均携带当前 conversation_id,路由到对话级 terminal 并持久化到正确对话
- 后端 permission.py:切换时同步工作区级服务 terminal 的内存态
  (persist=False),保证新建对话继承最新模式而非过期模式
- 附带修复:运行期间切换的 pending 队列此前排在错误的 terminal 上,
  任务工具循环永远消费不到
2026-07-20 14:25:49 +08:00

436 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from server.chat import chat_bp
from server.chat.settings import _dispatch_runtime_mode_notice
import json, time
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"]
from datetime import datetime
from typing import Dict, Any, Optional
from pathlib import Path
from io import BytesIO
import zipfile
import os
from flask import Blueprint, jsonify, request, session, send_file
from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge
import secrets
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
from modules.personalization_manager import (
load_personalization_config,
resolve_context_compression_settings,
save_personalization_config,
THINKING_INTERVAL_MIN,
THINKING_INTERVAL_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
)
from modules.skills_manager import (
get_skills_catalog,
infer_private_skills_dir,
merge_enabled_skills,
sync_workspace_skills,
)
from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import (
load_policy,
save_policy,
get_macos_deny_read_paths,
get_macos_deny_read_regexes,
)
from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker, get_user_resources
from server.security import rate_limited, prune_socket_tokens
from server.utils_common import debug_log
from server.state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
import os
import re
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
def _sync_workspace_terminal_mode(username: str, workspace, kind: str, mode: str) -> None:
"""把模式切换同步到工作区级服务 terminal仅内存态不持久化
对话级隔离后,携带 conversation_id 的切换请求落在对话级 terminal 上,
工作区级 terminal 仍保持旧模式;而新建对话会以工作区 terminal 的当前
模式冻结进 metadatacreate_new_conversation导致新对话继承过期模式。
这里同步内存态即可persist=False避免误写工作区 terminal 上的陈旧对话。
"""
try:
ws_terminal, _ws = get_user_resources(
username,
workspace_id=getattr(workspace, "workspace_id", None),
conversation_id=None,
)
if not ws_terminal:
return
if kind == "permission_mode":
ws_terminal.set_permission_mode(mode, persist=False)
elif kind == "execution_mode":
ws_terminal.set_execution_mode(mode)
elif kind == "network_permission":
ws_terminal.set_network_permission(mode)
except Exception:
pass
def _path_conflicts_with_deny_list(path: str) -> Optional[str]:
"""检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。"""
if not path:
return None
expanded = os.path.abspath(os.path.expanduser(path))
for deny_path in get_macos_deny_read_paths():
deny_expanded = os.path.abspath(os.path.expanduser(deny_path))
deny_lower = deny_expanded.lower().rstrip("/")
expanded_lower = expanded.lower().rstrip("/")
if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + "/"):
return f"禁止授权敏感路径: {path}"
for pattern in get_macos_deny_read_regexes():
try:
if re.search(pattern, expanded) or re.search(pattern, path):
return f"禁止授权敏感文件: {path}"
except re.error:
continue
return None
@chat_bp.route('/api/permission-mode', methods=['GET'])
@api_login_required
@with_terminal
def get_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前权限模式。"""
current_conversation_id = getattr(terminal.context_manager, "current_conversation_id", None)
return jsonify({
"success": True,
"mode": terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else "unrestricted",
"pending_mode": (terminal.get_pending_runtime_modes().get("permission_mode") if hasattr(terminal, "get_pending_runtime_modes") else None),
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": current_conversation_id,
})
@chat_bp.route('/api/permission-mode', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("permission_mode_switch", 30, 60, scope="user")
def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""更新当前对话权限模式。"""
data = request.get_json() or {}
target_mode = str(data.get("mode") or "").strip().lower()
if target_mode not in PERMISSION_MODE_OPTIONS:
return jsonify({
"success": False,
"error": "无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted"
}), 400
# 判断当前是否在对话运行期间
is_running = False
try:
from server.tasks import task_manager
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
running_tasks = [
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
]
if current_conv:
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
is_running = bool(running_tasks)
except Exception:
pass
if is_running:
# 运行期间:使用 pending 机制延迟到工具循环中统一处理,
# 避免「A→B→A」来回切换时中间值被覆盖后仍插入多余消息。
try:
terminal.queue_permission_mode_change(target_mode)
_sync_workspace_terminal_mode(username, workspace, "permission_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,
"mode": target_mode,
"pending_mode": target_mode,
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
"message": "权限模式将在当前工具执行完成后生效",
})
# 空闲期间:直接生效
previous_mode = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None
try:
applied_mode = terminal.set_permission_mode(target_mode)
if hasattr(terminal, "pending_permission_mode"):
terminal.pending_permission_mode = None
if hasattr(terminal, "_persist_runtime_mode_metadata"):
terminal._persist_runtime_mode_metadata({
"permission_mode": applied_mode,
"pending_permission_mode": None,
})
_sync_workspace_terminal_mode(username, workspace, "permission_mode", applied_mode)
except Exception as exc:
return jsonify({
"success": False,
"error": str(exc),
"message": "更新权限模式失败"
}), 500
session["permission_mode"] = applied_mode
if applied_mode != previous_mode:
permission_label = {
"readonly": "只读",
"approval": "批准",
"auto_approval": "自动审核",
"unrestricted": "无限制",
}.get(applied_mode, applied_mode)
_dispatch_runtime_mode_notice(terminal, username, f"权限模式被用户修改为 {permission_label}", source="权限变更")
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": applied_mode,
"pending_mode": None,
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
"message": "权限模式已更新并立即生效",
})
@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,
"pending_mode": (terminal.get_pending_runtime_modes().get("execution_mode") if hasattr(terminal, "get_pending_runtime_modes") else None),
"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
# 判断当前是否在对话运行期间
is_running = False
try:
from server.tasks import task_manager
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
running_tasks = [
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
]
if current_conv:
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
is_running = bool(running_tasks)
except Exception:
pass
if is_running:
# 运行期间:使用 pending 机制延迟到工具循环中统一处理,
# 避免「A→B→A」来回切换时中间值被覆盖后仍插入多余消息。
try:
terminal.queue_execution_mode_change(target_mode)
_sync_workspace_terminal_mode(username, workspace, "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": {
**(terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else {"mode": target_mode}),
"mode": target_mode,
},
"pending_mode": target_mode,
"options": EXECUTION_MODE_OPTIONS,
"message": "执行环境将在当前工具执行完成后生效",
})
# 空闲期间:直接生效
previous_mode = terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None
try:
state = terminal.set_execution_mode(target_mode)
if hasattr(terminal, "pending_execution_mode"):
terminal.pending_execution_mode = None
if hasattr(terminal, "_persist_runtime_mode_metadata"):
terminal._persist_runtime_mode_metadata({
"execution_mode": state.get("mode", target_mode),
"pending_execution_mode": None,
})
_sync_workspace_terminal_mode(username, workspace, "execution_mode", state.get("mode", target_mode))
except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500
status = terminal.get_status()
current_mode = state.get("mode", target_mode)
if current_mode != previous_mode:
execution_label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(current_mode, current_mode)
_dispatch_runtime_mode_notice(terminal, username, f"执行环境被用户修改为 {execution_label}", source="执行环境变更")
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"state": state,
"pending_mode": None,
"options": EXECUTION_MODE_OPTIONS,
"message": "执行环境已更新并立即生效",
})
@chat_bp.route('/api/network-permission', methods=['GET'])
@api_login_required
@with_terminal
def get_network_permission(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"
current = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else "restricted"
return jsonify({
"success": True,
"enabled": can_manage,
"mode": current,
"pending_mode": (terminal.get_pending_runtime_modes().get("network_permission") if hasattr(terminal, "get_pending_runtime_modes") else None),
"options": NETWORK_PERMISSION_OPTIONS,
})
@chat_bp.route('/api/network-permission', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("network_permission_switch", 20, 60, scope="user")
def update_network_permission(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 NETWORK_PERMISSION_OPTIONS:
return jsonify({"success": False, "error": "无效网络权限,仅支持 restricted / full"}), 400
is_running = False
try:
from server.tasks import task_manager
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
running_tasks = [
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
]
if current_conv:
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
is_running = bool(running_tasks)
except Exception:
pass
if is_running:
try:
terminal.queue_network_permission_change(target_mode)
_sync_workspace_terminal_mode(username, workspace, "network_permission", 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,
"mode": target_mode,
"pending_mode": target_mode,
"options": NETWORK_PERMISSION_OPTIONS,
"message": "网络权限将在当前工具执行完成后生效",
})
previous_mode = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else None
try:
applied = terminal.set_network_permission(target_mode)
if hasattr(terminal, "pending_network_permission"):
terminal.pending_network_permission = None
if hasattr(terminal, "_persist_runtime_mode_metadata"):
terminal._persist_runtime_mode_metadata({
"network_permission": applied,
"pending_network_permission": None,
})
_sync_workspace_terminal_mode(username, workspace, "network_permission", applied)
except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500
status = terminal.get_status()
if applied != previous_mode:
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(applied, applied)
_dispatch_runtime_mode_notice(terminal, username, f"网络权限被用户修改为 {label}", source="网络权限变更")
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": applied,
"pending_mode": None,
"options": NETWORK_PERMISSION_OPTIONS,
"message": "网络权限已更新并立即生效",
})
@chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required
@with_terminal
def get_path_authorization(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"
data = load_policy()
return jsonify({
"success": True,
"enabled": can_manage,
"writable_paths": data.get("macos_writable_paths", []),
"readable_extra_paths": data.get("macos_readable_extra_paths", []),
"deny_read_paths": data.get("macos_deny_read_paths", []),
"deny_read_regexes": data.get("macos_deny_read_regexes", []),
})
@chat_bp.route('/api/path-authorization', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("path_authorization_update", 20, 60, scope="user")
def update_path_authorization(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 {}
writable_items = data.get("writable_paths")
readable_items = data.get("readable_extra_paths")
if not isinstance(writable_items, list) or not isinstance(readable_items, list):
return jsonify({"success": False, "error": "writable_paths/readable_extra_paths 必须为数组"}), 400
writable = [str(x).strip() for x in writable_items if str(x).strip()]
readable_extra = [str(x).strip() for x in readable_items if str(x).strip()]
if "/" in writable or "/" in readable_extra:
return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400
for p in writable + readable_extra:
conflict = _path_conflicts_with_deny_list(p)
if conflict:
return jsonify({"success": False, "error": conflict}), 400
payload = save_policy({
"macos_writable_paths": writable,
"macos_readable_extra_paths": readable_extra
})
return jsonify({
"success": True,
"writable_paths": payload.get("macos_writable_paths", []),
"readable_extra_paths": payload.get("macos_readable_extra_paths", []),
"deny_read_paths": payload.get("macos_deny_read_paths", []),
"deny_read_regexes": payload.get("macos_deny_read_regexes", []),
})