Files
ai-gateway-go/internal/workbench/runtime_marketplace.go
T
superidou 9501751792 0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(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 币种维度)
2026-08-13 10:50:51 +08:00

486 lines
17 KiB
Go
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.
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)
}