mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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.
64 lines
2.3 KiB
Go
64 lines
2.3 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// Issue is a single structured problem found while validating a config file.
|
|
// Message is already routed through the package secret redaction.
|
|
type Issue struct {
|
|
FieldPath string `json:"fieldPath,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
|
// semantic provider/model rules used during resolution. It returns the parsed
|
|
// config (zero value on parse failure) plus any structured issues. A parse
|
|
// failure yields a single issue whose Message wraps the underlying JSON error
|
|
// so callers can extract *json.SyntaxError / *json.UnmarshalTypeError offsets
|
|
// via errors.As.
|
|
func ValidateFile(path string) (FileConfig, []Issue) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
|
}
|
|
|
|
var cfg FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON %s: %w", path, err).Error()}}
|
|
}
|
|
|
|
issues := validateSemantics(cfg)
|
|
issues = append(issues, unknownFieldIssues(data)...)
|
|
return cfg, issues
|
|
}
|
|
|
|
// ValidateBytes parses data as a Zero FileConfig and runs the same semantic
|
|
// provider/model rules as ValidateFile. It returns the parsed config (zero
|
|
// value on parse failure) plus any structured issues. A parse failure yields a
|
|
// single issue whose Message wraps the underlying JSON error (path-less form:
|
|
// "invalid config JSON: <err>") so callers can extract *json.SyntaxError /
|
|
// *json.UnmarshalTypeError offsets via errors.As.
|
|
func ValidateBytes(data []byte) (FileConfig, []Issue) {
|
|
var cfg FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON: %w", err).Error()}}
|
|
}
|
|
issues := validateSemantics(cfg)
|
|
issues = append(issues, unknownFieldIssues(data)...)
|
|
return cfg, issues
|
|
}
|
|
|
|
func validateSemantics(cfg FileConfig) []Issue {
|
|
if _, _, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider); err != nil {
|
|
// normalizeProviders already redacts secrets via providerError.
|
|
return []Issue{{FieldPath: "providers", Message: err.Error()}}
|
|
}
|
|
if err := validateSTTConfig(cfg.STT); err != nil {
|
|
return []Issue{{FieldPath: "stt", Message: err.Error()}}
|
|
}
|
|
return nil
|
|
}
|