agent-Specialization/server/chat/approval.py
JOJO f77f1d67a8 refactor(runtime): 审核 F1-F4 收口——发现入口、范围校验、入口统一转调、验收加固
- F1: RuntimeService.list_runs 公共 Run 发现(工作区/会话/状态筛选);
  task_public_payload 序列化收敛至 models.py 核心层单一实现(Web/公共入口同构,
  helpers.py 旧实现删除,无新旧双轨)
- F2: tasks/api.py 7 处、api_v1.py 3 处、chat/approval.py 6 处全部转调
  runtime_service;HTTP 轮询透传 window_start 缺口水位;死导入清零
- F3: _resources_for_query 补 principal workspace_id 一致性校验
  (跨工作区查询必须重新认证)
- F4: config 新增 ASTRION_IGNORE_DOTENV 逃生门(.env 对 ASTRION_DATA_ROOT 的
  优先覆盖是刻意设计,保留);验收测试自包含(假模型 127.0.0.1:9 快失败)+
  隔离生效断言;lifecycle 改 api_request_start 装配证据断言;
  新增审批等待链验收(approval 档真实工具循环:等待→公共入口批准→继续);
  chain 首段改双客户端场景(B 经 list_runs 发现 A 的 Run 并观察/取消)
- execution_plane/base.py 注释修正:命令校验/路径授权仍在旧链路(审核 §4)
- 全量 75 测试:失败恰为 4 项存量,与改造无关
2026-09-08 01:29:43 +08:00

177 lines
7.1 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
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
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
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.runtime import runtime_service
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
@api_login_required
@with_terminal
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待回答的问题列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="question")["question"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("user_question_answer", 120, 60, scope="user")
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
"""提交 ask_user 工具问题的回答。"""
data = request.get_json() or {}
try:
item = runtime_service.resolve_approval(
"question",
username=username,
item_id=question_id,
selected_option_id=data.get("selected_option_id"),
text=data.get("text"),
dismissed=bool(data.get("dismissed")),
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.question_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@chat_bp.route('/api/plan-approvals/pending', methods=['GET'])
@api_login_required
@with_terminal
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待批准的计划列表work_mode=plan 的 submit_plan 工具)。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="plan")["plan"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("plan_approval_answer", 120, 60, scope="user")
def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
"""提交计划批准/拒绝决策。approved=true 时工具循环侧会自动切换到 execute 模式。"""
data = request.get_json() or {}
try:
item = runtime_service.resolve_approval(
"plan",
username=username,
item_id=approval_id,
approved=bool(data.get("approved")),
comment=data.get("comment"),
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.plan_approval_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required
@with_terminal
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待审批工具列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="tool")["tool"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("tool_approval_decision", 60, 60, scope="user")
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
"""提交工具审批决策。"""
data = request.get_json() or {}
decision = str(data.get("decision") or "").strip().lower()
try:
item = runtime_service.resolve_approval(
"tool", username=username, item_id=approval_id, decision=decision
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.approval_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})