fix(single_request): 명시된 null 템플릿 경로를 생략과 구분한다

`plan_file: null`과 `review_file: ~`은 YAML에 실제로 존재하는 키인데
mapstructure가 present nil을 생략과 같은 nil pointer로 접어버려, 승인 경계가
빈 문자열과 공백만 거부하고 null 형태는 조용히 built-in default로 떨어뜨렸다.
Viper가 돌려주는 raw preset 구조에는 두 키가 nil 값으로 남아 있으므로, strict
decode 전에 그 presence를 검사해 필드 경로와 함께 거부하고 실제로 없는 키만
fallback 신호로 남긴다.

두 필드와 두 가지 YAML null 표기를 모두 회귀로 고정하고, 기존 빈/공백, 생략,
상대 경로, clone 독립성, refresh redaction 근거는 그대로 유지한다.

Refs: agent-task/single_request_plan_review_templates/PLAN-cloud-G08.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
toki 2026-08-09 10:35:53 +09:00
parent 8d0baac573
commit 1ed718cadc
2 changed files with 48 additions and 3 deletions

View file

@ -71,6 +71,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) {
}
if v.InConfig("execution_presets") {
raw := v.Get("execution_presets")
if err := rejectNullSingleRequestTemplatePaths(raw); err != nil {
return nil, err
}
var presets []ExecutionPreset
var metadata mapstructure.Metadata
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
@ -681,6 +684,42 @@ func validateWorkspaceNumericLimits(ws WorkspaceDefinition, nodeIdx, wsIdx int)
return nil
}
// rejectNullSingleRequestTemplatePaths fails closed on a present-but-null
// plan_file/review_file before the strict preset decode runs. Mapstructure
// collapses a present raw nil into the same nil pointer as an absent key, so
// `plan_file: null` and `review_file: ~` would otherwise be indistinguishable
// from omission and silently select the built-in default. Only a truly absent
// key may reach the nil-pointer fallback in resolveSingleRequestTemplates;
// non-nil values stay with the strict decoder and loadTemplateFile, and any
// other raw shape is left to the strict decoder's own type error.
func rejectNullSingleRequestTemplatePaths(raw any) error {
presets, ok := raw.([]any)
if !ok {
return nil
}
for i, entry := range presets {
preset, ok := entry.(map[string]any)
if !ok {
continue
}
singleRequest, ok := preset["single_request"].(map[string]any)
if !ok {
continue
}
templates, ok := singleRequest["templates"].(map[string]any)
if !ok {
continue
}
for _, field := range []string{"plan_file", "review_file"} {
value, present := templates[field]
if present && value == nil {
return fmt.Errorf("execution_presets[%d] single_request.templates.%s: template path must not be empty", i, field)
}
}
}
return nil
}
// resolveSingleRequestTemplates freezes the effective Plan/Review templates for
// every single-request preset. Only an omitted plan_file/review_file selects the
// built-in default; every configured value is routed through loadTemplateFile so

View file

@ -965,9 +965,11 @@ nodes:
})
// Only an omitted field selects the built-in default. An explicitly
// configured empty or whitespace-only path is a configuration error and
// must be rejected with its own field context before the loader touches
// the filesystem.
// configured empty, whitespace-only, or null path is a configuration error
// and must be rejected with its own field context before the loader touches
// the filesystem. Both YAML null spellings are covered because a present
// null decodes into the same nil pointer as an absent key, so the rejection
// has to happen on the raw preset structure before that presence is erased.
t.Run("explicitly configured empty template path fails closed", func(t *testing.T) {
cases := []struct {
name string
@ -977,8 +979,12 @@ nodes:
}{
{"empty plan_file", `""`, "", "single_request.templates.plan_file"},
{"whitespace plan_file", `" "`, "", "single_request.templates.plan_file"},
{"null plan_file", "null", "", "single_request.templates.plan_file"},
{"tilde plan_file", "~", "", "single_request.templates.plan_file"},
{"empty review_file", "", `""`, "single_request.templates.review_file"},
{"whitespace review_file", "", `" "`, "single_request.templates.review_file"},
{"null review_file", "", "null", "single_request.templates.review_file"},
{"tilde review_file", "", "~", "single_request.templates.review_file"},
}
for _, tc := range cases {