9501751792
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
>4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
486 lines
17 KiB
Go
486 lines
17 KiB
Go
package workbench
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"aigateway.local/core/internal/apikey"
|
||
"aigateway.local/core/internal/gateway"
|
||
)
|
||
|
||
// toolExecutor runs one tool (regular HTTP tool or MCP tool) during a digital
|
||
// employee chat round and returns the tool message body.
|
||
type toolExecutor func(ctx context.Context, args map[string]any) (map[string]any, error)
|
||
|
||
func (h *RuntimeHTTPHandler) renderSkill(w http.ResponseWriter, r *http.Request) {
|
||
principal, ok := h.principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var input struct {
|
||
Variables map[string]any `json:"variables"`
|
||
}
|
||
if !decodeRuntime(w, r, &input) {
|
||
return
|
||
}
|
||
skill, err := h.market.Skills.GetPublishedByCode(r.Context(), r.PathValue("code"))
|
||
if err != nil || !visible(skill.DepartmentIDs, principal, false) {
|
||
runtimeError(w, 404, "Skill 不存在或不可见")
|
||
return
|
||
}
|
||
rendered, err := h.market.Skills.Render(skill, input.Variables)
|
||
if err != nil {
|
||
runtimeError(w, 400, err.Error())
|
||
return
|
||
}
|
||
writeRuntime(w, 200, map[string]any{"code": skill.Code, "name": skill.Name, "rendered": rendered})
|
||
}
|
||
|
||
func (h *RuntimeHTTPHandler) listMCPServers(w http.ResponseWriter, r *http.Request) {
|
||
principal, ok := h.principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
portalUserID, err := h.portalUserID(r.Context(), principal)
|
||
if err != nil {
|
||
runtimeError(w, 503, "安装信息暂不可用")
|
||
return
|
||
}
|
||
servers, err := h.market.MCPServers.List(r.Context())
|
||
if err != nil {
|
||
runtimeError(w, 503, "MCP 服务器服务暂不可用")
|
||
return
|
||
}
|
||
result := []map[string]any{}
|
||
for _, server := range servers {
|
||
if server.Status != "published" || !server.Enabled {
|
||
continue
|
||
}
|
||
allowed, aerr := h.mcpAccessAllowed(r.Context(), principal, portalUserID, server)
|
||
if aerr != nil || !allowed {
|
||
continue
|
||
}
|
||
result = append(result, map[string]any{"code": server.Code, "name": server.Name, "description": server.Description, "transport": server.Transport})
|
||
}
|
||
writeRuntime(w, 200, map[string]any{"object": "list", "data": result})
|
||
}
|
||
|
||
func (h *RuntimeHTTPHandler) mcpServerTools(w http.ResponseWriter, r *http.Request) {
|
||
principal, ok := h.principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
server, err := h.market.MCPServers.GetPublishedByCode(r.Context(), r.PathValue("code"))
|
||
if err != nil || !h.mcpAccessible(r, principal, server) {
|
||
runtimeError(w, 404, "MCP 服务器不存在或不可访问")
|
||
return
|
||
}
|
||
headers, err := h.market.MCPServers.Headers(server)
|
||
if err != nil {
|
||
runtimeError(w, 503, "MCP 服务器凭据不可用")
|
||
return
|
||
}
|
||
tools, err := h.market.MCPClient.DiscoverTools(r.Context(), server, headers)
|
||
if err != nil {
|
||
runtimeError(w, 502, err.Error())
|
||
return
|
||
}
|
||
result := []map[string]any{}
|
||
for _, tool := range tools {
|
||
result = append(result, map[string]any{"name": mcpToolName(server.Code, tool.Name), "description": tool.Description})
|
||
}
|
||
writeRuntime(w, 200, map[string]any{"code": server.Code, "server": server.Name, "data": result})
|
||
}
|
||
|
||
func (h *RuntimeHTTPHandler) invokeMCPTool(w http.ResponseWriter, r *http.Request) {
|
||
principal, ok := h.principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
server, err := h.market.MCPServers.GetPublishedByCode(r.Context(), r.PathValue("code"))
|
||
if err != nil || !h.mcpAccessible(r, principal, server) {
|
||
runtimeError(w, 404, "MCP 服务器不存在或不可访问")
|
||
return
|
||
}
|
||
var input struct {
|
||
Input map[string]any `json:"input"`
|
||
}
|
||
if !decodeRuntime(w, r, &input) {
|
||
return
|
||
}
|
||
headers, err := h.market.MCPServers.Headers(server)
|
||
if err != nil {
|
||
runtimeError(w, 503, "MCP 服务器凭据不可用")
|
||
return
|
||
}
|
||
result, err := h.market.MCPClient.CallTool(r.Context(), server, headers, r.PathValue("tool"), input.Input)
|
||
if err != nil {
|
||
runtimeError(w, 502, err.Error())
|
||
return
|
||
}
|
||
status := http.StatusOK
|
||
if result.IsError {
|
||
status = http.StatusBadGateway
|
||
}
|
||
writeRuntime(w, status, map[string]any{"name": mcpToolName(server.Code, r.PathValue("tool")), "content": result.Content, "is_error": result.IsError})
|
||
}
|
||
|
||
type digitalEmployeeRequest struct {
|
||
Messages []map[string]any `json:"messages"`
|
||
Variables map[string]any `json:"variables"`
|
||
SkillIDs []string `json:"skill_ids"`
|
||
MCPServerIDs []string `json:"mcp_server_ids"`
|
||
}
|
||
|
||
func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.Request) {
|
||
principal, ok := h.principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var input digitalEmployeeRequest
|
||
if !decodeRuntime(w, r, &input) {
|
||
return
|
||
}
|
||
employee, err := h.market.Employees.GetPublishedByCode(r.Context(), r.PathValue("code"))
|
||
if err != nil {
|
||
runtimeError(w, 404, "数字员工不存在或未发布")
|
||
return
|
||
}
|
||
portalUserID, err := h.portalUserID(r.Context(), principal)
|
||
if err != nil {
|
||
runtimeError(w, 503, "安装信息暂不可用")
|
||
return
|
||
}
|
||
// Department-scoped digital employees are restricted to members of the
|
||
// department; everyone else must have installed the resource first.
|
||
if !visible(employee.DepartmentIDs, principal, false) {
|
||
installed, ierr := h.market.Market.Installed(r.Context(), "digital_employee", employee.ID, portalUserID)
|
||
if ierr != nil || !installed {
|
||
runtimeError(w, 403, "未安装此数字员工")
|
||
return
|
||
}
|
||
}
|
||
started := time.Now()
|
||
status := "error"
|
||
runError := ""
|
||
retrievalCount := 0
|
||
toolCount := 0
|
||
modelCallCount := 0
|
||
conversationID := strings.TrimSpace(r.Header.Get("X-Gateway-Conversation-ID"))
|
||
traceID := h.beginTrace(r.Context(), principal, "digital_employee", employee.ID, employee.Code, conversationID)
|
||
defer func() {
|
||
traceCtx := context.WithoutCancel(r.Context())
|
||
h.finishTrace(traceCtx, traceID, status, runError, retrievalCount, modelCallCount, toolCount)
|
||
runID, idErr := newUUID()
|
||
if idErr == nil {
|
||
_, _ = h.service.pool.Exec(traceCtx, `INSERT INTO gateway.digital_employee_runs(id,digital_employee_id,api_key_id,trace_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,'')::uuid,nullif($4,'')::uuid,$5,$6,$7,$8,$9,$10)`, runID, employee.ID, principal.APIKeyID, traceID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError)
|
||
}
|
||
}()
|
||
executors, payload, prepareErr := h.prepareDigitalEmployee(r.Context(), employee, input, principal, &retrievalCount, portalUserID, traceID)
|
||
if prepareErr != nil {
|
||
runError = prepareErr.Error()
|
||
runtimeError(w, 400, runError)
|
||
return
|
||
}
|
||
var response map[string]any
|
||
var responseHeaders http.Header
|
||
var statusCode int
|
||
for round := 0; ; round++ {
|
||
modelCallCount++
|
||
statusCode, responseHeaders, response, err = h.callGatewayWithTrace(r, payload, traceID, round)
|
||
if err != nil {
|
||
runError = err.Error()
|
||
copyHeaders(w.Header(), responseHeaders)
|
||
runtimeError(w, statusCode, runError)
|
||
return
|
||
}
|
||
calls := extractToolCalls(response)
|
||
if len(calls) == 0 {
|
||
break
|
||
}
|
||
if round >= employee.MaxToolRounds {
|
||
runError = "工具调用轮次已达上限"
|
||
runtimeError(w, 502, runError)
|
||
return
|
||
}
|
||
choice := firstChoiceMessage(response)
|
||
payload["messages"] = append(payload["messages"].([]map[string]any), choice)
|
||
for _, call := range calls {
|
||
exec, exists := executors[call.Name]
|
||
if !exists {
|
||
runError = "模型请求了未授权工具 " + call.Name
|
||
runtimeError(w, 400, runError)
|
||
return
|
||
}
|
||
var args map[string]any
|
||
if json.Unmarshal([]byte(call.Arguments), &args) != nil {
|
||
args = map[string]any{}
|
||
}
|
||
result, executeErr := h.executeToolWithTrace(r.Context(), traceID, call.Name, call.ID, round, func() (map[string]any, error) {
|
||
return exec(r.Context(), args)
|
||
})
|
||
if executeErr != nil {
|
||
runError = executeErr.Error()
|
||
runtimeError(w, 502, runError)
|
||
return
|
||
}
|
||
encoded, _ := json.Marshal(result["body"])
|
||
payload["messages"] = append(payload["messages"].([]map[string]any), map[string]any{"role": "tool", "tool_call_id": call.ID, "name": call.Name, "content": string(encoded)})
|
||
toolCount++
|
||
}
|
||
}
|
||
// 事实核验与普通应用一致:block 策略不能因走数字员工入口而被绕过。
|
||
if h.factCheck != nil {
|
||
h.applyFactCheck(r, applicationRequest{Messages: input.Messages, Variables: input.Variables}, response, employee.DepartmentIDs)
|
||
}
|
||
response["digital_employee"] = map[string]any{"code": employee.Code, "name": employee.Name, "persona": employee.Persona, "retrieval_count": retrievalCount, "tool_calls": toolCount}
|
||
status = "success"
|
||
copyHeaders(w.Header(), responseHeaders)
|
||
writeRuntime(w, statusCode, response)
|
||
}
|
||
|
||
// prepareDigitalEmployee assembles the chat payload for a digital employee:
|
||
// persona + rendered skills as system context, knowledge RAG evidence, and the
|
||
// union of bound tools (regular + MCP) exposed to the model. It returns the
|
||
// tool executors keyed by the exact schema name the model may call.
|
||
func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employee DigitalEmployee, input digitalEmployeeRequest, principal apikey.Principal, retrievalCount *int, portalUserID, traceID string) (map[string]toolExecutor, map[string]any, error) {
|
||
messages := make([]map[string]any, 0, len(input.Messages)+3)
|
||
total := 0
|
||
lastQuestion := ""
|
||
for _, message := range input.Messages {
|
||
role, _ := message["role"].(string)
|
||
content, contentOK := message["content"].(string)
|
||
if (role != "user" && role != "assistant") || !contentOK {
|
||
return nil, nil, errors.New("数字员工对话只接受 user/assistant 文本消息")
|
||
}
|
||
total += len(content)
|
||
if total > 100000 {
|
||
return nil, nil, errors.New("对话历史超过 100000 字符")
|
||
}
|
||
messages = append(messages, map[string]any{"role": role, "content": content})
|
||
if role == "user" {
|
||
lastQuestion = content
|
||
}
|
||
}
|
||
if lastQuestion == "" {
|
||
return nil, nil, errors.New("至少需要一条用户消息")
|
||
}
|
||
system := []string{}
|
||
if strings.TrimSpace(employee.Persona) != "" {
|
||
system = append(system, employee.Persona)
|
||
}
|
||
selectedSkills, err := selectedBindings(input.SkillIDs, employee.SkillIDs, "Skill")
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
selectedMCPServers, err := selectedBindings(input.MCPServerIDs, employee.MCPServerIDs, "MCP")
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
skills := map[string]Skill{}
|
||
for _, skillID := range selectedSkills {
|
||
skill, err := h.market.Skills.Get(ctx, skillID)
|
||
if err != nil || !skill.Enabled || !visible(skill.DepartmentIDs, principal, false) {
|
||
return nil, nil, fmt.Errorf("绑定的 Skill %s 当前不可用", skillID)
|
||
}
|
||
rendered, err := h.market.Skills.Render(skill, input.Variables)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
system = append(system, rendered)
|
||
skills[skillID] = skill
|
||
}
|
||
// Knowledge RAG across the employee's own bases and the bases bound to its
|
||
// skills (deduplicated).
|
||
evidence := []string{}
|
||
kbSeen := map[string]bool{}
|
||
rag := func(kbID string) error {
|
||
if kbSeen[kbID] {
|
||
return nil
|
||
}
|
||
kbSeen[kbID] = true
|
||
kb, err := h.service.GetKnowledgeBase(ctx, kbID)
|
||
if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) {
|
||
return fmt.Errorf("绑定的知识库 %s 当前不可用", kbID)
|
||
}
|
||
hits, searchErr := h.searchWithTrace(ctx, traceID, kbID, lastQuestion, employee.RetrievalTopK)
|
||
if searchErr != nil {
|
||
return nil
|
||
}
|
||
for _, hit := range hits {
|
||
*retrievalCount++
|
||
evidence = append(evidence, fmt.Sprintf("[资料%d|%s]\n%s", len(evidence)+1, hit.DocumentTitle, hit.Content))
|
||
}
|
||
return nil
|
||
}
|
||
for _, kbID := range employee.KnowledgeBaseIDs {
|
||
if err := rag(kbID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
for _, skill := range skills {
|
||
for _, kbID := range skill.KnowledgeBaseIDs {
|
||
if err := rag(kbID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
}
|
||
if len(evidence) > 0 {
|
||
system = append(system, "请优先依据以下企业资料回答;资料不足时明确说明不确定,不得编造。引用时使用[资料N]。\n\n"+strings.Join(evidence, "\n\n"))
|
||
}
|
||
executors := map[string]toolExecutor{}
|
||
schemas := []map[string]any{}
|
||
addTool := func(toolID string) error {
|
||
tool, err := h.tools.Get(ctx, toolID)
|
||
if err != nil || !tool.Enabled || !visible(tool.DepartmentIDs, principal, true) {
|
||
return fmt.Errorf("绑定的工具 %s 当前不可用", toolID)
|
||
}
|
||
if _, exists := executors[tool.Code]; exists {
|
||
return nil
|
||
}
|
||
executors[tool.Code] = func(ctx context.Context, args map[string]any) (map[string]any, error) {
|
||
return h.tools.Execute(ctx, tool, args, principal.APIKeyID, gateway.RequestID(ctx))
|
||
}
|
||
var schema any
|
||
_ = json.Unmarshal(tool.InputSchema, &schema)
|
||
schemas = append(schemas, map[string]any{"type": "function", "function": map[string]any{"name": tool.Code, "description": tool.Description, "parameters": schema}})
|
||
return nil
|
||
}
|
||
addMCP := func(serverID string) error {
|
||
server, err := h.market.MCPServers.Get(ctx, serverID)
|
||
if err != nil || !server.Enabled {
|
||
return fmt.Errorf("绑定的 MCP 服务器 %s 当前不可用", serverID)
|
||
}
|
||
allowed, aerr := h.mcpAccessAllowed(ctx, principal, portalUserID, server)
|
||
if aerr != nil || !allowed {
|
||
return fmt.Errorf("绑定的 MCP 服务器 %s 不可访问", server.Code)
|
||
}
|
||
headers, err := h.market.MCPServers.Headers(server)
|
||
if err != nil {
|
||
return fmt.Errorf("绑定的 MCP 服务器 %s 凭据不可用", server.Code)
|
||
}
|
||
mcpTools, err := h.market.MCPClient.DiscoverTools(ctx, server, headers)
|
||
if err != nil {
|
||
// A bound server that is transiently unreachable must not brick the
|
||
// whole chat; skip its tools and let the employee degrade.
|
||
h.logger.Warn("digital employee MCP discovery failed", "server", server.Code, "error", err)
|
||
return nil
|
||
}
|
||
for _, tool := range mcpTools {
|
||
name := mcpToolName(server.Code, tool.Name)
|
||
if _, exists := executors[name]; exists {
|
||
continue
|
||
}
|
||
executors[name] = func(ctx context.Context, args map[string]any) (map[string]any, error) {
|
||
result, err := h.market.MCPClient.CallTool(ctx, server, headers, tool.Name, args)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if result.IsError {
|
||
return map[string]any{"body": "[MCP 工具执行失败]\n" + result.Content}, nil
|
||
}
|
||
return map[string]any{"body": result.Content}, nil
|
||
}
|
||
var schema any
|
||
_ = json.Unmarshal(tool.InputSchema, &schema)
|
||
schemas = append(schemas, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": tool.Description, "parameters": schema}})
|
||
}
|
||
return nil
|
||
}
|
||
for _, toolID := range employee.ToolIDs {
|
||
if err := addTool(toolID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
for _, skill := range skills {
|
||
for _, toolID := range skill.ToolIDs {
|
||
if err := addTool(toolID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
}
|
||
for _, serverID := range selectedMCPServers {
|
||
if err := addMCP(serverID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
for _, skill := range skills {
|
||
for _, serverID := range skill.MCPServerIDs {
|
||
if err := addMCP(serverID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
}
|
||
if len(system) > 0 {
|
||
messages = append([]map[string]any{{"role": "system", "content": strings.Join(system, "\n\n")}}, messages...)
|
||
}
|
||
payload := map[string]any{"model": employee.Model, "messages": messages, "stream": false, "temperature": employee.Temperature}
|
||
if len(schemas) > 0 {
|
||
payload["tools"] = schemas
|
||
payload["tool_choice"] = "auto"
|
||
}
|
||
return executors, payload, nil
|
||
}
|
||
|
||
// selectedBindings lets scheduled tasks restrict a digital employee to a
|
||
// subset of its published Skill/MCP bindings. Empty means the employee's full
|
||
// published binding set; callers can never add resources it does not own.
|
||
func selectedBindings(selected, allowed []string, label string) ([]string, error) {
|
||
if len(selected) == 0 {
|
||
return allowed, nil
|
||
}
|
||
allowedSet := make(map[string]bool, len(allowed))
|
||
for _, id := range allowed {
|
||
allowedSet[id] = true
|
||
}
|
||
seen := map[string]bool{}
|
||
result := make([]string, 0, len(selected))
|
||
for _, id := range selected {
|
||
if !allowedSet[id] {
|
||
return nil, fmt.Errorf("请求的 %s %s 未绑定到数字员工", label, id)
|
||
}
|
||
if !seen[id] {
|
||
seen[id] = true
|
||
result = append(result, id)
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// portalUserID resolves the portal user behind an API key, if any. Resource
|
||
// marketplace installs are scoped to portal users.
|
||
func (h *RuntimeHTTPHandler) portalUserID(ctx context.Context, principal apikey.Principal) (string, error) {
|
||
if principal.APIKeyID == "" {
|
||
return "", nil
|
||
}
|
||
userID, _, err := h.market.Market.PortalUserForAPIKey(ctx, principal.APIKeyID)
|
||
return userID, err
|
||
}
|
||
|
||
// mcpAccessible reports whether a principal may reach a published MCP server:
|
||
// either the server is department-visible to them, or they have installed it
|
||
// from the marketplace.
|
||
func (h *RuntimeHTTPHandler) mcpAccessible(r *http.Request, principal apikey.Principal, server MCPServer) bool {
|
||
portalUserID, err := h.portalUserID(r.Context(), principal)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
allowed, err := h.mcpAccessAllowed(r.Context(), principal, portalUserID, server)
|
||
return err == nil && allowed
|
||
}
|
||
|
||
func (h *RuntimeHTTPHandler) mcpAccessAllowed(ctx context.Context, principal apikey.Principal, portalUserID string, server MCPServer) (bool, error) {
|
||
if visible(server.DepartmentIDs, principal, true) {
|
||
return true, nil
|
||
}
|
||
if portalUserID == "" {
|
||
return false, nil
|
||
}
|
||
return h.market.Market.Installed(ctx, "mcp_server", server.ID, portalUserID)
|
||
}
|