From 0ea9f7d94bcf14222b2421eb4535c8379fa06b2b Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 20 Sep 2026 12:47:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=20-=20=E4=B8=A5=E6=A0=BC=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E5=B9=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=8E=20DNS=20=E9=94=99=E8=AF=AF=E8=AF=8A?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/cloud_agent_json.go | 10 +- backend/internal/app/cloud_agent_media.go | 8 +- .../app/cloud_agent_model_selection.go | 44 ++++++ .../app/cloud_agent_model_selection_test.go | 148 ++++++++++++++++++ backend/internal/app/cloud_agent_runtime.go | 3 + backend/internal/app/cloud_agent_tools.go | 6 +- backend/internal/app/error_codes.go | 1 + backend/internal/app/task_diagnostic.go | 8 +- backend/internal/app/task_diagnostic_test.go | 97 ++++++++++++ backend/internal/handler/openapi.yaml | 1 + backend/internal/handler/response_test.go | 15 ++ backend/internal/kernel/error_codes.go | 1 + backend/internal/outbound/dns_test.go | 52 ++++++ backend/internal/outbound/outbound.go | 13 +- backend/internal/service/aliases_consts.go | 1 + docs/content/docs/backend/http-api.mdx | 4 + docs/content/docs/progress/pending-test.mdx | 8 + 17 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 backend/internal/app/cloud_agent_model_selection.go create mode 100644 backend/internal/app/cloud_agent_model_selection_test.go create mode 100644 backend/internal/app/task_diagnostic_test.go create mode 100644 backend/internal/outbound/dns_test.go diff --git a/backend/internal/app/cloud_agent_json.go b/backend/internal/app/cloud_agent_json.go index e2800859a..e96a8980d 100644 --- a/backend/internal/app/cloud_agent_json.go +++ b/backend/internal/app/cloud_agent_json.go @@ -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) } diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 8b4e68712..59772bb20 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -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 diff --git a/backend/internal/app/cloud_agent_model_selection.go b/backend/internal/app/cloud_agent_model_selection.go new file mode 100644 index 000000000..91998a249 --- /dev/null +++ b/backend/internal/app/cloud_agent_model_selection.go @@ -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 + } +} diff --git a/backend/internal/app/cloud_agent_model_selection_test.go b/backend/internal/app/cloud_agent_model_selection_test.go new file mode 100644 index 000000000..ffab34bbc --- /dev/null +++ b/backend/internal/app/cloud_agent_model_selection_test.go @@ -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") + } +} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index ed6b8f94b..b8a53655d 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -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"): diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index dfcc097d3..88ae609bf 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -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} diff --git a/backend/internal/app/error_codes.go b/backend/internal/app/error_codes.go index 35e129521..9e8f0ed0f 100644 --- a/backend/internal/app/error_codes.go +++ b/backend/internal/app/error_codes.go @@ -35,6 +35,7 @@ const ( ReasonTimeout = kernel.ReasonTimeout ReasonInternal = kernel.ReasonInternal ReasonBadGateway = kernel.ReasonBadGateway + ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed ) func ReasonForStatus(status int) ErrorReason { diff --git a/backend/internal/app/task_diagnostic.go b/backend/internal/app/task_diagnostic.go index db408f1df..885fd3d51 100644 --- a/backend/internal/app/task_diagnostic.go +++ b/backend/internal/app/task_diagnostic.go @@ -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 } diff --git a/backend/internal/app/task_diagnostic_test.go b/backend/internal/app/task_diagnostic_test.go new file mode 100644 index 000000000..8bf0a0876 --- /dev/null +++ b/backend/internal/app/task_diagnostic_test.go @@ -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") + } + } +} diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 67ac63951..61f5dc80e 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -1203,6 +1203,7 @@ components: - timeout - internal - bad_gateway + - upstream_dns_failed - model_capability_not_supported - model_price_not_configured - model_route_unavailable diff --git a/backend/internal/handler/response_test.go b/backend/internal/handler/response_test.go index e28c01029..0a0891cf7 100644 --- a/backend/internal/handler/response_test.go +++ b/backend/internal/handler/response_test.go @@ -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 个上限")) diff --git a/backend/internal/kernel/error_codes.go b/backend/internal/kernel/error_codes.go index c22523537..f5dfe80d1 100644 --- a/backend/internal/kernel/error_codes.go +++ b/backend/internal/kernel/error_codes.go @@ -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 { diff --git a/backend/internal/outbound/dns_test.go b/backend/internal/outbound/dns_test.go new file mode 100644 index 000000000..130b21ce4 --- /dev/null +++ b/backend/internal/outbound/dns_test.go @@ -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) + } +} diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 860f60b40..583318d60 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -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 { diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index 211666984..51d610eb9 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -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 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 9dbc329a4..3641a0d95 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -9,6 +9,8 @@ description: 信封、错误码与 OpenAPI 机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。 +出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。 + ## 站点皮肤主按钮 `GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。 @@ -132,6 +134,8 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 - `generate_media.prompt` 使用 `@图片1`、`@视频1`、`@音频1` 引用素材,各类型按 `referenceNodeIds` 的出现顺序独立编号,文本来源不占媒体编号。已有标签保留;遗漏的画布素材以“素材名称:@标签”补入 `【资产参考】`,不推断素材用途。节点入边同步排列,草稿、审批和实际任务保存同一份带标签的提示词。未绑定的媒体编号和补齐后超过16000字符的提示词会在提交前被拒绝,模型自身的提示词长度限制仍由任务准入校验。临时标注图没有持久画布节点,不自动添加无法解析的编辑器标签;历史节点不自动改写。 - Agent 的 `snapshotHash` 忽略顶层 `viewport`、`updatedAt`。`canvas_get_state` 另返回 `mediaSnapshotHash`,供 `generate_media.snapshotHash` 使用:仅比较节点与连线,节点忽略 `position`、`width`、`height`、`createdAt`、`updatedAt`,保留全部业务字段及未知 metadata。生成草稿审批也固定该哈希,移动、调整显示尺寸、自动补时间戳和保存聊天记录不会使生成审批失效,执行保留最新展示信息;提示词、素材、锁定状态、任务绑定和连线变化仍需重新读取并审批。画布修改与撤销继续使用完整内容哈希,数据库写入保持完整快照 CAS。发布前已按旧算法生成的待审批内容可能要求重新读取和审批,不自动替换旧快照重提任务。 - `canvas_get_state.nodes[].outputReference` 的 `ready` / `issue` 仅表示该节点产物能否作为其他生成的参考,不诊断本节点的生成输入。`generation` 返回当前画布、当前用户所属任务的 `taskId`、`taskStatus` 和安全的失败 / 取消 `error`;无任务绑定为 `not_submitted`,无法验证归属或读取失败为 `unavailable`。节点无法作为草稿提交时返回 `submitBlockedReason` / `submitBlockedIssue`,与写路径共用节点准入规则;未被阻止不等于已获授权,草稿所属运行和审批仍在提交时校验。`task_get` 同样返回安全错误详情。`generate_media` 准入失败可返回 `snapshot_conflict`、`task_bound`、`target_locked` 等结构化 `reason` 和 `nodeId`,不自动选择新节点或重新生成。 +- `task_get` / `generation` 的执行状态与回写状态分开:`succeeded` 不返回执行 `error`;回写失败仍通过 `writebackOutcome` / `writebackError` 暴露,不能因生成成功而隐藏。失败 / 取消任务保留脱敏错误,缺少详情不能伪装成成功。Agent 文本步骤的持久诊断为 `upstream_dns_failed` 时,`run_failed.reason` 同步该分类并明确提示检查渠道域名及后端 DNS,不自动重跑。 +- `generate_media` / `image_layer_split` 必须将 `model_list.selection` 展开到顶层参数:提供非空 `logicalModelId`,或完整的非空 `channelId` + `channelModelKey`,不得混用;未使用的字段可省略或传空字符串,不能传 `null` 或仅含空白的字符串。后端在读取画布、创建草稿、审批与计费前校验选择结构;返回 `reason: invalid_tool_arguments`、`field`、`issue` 和本轮工具 `parameters`,区分 `required`、`mutually_exclusive`、`type_mismatch`、`invalid_value`。合法结构不代表模型可用,仍须通过目录、能力、权限和价格校验;不猜测模型、不补全选择、不切换渠道,也不吞掉未知字段。 - `canvas_apply_ops` 创建节点、编辑提示词或连线不会提交生成任务,也不会改变已提交任务的输入;收费生成独立使用 `generate_media`。操作参数错误按数组项返回 `field`(例如 `ops[0].snapshotHash`)和 `issue`(例如 `unexpected_field`、`type_mismatch`、`required`),并附本轮工具 schema;不把未知字段误报成缺少其他必填字段,不回显任意参数键和值。 - `read_only` 只允许读取;`request_approval` 对写画布和媒体生成暂停为 `waiting_approval`;`auto` 可在预算内执行已授权画布操作,但图片 / 视频生成始终独立审批,不能绕过。`maxGenerationTasks` / `maxVideoSeconds` 为非负整数,0 表示该项不设业务上限,正数按本轮累计执行;不再固定 8 次模型调用、8 个生成任务或 120 秒视频。积分上限仍必填且大于 0,实际报价在任务创建与预留积分前校验,时长必须符合所选模型的实际能力。失败事件保留具体错误和已有任务的 `taskId`、`nodeId`、`status`;媒体结果的 `phase` 区分 `admission` 与 `completion`,`taskSubmitted` 表达是否已提交。已提交的收费生成失败作为工具错误交回模型,整轮保持可继续,不得自动重试收费生成;只有生成结果无法回写画布时才把整轮标为 `failed`。模型应报告错误,不能自行删减提示词。 - 必须有当前用户已保存的画布。首轮摘要最多取前 80 个节点,并按约 60KB 预算截断;装不下的节点记入 `omittedNodes`,不因摘要过大整轮拒绝。`canvas_get_state` 可按 `offset` 每页读取 40 个节点、资产可引用状态及相关连线,按 `nodeIds` 精读最多 8 个节点(正文每字段最多 16000 字符),通过 `hasMore` / `nextOffset` 和字段 `Truncated` 判断是否完整。分镜表包含镜头、角色节点与资产绑定;摘要每页最多 20 行,精读每次 1 行,可用 `storyboardOffset` 翻页;单行叙事文本累计最多 24000 字符,角色 / 绑定各最多 16 项。上下文不含媒体 URL、任意元数据或未同步修改;节点名称不等于图像视觉识别。普通任务重试接口不能重试 Agent 任务。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 2f3c4403c..b1d4bcd10 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## Agent 任务诊断与模型选择强校验 + +- 已修复:成功任务不再由空错误文本生成“任务未完成”;执行失败与回写失败独立保留。模型选择缺失、不完整、混用、`null` 和空白值在读画布及产生草稿 / 审批 / 收费任务前拒绝,返回字段级修正信息;未知字段继续拒绝,不自动选择模型或重放收费任务。 +- 已修复:出站 DNS 失败使用 `502 / upstream_dns_failed`,保留内部原因链且不公开请求地址;Agent 按持久诊断展示 DNS 原因,不修改代理、解析器、私网放行或自动重试策略。 +- [x] 本地专项回归:Agent 参数与执行流程、任务诊断、出站 DNS / 取消 / SSRF、HTTP 错误脱敏通过;缺失模型选择不会新增任务、账单、审批或画布草稿。 +- [ ] 全量回归发现 `TestDeleteGeneratedAssetTaskReferences/failed_output` 查询 `resource_deletion_jobs` 时遇到 SQLite 表锁;该用例单独复跑通过,未修改资源删除逻辑或增加重试,仍需排查并发稳定性。 +- [ ] 部署后检查真实模型渠道域名及后端 DNS;在真实 Agent 会话中验证失败提示、纠正参数后重新审批,以及成功任务与回写失败的分离展示。本地回归不等于部署 DNS 已恢复,也不替代真实供应商与浏览器验收。 + ## 画布 Agent 入口拖动 - [x] 右下角 Agent 图标支持鼠标和触屏拖动,使用指针捕获与 6px 阈值区分点击和拖动;拖动结束不打开面板,中断或失去捕获时结束手势,不传播到画布。