fix(agnet): structured deliverable judgment, secret_ref validation, single default model
代码评审(2026-06-01 全链路报告)中 Manager 侧自主可修项: - P2 交付物判定: runtimeArtifactsAreSummaryOnly 改为优先读结构化字段 (artifact_type + files_modified 信号),修复 Runtime 新 uri scheme 与 artifact_type=document 被旧 /artifacts/summary 字符串启发式漏判的回归。 - P6a: Resource CRUD(normalizeResourcePayload)强制 secret_ref 必须 azkv://, 与 agnet 部署/审批路径一致,堵住直写任意 secret_ref 的旁路。 - P6b: 明文密钥检测从仅按字段名升级为同时扫字符串值(sk-/ghp_/AKIA/JWT/PEM 等高置信模式),containsPlaintextSecret 与 containsSensitiveGrantField 均覆盖。 - P5 默认模型收敛: 新增单一来源 defaultAgnetModelID()(env AGNET_DEFAULT_MODEL_ID, 默认生产已验证的 gpt-5.4);移除 draft 构造器两处 agnet-model-<role> 占位回退 (生产 NewAPI "No available channel" 根因)与角色模板硬编码 claude-* 默认。 - P3 文档: 对接文档状态枚举补 completed 终态、runtime_state 镜像说明与未知值兜底; role-templates 示例占位名改为 gpt-5.4。 新增 controller/agnet_deliverable_secret_test.go 覆盖以上行为。 go build ./... 与 go test ./controller/ 全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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-<role>` 占位名;客户端如不指定 `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 子环节建议值
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<task>,
|
||||
// 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-<role>.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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-<role>)
|
||||
// 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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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://<vault>/secrets/<name> 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{}
|
||||
|
||||
Reference in New Issue
Block a user