fix(canvas): 画布智能体 - 严格校验模型选择并修正任务与 DNS 错误诊断
This commit is contained in:
@@ -26,7 +26,7 @@ type cloudAgentFieldArgumentError struct {
|
||||
|
||||
func (e *cloudAgentFieldArgumentError) Unwrap() error { return e.error }
|
||||
|
||||
func cloudAgentCanvasFieldError(field, issue, message string) error {
|
||||
func cloudAgentFieldError(field, issue, message string) error {
|
||||
return &cloudAgentFieldArgumentError{
|
||||
error: &cloudAgentArgumentError{BadAuthRequest(message)}, Field: field, Issue: issue,
|
||||
}
|
||||
@@ -45,10 +45,10 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
|
||||
return args, cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
if envelope.SnapshotHash == "" {
|
||||
return args, cloudAgentCanvasFieldError("snapshotHash", "required", "画布参数 snapshotHash 不能为空,请先读取画布")
|
||||
return args, cloudAgentFieldError("snapshotHash", "required", "画布参数 snapshotHash 不能为空,请先读取画布")
|
||||
}
|
||||
if len(envelope.Ops) < 1 || len(envelope.Ops) > 20 {
|
||||
return args, cloudAgentCanvasFieldError("ops", "item_count", "画布参数 ops 必须包含1到20项操作")
|
||||
return args, cloudAgentFieldError("ops", "item_count", "画布参数 ops 必须包含1到20项操作")
|
||||
}
|
||||
args.SnapshotHash = envelope.SnapshotHash
|
||||
for i, rawOp := range envelope.Ops {
|
||||
@@ -74,7 +74,7 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return args, cloudAgentCanvasFieldError(field, issue, fmt.Sprintf("画布参数 %s 无效:%s", field, cloudAgentSafeToolError(cloudAgentJSONArgumentError(err))))
|
||||
return args, cloudAgentFieldError(field, issue, fmt.Sprintf("画布参数 %s 无效:%s", field, cloudAgentSafeToolError(cloudAgentJSONArgumentError(err))))
|
||||
}
|
||||
required := ""
|
||||
switch {
|
||||
@@ -93,7 +93,7 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
|
||||
}
|
||||
if required != "" {
|
||||
field := path + "." + required
|
||||
return args, cloudAgentCanvasFieldError(field, "required", fmt.Sprintf("画布参数 %s 不能为空", field))
|
||||
return args, cloudAgentFieldError(field, "required", fmt.Sprintf("画布参数 %s 不能为空", field))
|
||||
}
|
||||
args.Ops = append(args.Ops, op)
|
||||
}
|
||||
|
||||
@@ -538,7 +538,10 @@ func (s *Service) fillCloudAgentMediaSnapshotHash(userID, canvasID string, a *cl
|
||||
func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) (CreateTaskRequest, *cloudAgentMediaPlan, error) {
|
||||
var a cloudAgentMediaArgs
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil {
|
||||
return CreateTaskRequest{}, nil, BadAuthRequest("生成参数必须是只含支持字段的单个JSON对象")
|
||||
return CreateTaskRequest{}, nil, cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
if err := validateCloudAgentModelSelection(call.Function.Arguments, a); err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
}
|
||||
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
|
||||
a.DraftRunID = run.ID
|
||||
@@ -551,9 +554,6 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
|
||||
if err := validateCloudAgentMediaArgs(a, state); err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
}
|
||||
if (a.LogicalModelID == "" && (a.ChannelID == "" || a.ChannelModelKey == "")) || (a.LogicalModelID != "" && (a.ChannelID != "" || a.ChannelModelKey != "")) {
|
||||
return CreateTaskRequest{}, nil, BadAuthRequest("请复制 model_list 的 selection:逻辑模型或系统渠道二选一,不得混用")
|
||||
}
|
||||
_, _, refs, err := cloudAgentMediaDocument(s.repo, run.UserID, state.Request.CanvasID, a, state.TransientReferences)
|
||||
if err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
|
||||
44
backend/internal/app/cloud_agent_model_selection.go
Normal file
44
backend/internal/app/cloud_agent_model_selection.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Keep cross-field validation server-side rather than adding provider-specific
|
||||
// root schema combinators. The same contract is advertised by both media tools.
|
||||
const cloudAgentModelSelectionDescription = "模型选择必填:将 model_list 返回的一种 selection 展开到顶层参数,必须提供非空 logicalModelId,或同时提供非空 channelId 和 channelModelKey,二者互斥。未使用的选择字段省略或传空字符串,不得传 null 或仅含空白的字符串。缺失、混用或不完整均在提交前拒绝,不会自动选择或切换模型。"
|
||||
|
||||
func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
|
||||
// Go's JSON decoder accepts null for string fields; the tool contract does
|
||||
// not. Check presence/type before interpreting the decoded selection.
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
|
||||
if value, exists := fields[field]; exists {
|
||||
var text *string
|
||||
if err := json.Unmarshal(value, &text); err != nil || text == nil {
|
||||
return cloudAgentFieldError(field, "type_mismatch", "模型选择字段必须是字符串,不能为 null")
|
||||
}
|
||||
if *text != "" && strings.TrimSpace(*text) == "" {
|
||||
return cloudAgentFieldError(field, "invalid_value", "模型选择字段不能仅包含空白字符")
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case a.LogicalModelID != "" && (a.ChannelID != "" || a.ChannelModelKey != ""):
|
||||
return cloudAgentFieldError("logicalModelId", "mutually_exclusive", "模型选择冲突:logicalModelId 与 channelId/channelModelKey 不得混用,请复制 model_list 的一种 selection")
|
||||
case a.LogicalModelID != "":
|
||||
return nil
|
||||
case a.ChannelID == "" && a.ChannelModelKey == "":
|
||||
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:必须复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey,不会自动选择模型")
|
||||
case a.ChannelID == "":
|
||||
return cloudAgentFieldError("channelId", "required", "系统渠道模型选择不完整:缺少 channelId,请复制 model_list 的完整 selection")
|
||||
case a.ChannelModelKey == "":
|
||||
return cloudAgentFieldError("channelModelKey", "required", "系统渠道模型选择不完整:缺少 channelModelKey,请复制 model_list 的完整 selection")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
148
backend/internal/app/cloud_agent_model_selection_test.go
Normal file
148
backend/internal/app/cloud_agent_model_selection_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentModelSelectionRejectsBeforeCanvasRead(t *testing.T) {
|
||||
for _, tc := range []struct{ name, args, field, issue string }{
|
||||
{"missing", `{}`, "logicalModelId", "required"},
|
||||
{"empty", `{"logicalModelId":"","channelId":"","channelModelKey":""}`, "logicalModelId", "required"},
|
||||
{"missing channel", `{"channelModelKey":"image"}`, "channelId", "required"},
|
||||
{"missing key", `{"channelId":"channel"}`, "channelModelKey", "required"},
|
||||
{"mixed", `{"logicalModelId":"model","channelId":"channel","channelModelKey":"image"}`, "logicalModelId", "mutually_exclusive"},
|
||||
{"partial mixed", `{"logicalModelId":"model","channelModelKey":"image"}`, "logicalModelId", "mutually_exclusive"},
|
||||
{"whitespace", `{"channelId":" \t","channelModelKey":"image"}`, "channelId", "invalid_value"},
|
||||
{"null", `{"logicalModelId":null,"channelId":"channel","channelModelKey":"image"}`, "logicalModelId", "type_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
call := cloudAgentCall{ID: "invalid"}
|
||||
call.Function.Name, call.Function.Arguments = "generate_media", tc.args
|
||||
// No service/repository: rejection must precede any read or side effect.
|
||||
var s *Service
|
||||
_, _, err := s.prepareCloudAgentMedia(nil, nil, call)
|
||||
var fieldErr *cloudAgentFieldArgumentError
|
||||
if !errors.As(err, &fieldErr) || fieldErr.Field != tc.field || fieldErr.Issue != tc.issue {
|
||||
t.Fatalf("unexpected validation: %#v", err)
|
||||
}
|
||||
req := agentTestRequest()
|
||||
req.PermissionMode = "auto"
|
||||
state := &cloudAgentRuntime{Canonical: canonicalAgentRequest{Tools: cloudAgentTools(req)}}
|
||||
cloudAgentToolResult("run", state, call, map[string]any{"phase": "admission", "taskSubmitted": false}, err)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(state.Canonical.Messages[0]["content"].(string)), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["field"] != tc.field || result["issue"] != tc.issue || result["reason"] != "invalid_tool_arguments" || result["parameters"] == nil || result["taskSubmitted"] != false {
|
||||
t.Fatalf("missing strict repair feedback: %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentModelSelectionAcceptsOnlyCompleteSelections(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"logicalModelId":"model"}`,
|
||||
`{"logicalModelId":"model","channelId":"","channelModelKey":""}`,
|
||||
`{"channelId":"channel","channelModelKey":"image"}`,
|
||||
`{"logicalModelId":"","channelId":"channel","channelModelKey":"image"}`,
|
||||
} {
|
||||
var args cloudAgentMediaArgs
|
||||
if err := decodeCloudAgentJSONObject(raw, &args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCloudAgentModelSelection(raw, args); err != nil {
|
||||
t.Fatalf("valid selection rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentMediaMalformedJSONKeepsStrictFeedback(t *testing.T) {
|
||||
for _, raw := range []string{`{"unknown":"private-sentinel"}`, `{"channelId":3}`, `{} {}`, `null`} {
|
||||
call := cloudAgentCall{}
|
||||
call.Function.Arguments = raw
|
||||
var s *Service
|
||||
_, _, err := s.prepareCloudAgentMedia(nil, nil, call)
|
||||
var argumentErr *cloudAgentArgumentError
|
||||
if !errors.As(err, &argumentErr) || strings.Contains(err.Error(), "private-sentinel") {
|
||||
t.Fatalf("malformed arguments lost safe schema error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentMediaToolsDeclareExclusiveModelSelection(t *testing.T) {
|
||||
count := 0
|
||||
req := agentTestRequest()
|
||||
req.PermissionMode = "auto"
|
||||
for _, tool := range cloudAgentTools(req) {
|
||||
function := tool["function"].(map[string]any)
|
||||
if function["name"] != "generate_media" && function["name"] != "image_layer_split" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
parameters := function["parameters"].(map[string]any)
|
||||
if !strings.Contains(function["description"].(string), cloudAgentModelSelectionDescription) || parameters["additionalProperties"] != false {
|
||||
t.Fatalf("incomplete model selection schema: %#v", parameters)
|
||||
}
|
||||
props := parameters["properties"].(map[string]any)
|
||||
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
|
||||
property := props[field].(map[string]any)
|
||||
if property["type"] != "string" {
|
||||
t.Fatalf("model field contract drift: %s %#v", field, property)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("missing media tools: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentMissingModelCreatesNoDraftApprovalOrTask(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
args.ChannelID, args.ChannelModelKey = "", ""
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
var before int64
|
||||
if err := db.Model(&model.Task{}).Count(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var billingBefore int64
|
||||
if err := db.Model(&model.BillingOrder{}).Count(&billingBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ = s.repo.CloudAgent("user", run.ID)
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterCanvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
var after int64
|
||||
if err := db.Model(&model.Task{}).Count(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var billingAfter int64
|
||||
if err := db.Model(&model.BillingOrder{}).Count(&billingAfter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != after || billingBefore != billingAfter || canvas.PayloadJSON != afterCanvas.PayloadJSON || state.Approval != nil || state.MediaTaskID != "" {
|
||||
t.Fatal("invalid selection created a task, draft, approval or billing order")
|
||||
}
|
||||
found := false
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "tool_failed" {
|
||||
result, _ := event.Payload["result"].(map[string]any)
|
||||
found = result["reason"] == "invalid_tool_arguments" && result["taskSubmitted"] == false
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("admission failure was silently hidden")
|
||||
}
|
||||
}
|
||||
@@ -847,6 +847,9 @@ func failCloudAgentAdmission(current *model.CloudAgentExecution, state *cloudAge
|
||||
// Only expose known failure categories; raw provider errors can contain URLs and credentials.
|
||||
func cloudAgentModelFailure(task *model.Task) (string, string) {
|
||||
detail, reason := "模型任务未成功", "model_task_failed"
|
||||
if diagnostic := taskExecutionDiagnostic(task); diagnostic != nil && diagnostic.Code == string(ReasonUpstreamDNSFailed) {
|
||||
return "模型服务域名解析失败,请检查渠道域名和后端 DNS 配置;本轮已停止。请在任务中心检查模型任务 " + task.ID, string(ReasonUpstreamDNSFailed)
|
||||
}
|
||||
raw := strings.ToLower(task.Error)
|
||||
switch {
|
||||
case strings.Contains(raw, "connection reset by peer"):
|
||||
|
||||
@@ -120,7 +120,11 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
|
||||
if required == nil {
|
||||
required = []string{}
|
||||
}
|
||||
tools = append(tools, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": description, "parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}}})
|
||||
parameters := map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}
|
||||
if name == "generate_media" || name == "image_layer_split" {
|
||||
description += " " + cloudAgentModelSelectionDescription
|
||||
}
|
||||
tools = append(tools, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": description, "parameters": parameters}})
|
||||
}
|
||||
str := func(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
ReasonTimeout = kernel.ReasonTimeout
|
||||
ReasonInternal = kernel.ReasonInternal
|
||||
ReasonBadGateway = kernel.ReasonBadGateway
|
||||
ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed
|
||||
)
|
||||
|
||||
func ReasonForStatus(status int) ErrorReason {
|
||||
|
||||
@@ -84,7 +84,13 @@ func taskExecutionDiagnostic(task *model.Task) *model.TaskExecutionDiagnostic {
|
||||
if json.Unmarshal([]byte(task.ExecutionDiagnosticJSON), &diagnostic) != nil {
|
||||
return nil
|
||||
}
|
||||
diagnostic.SafeMessage = safeTaskDiagnosticMessage(diagnostic.SafeMessage)
|
||||
// Execution success and writeback failure are separate facts. A successful
|
||||
// task has no execution error; writeback errors remain in WritebackMessage.
|
||||
if task.Status == model.TaskStatusSucceeded {
|
||||
diagnostic.SafeMessage = ""
|
||||
} else if diagnostic.SafeMessage != "" || task.Status == model.TaskStatusFailed || task.Status == model.TaskStatusCancelled {
|
||||
diagnostic.SafeMessage = safeTaskDiagnosticMessage(diagnostic.SafeMessage)
|
||||
}
|
||||
return &diagnostic
|
||||
}
|
||||
|
||||
|
||||
97
backend/internal/app/task_diagnostic_test.go
Normal file
97
backend/internal/app/task_diagnostic_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestTaskDiagnosticKeepsExecutionAndWritebackFailuresSeparate(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
for _, tc := range []struct {
|
||||
name, outcome, storedMessage, wantError string
|
||||
status model.TaskStatus
|
||||
writebackErr error
|
||||
}{
|
||||
{"success", "succeeded", "", "", model.TaskStatusSucceeded, nil},
|
||||
{"success stale message", "succeeded", "任务未完成,请在任务中心查看诊断", "", model.TaskStatusSucceeded, nil},
|
||||
{"writeback failed", "failed", "", "", model.TaskStatusSucceeded, BadAuthRequest("结果无法回写画布")},
|
||||
{"execution failed", "succeeded", "上游拒绝请求", "上游拒绝请求", model.TaskStatusFailed, nil},
|
||||
{"failed without detail", "succeeded", "", "任务未完成,请在任务中心查看诊断", model.TaskStatusFailed, nil},
|
||||
{"cancelled", "succeeded", "任务已取消", "任务已取消", model.TaskStatusCancelled, nil},
|
||||
{"sensitive", "succeeded", "https://private-sentinel.invalid?token=private-sentinel", "任务未完成,请在任务中心查看诊断", model.TaskStatusFailed, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diagnostic := model.TaskExecutionDiagnostic{Code: "task_terminal", Phase: "completion", SafeMessage: tc.storedMessage}
|
||||
raw, err := json.Marshal(diagnostic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &model.Task{ID: tc.name, UserID: "user", ProjectID: "agent-canvas", Status: tc.status, ExecutionDiagnosticJSON: string(raw)}
|
||||
recordTaskWritebackDiagnostic(task, "node", tc.outcome, "", tc.writebackErr)
|
||||
if err := db.Create(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := cloudAgentCall{ID: "task-query"}
|
||||
call.Function.Name, call.Function.Arguments = "task_get", `{"taskId":"`+task.ID+`"}`
|
||||
result, err := cloudAgentReadTool(s.repo, "user", &cloudAgentRuntime{Request: agentTestRequest()}, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
facts := result.(map[string]any)
|
||||
if tc.wantError == "" {
|
||||
if _, exists := facts["error"]; exists {
|
||||
t.Fatalf("successful task contains error: %#v", facts)
|
||||
}
|
||||
} else if facts["error"] != tc.wantError {
|
||||
t.Fatalf("failure detail lost: %#v", facts)
|
||||
}
|
||||
if facts["writebackOutcome"] != tc.outcome || (tc.writebackErr != nil && facts["writebackError"] != "结果无法回写画布") {
|
||||
t.Fatalf("writeback facts lost: %#v", facts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDiagnosticSuccessWithoutStoredDiagnosticHasNoError(t *testing.T) {
|
||||
task := &model.Task{ID: "success", Status: model.TaskStatusSucceeded}
|
||||
recordTaskWritebackDiagnostic(task, "node", "succeeded", "", nil)
|
||||
for range 2 {
|
||||
facts := cloudAgentTaskDiagnostic(nil, task)
|
||||
if _, exists := facts["error"]; exists {
|
||||
t.Fatalf("successful writeback fabricated an error: %#v", facts)
|
||||
}
|
||||
recordTaskWritebackDiagnostic(task, "node", "succeeded", "", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentDNSDiagnosticSurvivesTransportWrapping(t *testing.T) {
|
||||
dnsCause := &net.DNSError{Err: "private-sentinel resolver failure", Name: "private-sentinel.invalid", IsTimeout: true}
|
||||
dnsErr := WrapAppError(CodeBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", dnsCause)
|
||||
dnsErr.Reason = ReasonUpstreamDNSFailed
|
||||
transportErr := &url.Error{Op: "Post", URL: "https://private-sentinel.invalid?token=private-sentinel", Err: dnsErr}
|
||||
for _, cause := range []error{mapOutboundError(dnsErr), transportErr} {
|
||||
if !errors.Is(cause, dnsCause) {
|
||||
t.Fatal("internal DNS cause lost")
|
||||
}
|
||||
task := &model.Task{ID: "dns-task", Status: model.TaskStatusFailed, Error: providerUserFacingErrorMessage(cause)}
|
||||
recordTaskDiagnostic(task, "execution", "provider_execution_failed", "unknown", "reconcile_submission", cause)
|
||||
facts := cloudAgentTaskDiagnostic(nil, task)
|
||||
if facts["diagnosticCode"] != string(ReasonUpstreamDNSFailed) || facts["submissionOutcome"] != "unknown" || facts["retryClass"] != "reconcile_submission" {
|
||||
t.Fatalf("DNS classification or submission safety changed: %#v", facts)
|
||||
}
|
||||
message, reason := cloudAgentModelFailure(task)
|
||||
if reason != string(ReasonUpstreamDNSFailed) || !strings.Contains(message, "域名解析失败") || !strings.Contains(message, task.ID) {
|
||||
t.Fatalf("DNS cause hidden in generic failure: %s %s", reason, message)
|
||||
}
|
||||
raw, _ := json.Marshal(facts)
|
||||
if strings.Contains(string(raw)+message, "private-sentinel") {
|
||||
t.Fatal("raw DNS/transport details leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1203,6 +1203,7 @@ components:
|
||||
- timeout
|
||||
- internal
|
||||
- bad_gateway
|
||||
- upstream_dns_failed
|
||||
- model_capability_not_supported
|
||||
- model_price_not_configured
|
||||
- model_route_unavailable
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -40,6 +41,20 @@ func TestFailServiceProjectsAppError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceProjectsDNSFailureWithoutTransportDetails(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
err := service.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", errors.New("private-sentinel resolver failure"))
|
||||
err.Reason = service.ReasonUpstreamDNSFailed
|
||||
failService(context, &url.Error{Op: "Post", URL: "https://private-sentinel.invalid?token=private-sentinel", Err: err})
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusBadGateway || response.Code != service.CodeBadGateway || response.Reason != string(service.ReasonUpstreamDNSFailed) || response.Msg != err.Message {
|
||||
t.Fatalf("DNS response contract lost: %d %#v", recorder.Code, response)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "private-sentinel") {
|
||||
t.Fatal("raw transport details leaked into HTTP response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceQuotaExceeded(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failService(context, service.QuotaExceeded("账号素材数量已达到 100 个上限"))
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
ReasonTimeout ErrorReason = "timeout"
|
||||
ReasonInternal ErrorReason = "internal"
|
||||
ReasonBadGateway ErrorReason = "bad_gateway"
|
||||
ReasonUpstreamDNSFailed ErrorReason = "upstream_dns_failed"
|
||||
)
|
||||
|
||||
func ReasonForStatus(status int) ErrorReason {
|
||||
|
||||
52
backend/internal/outbound/dns_test.go
Normal file
52
backend/internal/outbound/dns_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
)
|
||||
|
||||
func TestOutboundDNSFailurePreservesCauseWithoutExposingHost(t *testing.T) {
|
||||
previous := net.DefaultResolver
|
||||
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("private-sentinel DNS transport unavailable")
|
||||
}}
|
||||
t.Cleanup(func() { net.DefaultResolver = previous })
|
||||
|
||||
for _, validate := range []func(string) error{ValidateOutboundHost, validateCustomRelayHost} {
|
||||
err := validate("private-sentinel.invalid")
|
||||
var appErr *kernel.AppError
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &appErr) || appErr.Status != http.StatusBadGateway || appErr.Reason != kernel.ReasonUpstreamDNSFailed || appErr.Retryable {
|
||||
t.Fatalf("incorrect DNS failure classification: %#v", err)
|
||||
}
|
||||
if !errors.As(err, &dnsErr) {
|
||||
t.Fatal("DNS cause was discarded")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private-sentinel") {
|
||||
t.Fatal("DNS details leaked into safe message")
|
||||
}
|
||||
}
|
||||
// Invalid destinations remain policy failures, never DNS/network failures.
|
||||
for _, host := range []string{"", "localhost", "127.0.0.1", "10.0.0.1"} {
|
||||
_, err := resolveOutboundHostWithPolicy(context.Background(), host, false)
|
||||
var badRequest *BadRequestError
|
||||
if !errors.As(err, &badRequest) {
|
||||
t.Fatalf("SSRF validation weakened for %q: %v", host, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundDNSCancellationKeepsCancellationSemantics(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := resolveOutboundHostWithPolicy(ctx, "cancelled.invalid", false)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation became a DNS failure: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
@@ -341,10 +343,17 @@ func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivat
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("外部服务域名解析失败")
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
failure := kernel.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", err)
|
||||
failure.Reason = kernel.ReasonUpstreamDNSFailed
|
||||
return nil, failure
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, BadAuthRequest("外部服务域名没有可用地址")
|
||||
failure := kernel.NewAppError(http.StatusBadGateway, "外部服务域名没有可用地址,请检查渠道域名和后端 DNS 配置")
|
||||
failure.Reason = kernel.ReasonUpstreamDNSFailed
|
||||
return nil, failure
|
||||
}
|
||||
if !allowPrivateHost {
|
||||
for _, ip := range addresses {
|
||||
|
||||
@@ -65,6 +65,7 @@ const (
|
||||
PluginScopeSystem = app.PluginScopeSystem
|
||||
PluginScopeUser = app.PluginScopeUser
|
||||
ReasonBadGateway = app.ReasonBadGateway
|
||||
ReasonUpstreamDNSFailed = app.ReasonUpstreamDNSFailed
|
||||
ReasonConflict = app.ReasonConflict
|
||||
ReasonFailedPrecondition = app.ReasonFailedPrecondition
|
||||
ReasonForbidden = app.ReasonForbidden
|
||||
|
||||
Reference in New Issue
Block a user