agent-Specialization/server/chat/permission.py
JOJO aa50a72d86 fix(chat): /new页面切换工作模式被运行中对话误拦
- 运行中判定改基于请求显式携带的 conversation_id,不再用工作区级
  terminal 的残留焦点(_ensure_conversation 自动加载最近对话);
  /new 页面无 cid 直接放行(新对话默认模式与运行中对话无关)
- /new 路径切换改 persist=False,避免误写残留焦点对话的 metadata
  (plan 联动权限曾一并污染),与 _sync_workspace_terminal_mode 同策略

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-09 02:19:30 +08:00

560 lines
26 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
import json, time
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"]
WORK_MODE_OPTIONS = ["plan", "ask", "execute"]
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 MAX_UPLOAD_SIZE, OUTPUT_FORMATS
from modules.personalization_manager import (
load_personalization_config,
resolve_context_compression_settings,
save_personalization_config,
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,
get_windows_deny_read_paths,
)
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, 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
import os
import re
from modules.i18n import tr
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)
elif kind == "work_mode":
# 必须走 switch_work_modepersist=False 仅内存态):裸 set 会破坏
# 「plan ⇒ 权限只读」不变量(切 plan 不锁只读 / 离 plan 不恢复权限)。
# persist=False 避免误写工作区 terminal 上可能残留的陈旧对话 metadata。
ws_terminal.switch_work_mode(mode, persist=False)
except Exception:
pass
# Windows 特有的 deny 正则POSIX 的 ^/.*\.env$ 无法匹配 C:\ 开头的路径)
_WINDOWS_DENY_READ_REGEXES = [
r"^[A-Za-z]:[\\/].*\.env(\.[^\\/]*)?$",
]
def _windows_system_deny_paths() -> list:
"""Windows 系统敏感目录(从环境变量取值,适配非默认安装位置)。"""
return [
os.environ.get("SystemRoot", r"C:\Windows"),
os.environ.get("ProgramData", r"C:\ProgramData"),
os.environ.get("ProgramFiles", r"C:\Program Files"),
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
]
def _normalize_for_compare(p: str) -> str:
"""路径比较归一化:展开 ~、转绝对路径、统一大小写与分隔符。"""
return os.path.normcase(os.path.abspath(os.path.expanduser(p))).lower().rstrip("\\/")
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))
deny_paths = list(get_macos_deny_read_paths())
deny_regexes = list(get_macos_deny_read_regexes())
if os.name == "nt":
# Windows补充 Windows deny 列表与系统目录(此前完全不校验)
deny_paths += list(get_windows_deny_read_paths()) + _windows_system_deny_paths()
deny_regexes += _WINDOWS_DENY_READ_REGEXES
expanded_lower = _normalize_for_compare(expanded)
for deny_path in deny_paths:
deny_lower = _normalize_for_compare(deny_path)
if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + os.sep):
return tr("chat_permission.deny_sensitive_path", path=path)
for pattern in deny_regexes:
try:
if re.search(pattern, expanded) or re.search(pattern, path):
return tr("chat_permission.deny_sensitive_file", path=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": tr("chat_permission.invalid_permission_mode")
}), 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": tr("chat_permission.update_failed")
}), 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),
"state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None),
"message": tr("chat_permission.pending_effective"),
})
# 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发
# (见 chat_flow_task_main 的 drift 注入点),此处不再 enqueue。
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": tr("chat_permission.update_failed")
}), 500
session["permission_mode"] = applied_mode
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),
"state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None),
"message": tr("chat_permission.updated_immediately"),
})
@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": tr("chat_permission.execution_host_admin_only")}), 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": tr("chat_permission.invalid_execution_mode")}), 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": tr("chat_permission.execution_update_failed")}), 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": tr("chat_permission.execution_pending_effective"),
})
# 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。
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": tr("chat_permission.execution_update_failed")}), 500
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"state": state,
"pending_mode": None,
"options": EXECUTION_MODE_OPTIONS,
"message": tr("chat_permission.execution_updated_immediately"),
})
@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": tr("chat_permission.network_host_admin_only")}), 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": tr("chat_permission.invalid_network_permission")}), 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": tr("chat_permission.network_update_failed")}), 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": tr("chat_permission.network_pending_effective"),
})
# 空闲期间:直接生效。切换通知由 baseline 机制在下一条真实 user 消息时补发。
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": tr("chat_permission.network_update_failed")}), 500
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": applied,
"pending_mode": None,
"options": NETWORK_PERMISSION_OPTIONS,
"message": tr("chat_permission.network_updated_immediately"),
})
@chat_bp.route('/api/work-mode', methods=['GET'])
@api_login_required
@with_terminal
def get_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前运行模式plan/ask/execute"""
current_conversation_id = getattr(terminal.context_manager, "current_conversation_id", None)
return jsonify({
"success": True,
"mode": terminal.get_work_mode() if hasattr(terminal, "get_work_mode") else "plan",
# plan 档下权限被联动锁定为只读、执行环境被联动锁定为沙箱,前端需要同步显示
"permission_mode": terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None,
"execution_mode": terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None,
"options": WORK_MODE_OPTIONS,
"conversation_id": current_conversation_id,
})
@chat_bp.route('/api/work-mode', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("work_mode_switch", 30, 60, scope="user")
def update_work_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 WORK_MODE_OPTIONS:
return jsonify({
"success": False,
"error": tr("chat_permission.invalid_work_mode")
}), 400
# 请求显式携带的 conversation_idquery > body与 with_terminal 路由同优先级)。
# 判定必须基于请求而非 terminal.context_manager.current_conversation_id
# /new 页面不带 cid 时路由到工作区级 terminal其焦点残留为「最近对话」
# _ensure_conversation 自动加载最近对话恢复焦点),按残留焦点判定会把
# 最近对话误判为「本对话」,其运行中会误拦 /new 页面切换(新对话默认模式
# 与任何运行中对话无关)。
req_conv_id = (request.args.get("conversation_id") or "").strip() or None
if not req_conv_id and isinstance(data, dict):
req_conv_id = (data.get("conversation_id") or "").strip() or None
# 运行中拒绝切换(无 pending 队列——运行模式不存在「工具结果后插入」的路径)。
# 运行模式是对话级状态,只检测本对话是否有运行中任务;其他对话运行不影响;
# /new 页面(无 cid不检查直接放行。
if req_conv_id:
try:
from server.tasks import task_manager
req_conv_norm = req_conv_id.removeprefix("conv_")
conv_running = [
r for r in task_manager.list_tasks(username)
if r.status in {"pending", "running", "cancel_requested"}
and (r.conversation_id or "").removeprefix("conv_") == req_conv_norm
]
if conv_running:
return jsonify({
"success": False,
"error": tr("chat_permission.work_mode_running_refused"),
"message": tr("chat_permission.work_mode_running_refused"),
}), 409
except Exception:
pass
previous_permission = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None
try:
if req_conv_id:
# 对话内切换:持久化到该对话 metadata对话级唯一真相
result = terminal.switch_work_mode(target_mode, conversation_id=req_conv_id)
else:
# /new 页面:仅设工作区级内存态(新对话继承冻结读 terminal 当前值),
# persist=False 避免误写工作区 terminal 残留焦点对话的 metadata
# (与 _sync_workspace_terminal_mode 同策略)。
result = terminal.switch_work_mode(target_mode, persist=False)
except Exception as exc:
return jsonify({
"success": False,
"error": str(exc),
"message": tr("chat_permission.work_mode_switch_failed")
}), 500
# plan 联动可能改了权限模式:同步工作区级 terminal 的两个模式
try:
_sync_workspace_terminal_mode(username, workspace, "work_mode", result.get("mode") or target_mode)
new_permission = result.get("permission_mode")
if new_permission and new_permission != previous_permission:
_sync_workspace_terminal_mode(username, workspace, "permission_mode", new_permission)
except Exception:
pass
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": result.get("mode") or target_mode,
"permission_mode": result.get("permission_mode"),
# plan 联动可能切了执行环境direct⇒sandbox一并返回供前端同步
"execution_mode": terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None,
"options": WORK_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
"message": tr("chat_permission.work_mode_updated_immediately"),
})
@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", []),
"windows_deny_read_paths": data.get("windows_deny_read_paths", []),
})
@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": tr("chat_permission.path_auth_host_admin_only")}), 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": tr("chat_permission.paths_must_be_arrays")}), 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": tr("chat_permission.root_path_forbidden")}), 400
# Windows禁止授权驱动器根目录如 C:\、D:/),此前仅检查 POSIX 根 "/"
drive_root_pattern = re.compile(r"^[A-Za-z]:[\\/]?$")
if any(drive_root_pattern.match(p) for p in writable + readable_extra):
return jsonify({"success": False, "error": tr("chat_permission.drive_root_forbidden")}), 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", []),
})