Files
zero/internal/config/validate_test.go
Ashwinhegde19 893b7b424c fix(config): surface unknown/typo'd config fields instead of silently dropping them (#645)
* fix(config): surface unknown/typo'd config fields instead of silently dropping them

json.Unmarshal ignores unknown JSON keys by default, so typos such as
"maxTurn" or "sandbox.network" were silently discarded: the user
believed a setting (e.g. a sandbox hardening option) was active while
Zero used its default. Worse, "zero doctor" reported
"config files parsed and validated successfully" even when fields were dropped.

Add a reflection-based unknown-field scanner
(internal/config/unknownfields.go) that walks the FileConfig JSON schema
and recurses into nested objects, arrays, and maps, reporting each
unknown key with its full JSON path and a near-match suggestion
("did you mean 'maxTurns'?"). The two legacy top-level aliases
(mcpServers, mcp_servers) are allowlisted so they are not flagged,
and detection only inspects json tags, so a config written by a newer
Zero with new fields still loads and merges normally — validate/doctor
just surface the unknown keys as issues rather than rejecting the file.

Wire the scanner into ValidateBytes/ValidateFile, which zero doctor
already uses, so doctor now reports the unknown fields instead of
"validation passed".

Add tests for unknown top-level keys, unknown nested keys, unknown
keys inside a provider entry, and that the legacy aliases are not
flagged.

Fixes #644

* fix(config): address review comments on unknown-field scanner

- Treat JSON keys case-insensitively (matching encoding/json), so a
  valid case variant such as "MaxTurns" is not falsely flagged as
  unknown. Canonical (correct-case) names are kept only for the
  "did you mean" hint.
- Allowlist ProviderProfile's legacy snake_case keys (providerKind,
  catalog_id, base_url, api_key, ...) that ProviderProfile.UnmarshalJSON
  accepts but which are not fields on the Go type, so existing
  configs using them are not false-positive'd in zero doctor.
- Make the near-match suggestion deterministic: sort candidates before
  picking so an edit-distance tie always resolves to the same lexical
  winner (map-range order was nondeterministic and could flake).
- Make the unknown-field test assertions order-independent (search by
  FieldPath instead of assuming issues[0]).

Add regression coverage for legacy provider keys and valid case
variants not being flagged.
2026-07-13 21:52:44 +08:00

262 lines
7.5 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeValidateFixture(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func TestValidateFileReturnsParseIssueForMalformedJSON(t *testing.T) {
path := writeValidateFixture(t, `{"activeProvider": "openai",`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected parse issue, got none")
}
if !strings.Contains(issues[0].Message, "invalid config JSON") {
t.Fatalf("expected parse issue message, got %#v", issues)
}
}
func TestValidateFileSurfacesSemanticIssue(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "model": "gpt-4.1"}
]
}`)
cfg, issues := ValidateFile(path)
if cfg.ActiveProvider != "main" {
t.Fatalf("expected parsed config, got %#v", cfg)
}
if len(issues) == 0 {
t.Fatalf("expected semantic issue for openai custom baseURL, got none")
}
}
func TestValidateFileRedactsSecretInIssue(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "apiKey": "sk-proj-secret1234567890", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected semantic issue, got none")
}
for _, issue := range issues {
if strings.Contains(issue.Message, "sk-proj-secret") {
t.Fatalf("issue leaked apiKey: %q", issue.Message)
}
}
}
func TestValidateFileMissingModelWarns(t *testing.T) {
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
// on, so a missing model is still a real issue — and the message must tell
// the user how to fix it.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected requires-model issue, got none")
}
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
if !strings.Contains(issues[0].Message, "config.json") {
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
}
}
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
// Official-API kinds (anthropic/google) fall back to their catalog default
// model, so a hand-written model-less profile validates clean instead of
// bricking zero config / bare zero setup — the only commands that could
// have fixed it (the reported google case).
path := writeValidateFixture(t, `{
"activeProvider": "google",
"providers": [
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "requires model") {
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
}
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) != 0 {
t.Fatalf("expected no issues for valid config, got %#v", issues)
}
}
func TestValidateFileFlagsUnknownTopLevelKey(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"maxTurn": 1
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected unknown-field issue for top-level typo, got none")
}
var got Issue
found := false
for _, issue := range issues {
if issue.FieldPath == "maxTurn" {
got, found = issue, true
}
}
if !found {
t.Fatalf("expected unknown-field issue at maxTurn, got %#v", issues)
}
if !strings.Contains(got.Message, `did you mean "maxTurns"`) {
t.Fatalf("expected near-match suggestion, got %q", got.Message)
}
}
func TestValidateFileFlagsUnknownNestedKey(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"sandbox": {
"network": "allow",
"blockUnixSocket": true
}
}`)
_, issues := ValidateFile(path)
var got Issue
found := false
for _, issue := range issues {
if issue.FieldPath == "sandbox.blockUnixSocket" {
got, found = issue, true
}
}
if !found {
t.Fatalf("expected nested unknown-field issue at sandbox.blockUnixSocket, got %#v", issues)
}
if !strings.Contains(got.Message, `did you mean "sandbox.blockUnixSockets"`) {
t.Fatalf("expected near-match suggestion, got %q", got.Message)
}
}
func TestValidateFileFlagsUnknownInProvider(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1", "endpoint": "https://example.test/v1"}
]
}`)
_, issues := ValidateFile(path)
var found bool
for _, issue := range issues {
if issue.FieldPath == "providers[0].endpoint" {
found = true
}
}
if !found {
t.Fatalf("expected unknown-field issue for providers[0].endpoint, got %#v", issues)
}
}
func TestValidateFileAllowsLegacyAliases(t *testing.T) {
// The legacy mcpServers / mcp_servers aliases are still read by
// FileConfig.UnmarshalJSON and must not be reported as unknown.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"mcpServers": {
"docs": {"type": "stdio", "command": "docs-mcp"}
}
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.FieldPath, "mcpServers") {
t.Fatalf("legacy alias mcpServers reported as unknown: %#v", issues)
}
}
}
func TestValidateFileAllowsCaseVariantKey(t *testing.T) {
// encoding/json matches object keys case-insensitively, so "MaxTurns"
// parses into MaxTurns. The scanner compares lower-cased keys,
// so a valid case variant must not be flagged as unknown.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"MaxTurns": 1
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "unknown config field") {
t.Fatalf("valid case-variant key flagged as unknown: %#v", issues)
}
}
}
func TestValidateFileAllowsLegacyProviderKeys(t *testing.T) {
// ProviderProfile.UnmarshalJSON accepts snake_case legacy keys
// (base_url, api_key, providerKind, ...). These are valid and
// must not be flagged as unknown — only the canonical camelCase
// tags are visible to the reflection scan, so they are allowlisted.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "providerKind": "openai", "base_url": "https://example.test/v1", "api_key": "sk-x", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "unknown config field") {
t.Fatalf("legacy provider key reported as unknown: %#v", issues)
}
}
}