diff --git a/docs/integration/heicode-desktop-sub-agile-api.md b/docs/integration/heicode-desktop-sub-agile-api.md index 6ce538d..6516cd8 100644 --- a/docs/integration/heicode-desktop-sub-agile-api.md +++ b/docs/integration/heicode-desktop-sub-agile-api.md @@ -1373,7 +1373,7 @@ summary-only 示例片段: "key": "backend", "display_name": "Backend Agnet", "summary": "实现后端接口、数据模型和服务逻辑", - "default_model": "agnet-model-backend", + "default_model": "gpt-5.4", "default_permissions": ["repo:read", "repo:write"], "risk_level": "medium" } @@ -1382,6 +1382,8 @@ summary-only 示例片段: } ``` +> `default_model` 由 Manager 单一来源 `defaultAgnetModelID()` 提供(环境变量 `AGNET_DEFAULT_MODEL_ID`,默认生产已验证的 `gpt-5.4`)。Manager 不再回退 `agnet-model-` 占位名;客户端如不指定 `default_model_id`,draft/runtime agent 会自动采用该单一默认值。 + ## 12. 状态枚举 ### HeicodeTask.status @@ -1402,9 +1404,12 @@ summary-only 示例片段: |---|---| | `accepted` | Manager 已接受并落本地记录 | | `running` | Runtime 已开始执行 | +| `completed` | Runtime 回调 `deployment.status_changed: completed` 后的成功终态(**真实终态,客户端状态机必须覆盖**;注意需结合 §15.1 成功四要素判断是否为有效交付,`completed` 本身不代表有业务产物) | | `stopped` | 已停止 | | `failed` | 失败 | +> 注意:`status` 由 Manager 接收 Runtime `deployment.status_changed` 后镜像写入。除上述规范值外,Runtime 若回传其他自定义状态字符串,Manager 会透传保存,客户端应对未知值做兜底(按非终态处理或显示原值)。 + ### Agnet Deployment.runtime_state | 状态 | 说明 | @@ -1414,6 +1419,7 @@ summary-only 示例片段: | `runtime_accepted` | Runtime 接受 | | `runtime_sync_failed` | Runtime 同步失败 | | `not_configured` | 生产 Runtime 未配置 | +| `running` / `completed` / `failed` / `stopped` | Runtime 回调后 `runtime_state` 会镜像 Runtime 上报的状态值;`phase.changed` 还会把 `checkpoint` 写入此字段。客户端不要假设其取值封闭,应做兜底 | ### 普通 sub 子环节建议值 diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index 81508e0..ec04ad5 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -535,7 +535,7 @@ func containsString(values []string, target string) bool { } func containsSensitiveGrantField(values map[string]string) bool { - for key := range values { + for key, val := range values { normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_")) if strings.Contains(normalized, "password") || strings.Contains(normalized, "token") || @@ -545,6 +545,9 @@ func containsSensitiveGrantField(values map[string]string) bool { strings.Contains(normalized, "credential") { return true } + if valueLooksLikeSecret(val) { + return true + } } return false } diff --git a/heicode/controller/agnet_deliverable_secret_test.go b/heicode/controller/agnet_deliverable_secret_test.go new file mode 100644 index 0000000..cd801ae --- /dev/null +++ b/heicode/controller/agnet_deliverable_secret_test.go @@ -0,0 +1,152 @@ +package controller + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// P2: deliverable judgment must read structured artifact_type / file signals, +// not just the legacy title/summary/uri string heuristics. +func TestRuntimeArtifactsAreSummaryOnly_StructuredFields(t *testing.T) { + cases := []struct { + name string + artifacts []gin.H + want bool + }{ + { + name: "empty list is not summary-only", + artifacts: nil, + want: false, + }, + { + // The regression the review flagged: Runtime now emits + // artifact_type=document under runtime://.../artifacts/, + // which the old "/artifacts/summary" uri heuristic missed. + name: "document type with new uri scheme and no files is summary-only", + artifacts: []gin.H{{ + "artifact_id": "art_1", + "artifact_type": "document", + "title": "Backend delivery", + "summary": "下面是方案总结", + "uri": "runtime://swm_x/artifacts/backend_1", + }}, + want: true, + }, + { + name: "code_patch is a real deliverable", + artifacts: []gin.H{{ + "artifact_type": "code_patch", + "uri": "runtime://swm_x/artifacts/backend_1", + }}, + want: false, + }, + { + name: "deployment_manifest is a real deliverable", + artifacts: []gin.H{{"artifact_type": "deployment_manifest"}}, + want: false, + }, + { + name: "document type but with file-change signal is a deliverable", + artifacts: []gin.H{{ + "artifact_type": "document", + "metadata": map[string]any{"files_modified": float64(2)}, + }}, + want: false, + }, + { + name: "mixed set with one real deliverable is not summary-only", + artifacts: []gin.H{ + {"artifact_type": "document"}, + {"artifact_type": "code_patch"}, + }, + want: false, + }, + { + name: "legacy runtime execution summary marker still detected", + artifacts: []gin.H{{ + "artifact_type": "other", + "title": "Runtime execution summary", + }}, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, runtimeArtifactsAreSummaryOnly(tc.artifacts)) + }) + } +} + +// P6a: Resource CRUD must reject a secret_ref that is not an azkv:// reference. +func TestNormalizeResourcePayload_SecretRefMustBeAzkv(t *testing.T) { + base := func(secretRef string) resourcePayload { + return resourcePayload{Name: "repo", ResourceType: "git", SecretRef: secretRef} + } + + _, err := normalizeResourcePayload(base("https://example.com/token")) + require.Error(t, err) + require.Contains(t, err.Error(), "azkv://") + + _, err = normalizeResourcePayload(base("sk-live-plaintext-leak")) + require.Error(t, err) + + got, err := normalizeResourcePayload(base("azkv://heicode-kv.vault.azure.net/secrets/repo")) + require.NoError(t, err) + require.Equal(t, "azkv://heicode-kv.vault.azure.net/secrets/repo", got.SecretRef) + + // Empty secret_ref stays allowed (secret can be set later via UpsertResourceSecret). + _, err = normalizeResourcePayload(base("")) + require.NoError(t, err) +} + +// P6b: plaintext-secret detection must scan string VALUES, not only key names. +func TestValueLooksLikeSecretAndPlaintextScan(t *testing.T) { + positives := []string{ + "sk-abcdefghij1234567890", + "sk-live-abcdefghijklmnopqrst", + "ghp_abcdefghijklmnopqrstuvwxyz0123", + "AKIAIOSFODNN7EXAMPLE", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.s5h6Qk0c5Qx2hQ", + "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END", + } + for _, s := range positives { + require.Truef(t, valueLooksLikeSecret(s), "expected secret-like: %q", s) + } + + negatives := []string{ + "", + "main", + "https://github.com/org/repo.git", + "azkv://heicode-kv.vault.azure.net/secrets/repo", + "a normal sentence with sk in it", + } + for _, s := range negatives { + require.Falsef(t, valueLooksLikeSecret(s), "expected NOT secret-like: %q", s) + } + + // Value hidden under an innocuous key must now be caught. + require.True(t, containsPlaintextSecret(map[string]any{"note": "sk-abcdefghij1234567890"})) + require.False(t, containsPlaintextSecret(map[string]any{"repo_url": "https://github.com/o/r.git"})) +} + +// P5: a single source of truth for the default model; no placeholder fallback. +func TestDefaultAgnetModelID_SingleSourceNoPlaceholder(t *testing.T) { + def := defaultAgnetModelID() + require.Equal(t, "gpt-5.4", def) + + // Draft builder must use the single default, never agnet-model-. + plan := buildAgnetDraftAgentPlan("backend", "", nil) + require.Equal(t, def, plan.DefaultModelID) + require.NotContains(t, plan.DefaultModelID, "agnet-model-") + + // Explicit client model is still honored. + plan = buildAgnetDraftAgentPlan("backend", "gpt-5.4-mini", nil) + require.Equal(t, "gpt-5.4-mini", plan.DefaultModelID) + + // Every role template resolves to the single default, no claude-* hardcoding. + for _, tpl := range agnetRoleTemplates() { + require.Equal(t, def, tpl.DefaultModel, "role %s", tpl.Key) + } +} diff --git a/heicode/controller/agnet_role_template.go b/heicode/controller/agnet_role_template.go index dea3784..b919679 100644 --- a/heicode/controller/agnet_role_template.go +++ b/heicode/controller/agnet_role_template.go @@ -33,6 +33,15 @@ type AgnetRoleTemplate struct { RiskLevel string `json:"risk_level"` } +// defaultAgnetModelID is the single source of truth for the default sub-agent +// model. It is aligned to the production-verified NewAPI model and overridable +// via AGNET_DEFAULT_MODEL_ID, so role templates, deployment drafts and runtime +// agent refs never fall back to placeholder names (e.g. agnet-model-) +// that production NewAPI cannot route ("No available channel for model ..."). +func defaultAgnetModelID() string { + return common.GetEnvOrDefaultString("AGNET_DEFAULT_MODEL_ID", "gpt-5.4") +} + // agnetRoleTemplates returns the canonical six-role catalog. Order // matches the typical lifecycle a user walks through when assembling // a team: discover -> design -> build -> review -> operate. @@ -46,7 +55,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "product", DisplayName: "Product Agnet", Summary: "Refines the user idea into product scope, requirements and acceptance criteria.", - DefaultModel: "claude-sonnet-4-6", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:project_docs", "write:product_spec", @@ -57,7 +66,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "architect", DisplayName: "Architect Agnet", Summary: "Designs the technical approach, picks frameworks, and breaks work into sub-tasks.", - DefaultModel: "claude-opus-4-7", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:repo", "write:architecture_doc", @@ -68,7 +77,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "frontend", DisplayName: "Frontend Agnet", Summary: "Implements UI, components and client-side state per the architect's plan.", - DefaultModel: "claude-sonnet-4-6", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:repo", "write:repo:frontend", @@ -79,7 +88,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "backend", DisplayName: "Backend Agnet", Summary: "Implements server-side APIs, data models and integrations.", - DefaultModel: "claude-sonnet-4-6", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:repo", "write:repo:backend", @@ -91,7 +100,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "reviewer", DisplayName: "Reviewer Agnet", Summary: "Performs code review, security checks and runs the test suite.", - DefaultModel: "claude-opus-4-7", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:repo", "run:tests", @@ -103,7 +112,7 @@ func agnetRoleTemplates() []AgnetRoleTemplate { Key: "ops", DisplayName: "Ops Agnet", Summary: "Deploys to test environments, watches logs and prepares production rollouts (production requires approval).", - DefaultModel: "claude-sonnet-4-6", + DefaultModel: defaultAgnetModelID(), DefaultPermissions: []string{ "read:repo", "deploy:test_env", diff --git a/heicode/controller/agnet_runtime_client.go b/heicode/controller/agnet_runtime_client.go index 5a547fa..acc4d11 100644 --- a/heicode/controller/agnet_runtime_client.go +++ b/heicode/controller/agnet_runtime_client.go @@ -660,20 +660,91 @@ func runtimeAgentHasFailed(agents []gin.H) bool { return false } +// deliverableArtifactTypes are Runtime artifact_type values that represent a +// real business deliverable (code, tests, deploy manifest). Anything outside +// this set with no file-change signal is treated as a non-deliverable summary. +var deliverableArtifactTypes = map[string]bool{ + "code_patch": true, + "code_bundle": true, + "code_document": true, + "deployment_manifest": true, + "test_report": true, + "diff": true, + "patch": true, +} + +// artifactHasFileChanges reports whether the artifact carries a structured +// signal that real files were produced (count > 0 or a non-empty list), at the +// top level or under metadata. This is the authoritative deliverable signal, +// preferred over title/summary/uri string heuristics. +func artifactHasFileChanges(artifact gin.H) bool { + if anyPositiveFileSignal(artifact) { + return true + } + if meta, ok := artifact["metadata"].(map[string]any); ok { + if anyPositiveFileSignal(meta) { + return true + } + } + return false +} + +func anyPositiveFileSignal(values map[string]any) bool { + for _, key := range []string{"files_modified", "files_deleted", "files_changed", "files"} { + switch v := values[key].(type) { + case float64: + if v > 0 { + return true + } + case int: + if v > 0 { + return true + } + case []any: + if len(v) > 0 { + return true + } + case string: + if trimmed := strings.TrimSpace(v); trimmed != "" && trimmed != "0" { + return true + } + } + } + return false +} + +// artifactIsSummaryOnly judges a single artifact by structured fields first +// (artifact_type + file-change signal), then falls back to the legacy title/ +// summary/uri markers. Reading artifact_type fixes the case where Runtime emits +// artifact_type="document" (its no-files fallback) under a uri scheme that the +// old "/artifacts/summary" heuristic no longer matched. +func artifactIsSummaryOnly(artifact gin.H) bool { + atype := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["artifact_type"]))) + if deliverableArtifactTypes[atype] || artifactHasFileChanges(artifact) { + return false + } + title := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["title"]))) + summary := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["summary"]))) + uri := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["uri"]))) + if strings.Contains(summary, "without per-agent artifacts") || + strings.Contains(title, "runtime execution summary") || + strings.Contains(title, "runtime execution failed") || + strings.Contains(uri, "/artifacts/summary") { + return true + } + // Runtime emits artifact_type="document" (or "summary") for the no-deliverable + // fallback; with no file-change signal that is a summary, not a code delivery. + return atype == "document" || atype == "summary" +} + func runtimeArtifactsAreSummaryOnly(artifacts []gin.H) bool { if len(artifacts) == 0 { return false } for _, artifact := range artifacts { - title := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["title"]))) - summary := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["summary"]))) - uri := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["uri"]))) - if strings.Contains(summary, "without per-agent artifacts") || - strings.Contains(title, "runtime execution summary") || - strings.Contains(uri, "/artifacts/summary") { - continue + if !artifactIsSummaryOnly(artifact) { + return false } - return false } return true } diff --git a/heicode/controller/agnet_task_bridge.go b/heicode/controller/agnet_task_bridge.go index d94f1c8..348c89a 100644 --- a/heicode/controller/agnet_task_bridge.go +++ b/heicode/controller/agnet_task_bridge.go @@ -124,7 +124,7 @@ func sanitizeAgnetRef(value string) string { func buildAgnetDraftAgentPlan(role string, defaultModelID string, grants []agnetResourceGrant) agnetAgentPlan { if defaultModelID == "" { - defaultModelID = "agnet-model-" + sanitizeAgnetRef(role) + defaultModelID = defaultAgnetModelID() } return agnetAgentPlan{ RoleTemplate: role, @@ -215,7 +215,7 @@ func AgnetCreateTaskDeploymentDraft(c *gin.Context) { agents = append(agents, buildAgnetDraftAgentPlan(role, defaultModelID, grants)) modelRef := defaultModelID if modelRef == "" { - modelRef = "agnet-model-" + sanitizeAgnetRef(role) + modelRef = defaultAgnetModelID() } runtimeAgents = append(runtimeAgents, agnetRuntimeAgent{Role: role, ModelRef: modelRef, InstanceCount: 1}) } diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index 8710035..8735674 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -167,6 +167,9 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { if !allowedResourceStatuses[p.Status] { return p, errors.New("status must be active, disabled, or revoked") } + if p.SecretRef != "" && !strings.HasPrefix(p.SecretRef, "azkv://") { + return p, errors.New("secret_ref must use azkv:///secrets/ Azure Key Vault reference") + } if p.Metadata == nil { p.Metadata = map[string]any{} } @@ -258,6 +261,8 @@ func containsPlaintextSecret(value any) bool { return true } } + case string: + return valueLooksLikeSecret(v) } return false } @@ -272,6 +277,52 @@ func isSecretLikeKey(key string) bool { return strings.HasSuffix(normalized, "_token") || strings.HasSuffix(normalized, "_secret") || strings.HasSuffix(normalized, "_password") || strings.HasSuffix(normalized, "_private_key") } +// valueLooksLikeSecret scans a string VALUE (not just a key name) for high- +// confidence plaintext credential patterns, so a secret hidden under an +// innocuous key (e.g. {"note":"sk-live-..."}) is still rejected. Patterns are +// kept deliberately tight to avoid false positives on ordinary content. An +// azkv:// secret_ref never matches any of these, so references stay allowed. +func valueLooksLikeSecret(value string) bool { + s := strings.TrimSpace(value) + if len(s) < 12 { + return false + } + if strings.Contains(s, "PRIVATE KEY-----") { + return true + } + prefixes := []struct { + p string + min int + }{ + {"sk-", 20}, {"sk_live_", 20}, {"sk_test_", 20}, + {"ghp_", 24}, {"gho_", 24}, {"github_pat_", 24}, + {"xoxb-", 24}, {"xoxp-", 24}, {"xoxa-", 24}, + {"AKIA", 16}, {"ASIA", 16}, {"AIza", 24}, + } + for _, pf := range prefixes { + if len(s) >= pf.min && strings.HasPrefix(s, pf.p) { + return true + } + } + return looksLikeJWT(s) +} + +func looksLikeJWT(s string) bool { + if !strings.HasPrefix(s, "eyJ") { + return false + } + parts := strings.Split(s, ".") + if len(parts) != 3 { + return false + } + for _, p := range parts { + if len(p) < 8 { + return false + } + } + return true +} + func marshalResourceJSON(value map[string]any) (string, error) { if value == nil { value = map[string]any{}