harness: config layer + model routing v0 (tier map, no heuristics)
harness.toml loading via a stdlib-only TOML subset parser (tables, bare
keys, strings/ints/bools, multi-line arrays; anything richer fails loudly).
Secrets are refs only (env:/file:/literal:, bw: reserved) and are redacted
from every error path. Model routing v0: static class -> tier alias ->
concrete model map per the DESIGN model-selection layer; requests carry the
resolved concrete model and unknown classes fail hard so routing stays
auditable. Table-driven tests cover the parser, validation, key refs, and
routing decisions.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Config is the full harness.toml surface. Defaults live in Default();
|
||||
// apply() overlays the parsed document; Validate() enforces invariants.
|
||||
type Config struct {
|
||||
Vertical string
|
||||
WorkRoot string
|
||||
ReportDir string
|
||||
Loop LoopConfig
|
||||
Redmine RedmineConfig
|
||||
LiteLLM LiteLLMConfig
|
||||
Models ModelsConfig
|
||||
Bash BashConfig
|
||||
Demo DemoConfig
|
||||
}
|
||||
|
||||
type LoopConfig struct {
|
||||
MaxRounds int
|
||||
}
|
||||
|
||||
type RedmineConfig struct {
|
||||
URL string
|
||||
KeyRef string
|
||||
ScopeQuery string
|
||||
ScopeQueryID int
|
||||
ClassField string
|
||||
DefaultClass string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type LiteLLMConfig struct {
|
||||
BaseURL string
|
||||
KeyRef string
|
||||
TimeoutSecs int
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
type ModelsConfig struct {
|
||||
// Tiers maps tier aliases (mopac-study, ...) to concrete proxy models.
|
||||
Tiers map[string]string
|
||||
// Classes maps task classes (study, code, ...) to tier aliases.
|
||||
Classes map[string]string
|
||||
// DefaultTier is used when a task carries no class.
|
||||
DefaultTier string
|
||||
}
|
||||
|
||||
type BashConfig struct {
|
||||
Enabled bool
|
||||
Allow []string
|
||||
Deny []string
|
||||
DefaultAllow bool
|
||||
TimeoutSecs int
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
type DemoConfig struct {
|
||||
ID string
|
||||
Subject string
|
||||
Prompt string
|
||||
Class string
|
||||
}
|
||||
|
||||
// Default returns the built-in defaults for every field.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
WorkRoot: ".",
|
||||
ReportDir: "reports",
|
||||
Loop: LoopConfig{MaxRounds: 8},
|
||||
Redmine: RedmineConfig{
|
||||
ClassField: "Class",
|
||||
DefaultClass: "primary",
|
||||
Limit: 50,
|
||||
},
|
||||
LiteLLM: LiteLLMConfig{TimeoutSecs: 120, MaxRetries: 2},
|
||||
Models: ModelsConfig{
|
||||
Tiers: map[string]string{},
|
||||
Classes: map[string]string{},
|
||||
DefaultTier: "mopac-primary",
|
||||
},
|
||||
Bash: BashConfig{Enabled: true, TimeoutSecs: 60, MaxOutputBytes: 100_000},
|
||||
Demo: DemoConfig{
|
||||
ID: "demo-1",
|
||||
Subject: "MVP demo: GLM self-description",
|
||||
Prompt: "tell me about yourself",
|
||||
Class: "primary",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) apply(doc TOMLDoc) error {
|
||||
if v, ok := doc.String("vertical"); ok {
|
||||
c.Vertical = v
|
||||
}
|
||||
if v, ok := doc.String("work_root"); ok {
|
||||
c.WorkRoot = v
|
||||
}
|
||||
if v, ok := doc.String("report_dir"); ok {
|
||||
c.ReportDir = v
|
||||
}
|
||||
if v, ok := doc.Table("loop").Int("max_rounds"); ok {
|
||||
c.Loop.MaxRounds = int(v)
|
||||
}
|
||||
|
||||
rm := doc.Table("redmine")
|
||||
if v, ok := rm.String("url"); ok {
|
||||
c.Redmine.URL = v
|
||||
}
|
||||
if v, ok := rm.String("key_ref"); ok {
|
||||
c.Redmine.KeyRef = v
|
||||
}
|
||||
if v, ok := rm.String("scope_query"); ok {
|
||||
c.Redmine.ScopeQuery = v
|
||||
}
|
||||
if v, ok := rm.Int("scope_query_id"); ok {
|
||||
c.Redmine.ScopeQueryID = int(v)
|
||||
}
|
||||
if v, ok := rm.String("class_field"); ok {
|
||||
c.Redmine.ClassField = v
|
||||
}
|
||||
if v, ok := rm.String("default_class"); ok {
|
||||
c.Redmine.DefaultClass = v
|
||||
}
|
||||
if v, ok := rm.Int("limit"); ok {
|
||||
c.Redmine.Limit = int(v)
|
||||
}
|
||||
|
||||
lt := doc.Table("litellm")
|
||||
if v, ok := lt.String("base_url"); ok {
|
||||
c.LiteLLM.BaseURL = v
|
||||
}
|
||||
if v, ok := lt.String("key_ref"); ok {
|
||||
c.LiteLLM.KeyRef = v
|
||||
}
|
||||
if v, ok := lt.Int("timeout_secs"); ok {
|
||||
c.LiteLLM.TimeoutSecs = int(v)
|
||||
}
|
||||
if v, ok := lt.Int("max_retries"); ok {
|
||||
c.LiteLLM.MaxRetries = int(v)
|
||||
}
|
||||
|
||||
md := doc.Table("models")
|
||||
for _, k := range md.Keys() {
|
||||
if k == "default_tier" {
|
||||
continue
|
||||
}
|
||||
if v, ok := md.String(k); ok {
|
||||
c.Models.Tiers[k] = v
|
||||
}
|
||||
}
|
||||
if v, ok := md.String("default_tier"); ok {
|
||||
c.Models.DefaultTier = v
|
||||
}
|
||||
cls := doc.Table("models", "classes")
|
||||
for _, k := range cls.Keys() {
|
||||
if v, ok := cls.String(k); ok {
|
||||
c.Models.Classes[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
bt := doc.Table("tools", "bash")
|
||||
if v, ok := bt.Bool("enabled"); ok {
|
||||
c.Bash.Enabled = v
|
||||
}
|
||||
if v, ok := bt.StringList("allow"); ok {
|
||||
c.Bash.Allow = v
|
||||
}
|
||||
if v, ok := bt.StringList("deny"); ok {
|
||||
c.Bash.Deny = v
|
||||
}
|
||||
if v, ok := bt.String("default"); ok {
|
||||
switch v {
|
||||
case "allow":
|
||||
c.Bash.DefaultAllow = true
|
||||
case "deny":
|
||||
c.Bash.DefaultAllow = false
|
||||
default:
|
||||
return fmt.Errorf("[tools.bash]: default must be \"allow\" or \"deny\", got %q", v)
|
||||
}
|
||||
}
|
||||
if v, ok := bt.Int("timeout_secs"); ok {
|
||||
c.Bash.TimeoutSecs = int(v)
|
||||
}
|
||||
if v, ok := bt.Int("max_output_bytes"); ok {
|
||||
c.Bash.MaxOutputBytes = int(v)
|
||||
}
|
||||
|
||||
dm := doc.Table("demo")
|
||||
if v, ok := dm.String("id"); ok {
|
||||
c.Demo.ID = v
|
||||
}
|
||||
if v, ok := dm.String("subject"); ok {
|
||||
c.Demo.Subject = v
|
||||
}
|
||||
if v, ok := dm.String("prompt"); ok {
|
||||
c.Demo.Prompt = v
|
||||
}
|
||||
if v, ok := dm.String("class"); ok {
|
||||
c.Demo.Class = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate enforces the invariants the conductor relies on.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Vertical == "" {
|
||||
return fmt.Errorf("[core]: vertical is required")
|
||||
}
|
||||
if c.Loop.MaxRounds < 1 {
|
||||
return fmt.Errorf("[loop]: max_rounds must be >= 1")
|
||||
}
|
||||
if c.LiteLLM.BaseURL == "" {
|
||||
return fmt.Errorf("[litellm]: base_url is required")
|
||||
}
|
||||
if c.LiteLLM.KeyRef == "" {
|
||||
return fmt.Errorf("[litellm]: key_ref is required (env:NAME, file:PATH, or literal:VALUE)")
|
||||
}
|
||||
if err := CheckKeyRef(c.LiteLLM.KeyRef); err != nil {
|
||||
return fmt.Errorf("[litellm]: %w", err)
|
||||
}
|
||||
if c.LiteLLM.TimeoutSecs < 1 || c.LiteLLM.MaxRetries < 0 {
|
||||
return fmt.Errorf("[litellm]: bad timeout_secs/max_retries")
|
||||
}
|
||||
|
||||
// Redmine is optional (demo-only configs); if any piece is set, the rest
|
||||
// of the minimum set must be too.
|
||||
rc := c.Redmine
|
||||
if rc.URL != "" || rc.KeyRef != "" || rc.ScopeQuery != "" || rc.ScopeQueryID != 0 {
|
||||
if rc.URL == "" {
|
||||
return fmt.Errorf("[redmine]: url is required when redmine is configured")
|
||||
}
|
||||
if rc.KeyRef == "" {
|
||||
return fmt.Errorf("[redmine]: key_ref is required when redmine is configured")
|
||||
}
|
||||
if err := CheckKeyRef(rc.KeyRef); err != nil {
|
||||
return fmt.Errorf("[redmine]: %w", err)
|
||||
}
|
||||
if rc.ScopeQuery == "" && rc.ScopeQueryID == 0 {
|
||||
return fmt.Errorf("[redmine]: scope_query or scope_query_id is required")
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Models.Tiers) == 0 {
|
||||
return fmt.Errorf("[models]: at least one tier alias is required")
|
||||
}
|
||||
if _, ok := c.Models.Tiers[c.Models.DefaultTier]; !ok {
|
||||
return fmt.Errorf("[models]: default_tier %q has no entry in [models]", c.Models.DefaultTier)
|
||||
}
|
||||
for class, tier := range c.Models.Classes {
|
||||
if _, ok := c.Models.Tiers[tier]; !ok {
|
||||
return fmt.Errorf("[models.classes]: class %q points at unknown tier %q", class, tier)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bash.TimeoutSecs < 1 || c.Bash.MaxOutputBytes < 1 {
|
||||
return fmt.Errorf("[tools.bash]: bad timeout_secs/max_output_bytes")
|
||||
}
|
||||
if c.Demo.Prompt == "" {
|
||||
return fmt.Errorf("[demo]: prompt is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testCfg = `
|
||||
vertical = "teststack"
|
||||
work_root = "/tmp/harness-test"
|
||||
report_dir = "out"
|
||||
|
||||
[redmine]
|
||||
url = "https://rm.test"
|
||||
key_ref = "env:HARNESS_TEST_RM_KEY"
|
||||
scope_query = "project=x&status_id=open"
|
||||
|
||||
[litellm]
|
||||
base_url = "http://litellm.test:4000"
|
||||
key_ref = "env:HARNESS_TEST_LLM_KEY"
|
||||
|
||||
[models]
|
||||
mopac-study = "glm-4.7-flash"
|
||||
mopac-code = "glm-5.2"
|
||||
mopac-review = "glm-5-turbo"
|
||||
mopac-primary = "glm-5.3"
|
||||
default_tier = "mopac-primary"
|
||||
|
||||
[models.classes]
|
||||
study = "mopac-study"
|
||||
code = "mopac-code"
|
||||
primary = "mopac-primary"
|
||||
|
||||
[tools.bash]
|
||||
allow = ["pwd", "ls *"]
|
||||
deny = ["sudo *"]
|
||||
`
|
||||
|
||||
func writeTemp(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "harness.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadAppliesDocAndDefaults(t *testing.T) {
|
||||
cfg, err := Load(writeTemp(t, testCfg))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Vertical != "teststack" || cfg.WorkRoot != "/tmp/harness-test" || cfg.ReportDir != "out" {
|
||||
t.Errorf("core fields wrong: %+v", cfg)
|
||||
}
|
||||
if cfg.Loop.MaxRounds != 8 {
|
||||
t.Errorf("default max_rounds = %d, want 8", cfg.Loop.MaxRounds)
|
||||
}
|
||||
if cfg.LiteLLM.BaseURL != "http://litellm.test:4000" || cfg.LiteLLM.MaxRetries != 2 {
|
||||
t.Errorf("litellm wrong: %+v", cfg.LiteLLM)
|
||||
}
|
||||
wantTiers := map[string]string{
|
||||
"mopac-study": "glm-4.7-flash",
|
||||
"mopac-code": "glm-5.2",
|
||||
"mopac-review": "glm-5-turbo",
|
||||
"mopac-primary": "glm-5.3",
|
||||
}
|
||||
for tier, model := range wantTiers {
|
||||
if got, ok := cfg.Models.Tiers[tier]; !ok || got != model {
|
||||
t.Errorf("tier %s = %q ok=%v, want %q", tier, got, ok, model)
|
||||
}
|
||||
}
|
||||
if cfg.Models.Classes["code"] != "mopac-code" {
|
||||
t.Errorf("class map wrong: %+v", cfg.Models.Classes)
|
||||
}
|
||||
if len(cfg.Bash.Allow) != 2 || len(cfg.Bash.Deny) != 1 {
|
||||
t.Errorf("bash lists wrong: %v / %v", cfg.Bash.Allow, cfg.Bash.Deny)
|
||||
}
|
||||
if cfg.Bash.TimeoutSecs != 60 || cfg.Bash.MaxOutputBytes != 100_000 {
|
||||
t.Errorf("bash defaults wrong: %+v", cfg.Bash)
|
||||
}
|
||||
if cfg.Demo.Prompt != "tell me about yourself" {
|
||||
t.Errorf("demo default prompt = %q", cfg.Demo.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrackedExampleFile(t *testing.T) {
|
||||
// The example ships to users; it must always parse and validate as-is.
|
||||
path := filepath.Join("..", "..", "harness.toml.example")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("example not found: %v", err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(harness.toml.example): %v", err)
|
||||
}
|
||||
if cfg.Vertical != "demo" || cfg.Models.Tiers["mopac-primary"] != "glm-5.3" {
|
||||
t.Errorf("example parsed wrong: %+v", cfg.Models.Tiers)
|
||||
}
|
||||
if len(cfg.Models.Classes) != 9 {
|
||||
t.Errorf("example classes = %d, want 9", len(cfg.Models.Classes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidationErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(string) string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing vertical",
|
||||
mut: func(s string) string { return strings.Replace(s, `vertical = "teststack"`, "", 1) },
|
||||
want: "vertical is required",
|
||||
},
|
||||
{
|
||||
name: "missing litellm url",
|
||||
mut: func(s string) string { return strings.Replace(s, `base_url = "http://litellm.test:4000"`, "", 1) },
|
||||
want: "base_url is required",
|
||||
},
|
||||
{
|
||||
name: "bare key value",
|
||||
mut: func(s string) string { return strings.Replace(s, `key_ref = "env:HARNESS_TEST_LLM_KEY"`, `key_ref = "sk-or-whatever"`, 1) },
|
||||
want: "env:, file:, literal:, or bw:",
|
||||
},
|
||||
{
|
||||
name: "bw ref not implemented",
|
||||
mut: func(s string) string { return strings.Replace(s, `key_ref = "env:HARNESS_TEST_LLM_KEY"`, `key_ref = "bw:item"`, 1) },
|
||||
want: "not implemented",
|
||||
},
|
||||
{
|
||||
name: "redmine half configured",
|
||||
mut: func(s string) string { return strings.Replace(s, `scope_query = "project=x&status_id=open"`, "", 1) },
|
||||
want: "scope_query or scope_query_id is required",
|
||||
},
|
||||
{
|
||||
name: "class to unknown tier",
|
||||
mut: func(s string) string { return strings.Replace(s, `code = "mopac-code"`, `code = "mopac-nope"`, 1) },
|
||||
want: `unknown tier "mopac-nope"`,
|
||||
},
|
||||
{
|
||||
name: "default tier missing",
|
||||
mut: func(s string) string { return strings.Replace(s, `mopac-primary = "glm-5.3"`, "", 1) },
|
||||
want: `default_tier "mopac-primary" has no entry`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Load(writeTemp(t, tc.mut(testCfg)))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error %q does not contain %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoOnlyConfigIsValid(t *testing.T) {
|
||||
// No [redmine] at all: --demo still works.
|
||||
cfg, err := Load(writeTemp(t, `
|
||||
vertical = "demo"
|
||||
[litellm]
|
||||
base_url = "http://x:4001"
|
||||
key_ref = "literal:test-key"
|
||||
[models]
|
||||
mopac-primary = "glm-5.3"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Redmine.URL != "" {
|
||||
t.Errorf("redmine should be unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeyRef(t *testing.T) {
|
||||
t.Setenv("HARNESS_TEST_KEY", "sekrit")
|
||||
if v, err := ResolveKeyRef("env:HARNESS_TEST_KEY"); err != nil || v != "sekrit" {
|
||||
t.Errorf("env ref = %q err=%v", v, err)
|
||||
}
|
||||
if _, err := ResolveKeyRef("env:HARNESS_TEST_UNSET"); err == nil {
|
||||
t.Errorf("unset env should error")
|
||||
}
|
||||
|
||||
f := filepath.Join(t.TempDir(), "key")
|
||||
os.WriteFile(f, []byte(" filekey\n"), 0o600)
|
||||
if v, err := ResolveKeyRef("file:" + f); err != nil || v != "filekey" {
|
||||
t.Errorf("file ref = %q err=%v", v, err)
|
||||
}
|
||||
if v, err := ResolveKeyRef("literal:abc"); err != nil || v != "abc" {
|
||||
t.Errorf("literal ref = %q err=%v", v, err)
|
||||
}
|
||||
// Secrets must never leak through the redaction path used in errors.
|
||||
_, err := ResolveKeyRef("super-secret-value")
|
||||
if err == nil || strings.Contains(err.Error(), "super-secret-value") {
|
||||
t.Errorf("bare ref error leaks value: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Secret key references. Values are never logged and never stored by the
|
||||
// harness; only their resolved bytes reach the outgoing HTTP headers.
|
||||
//
|
||||
// Formats:
|
||||
//
|
||||
// env:NAME environment variable (must be set and non-empty)
|
||||
// file:PATH file whose trimmed contents are the key
|
||||
// literal:VALUE inline key (last resort; still never logged)
|
||||
// bw:REF bitwarden item (reserved; lands with the bw wrapper, phase 3)
|
||||
func CheckKeyRef(ref string) error {
|
||||
switch {
|
||||
case ref == "":
|
||||
return fmt.Errorf("empty key reference")
|
||||
case strings.HasPrefix(ref, "env:"):
|
||||
if len(ref) <= 4 {
|
||||
return fmt.Errorf("key ref %q: env: needs a variable name", redact(ref))
|
||||
}
|
||||
case strings.HasPrefix(ref, "file:"):
|
||||
if len(ref) <= 5 {
|
||||
return fmt.Errorf("key ref %q: file: needs a path", redact(ref))
|
||||
}
|
||||
case strings.HasPrefix(ref, "literal:"):
|
||||
if len(ref) <= 8 {
|
||||
return fmt.Errorf("key ref: literal: value is empty")
|
||||
}
|
||||
case strings.HasPrefix(ref, "bw:"):
|
||||
return fmt.Errorf("key ref bw: not implemented yet (bitwarden wrapper lands in build phase 3)")
|
||||
default:
|
||||
return fmt.Errorf("key ref must use env:, file:, literal:, or bw: prefixes (unrecognized ref redacted)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveKeyRef resolves a key reference to its value. Never log the result.
|
||||
func ResolveKeyRef(ref string) (string, error) {
|
||||
if err := CheckKeyRef(ref); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ref, "env:"):
|
||||
v := os.Getenv(ref[4:])
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("environment variable %s is not set", ref[4:])
|
||||
}
|
||||
return v, nil
|
||||
case strings.HasPrefix(ref, "file:"):
|
||||
data, err := os.ReadFile(ref[5:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read key file: %w", err)
|
||||
}
|
||||
v := strings.TrimSpace(string(data))
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("key file %s is empty", ref[5:])
|
||||
}
|
||||
return v, nil
|
||||
case strings.HasPrefix(ref, "literal:"):
|
||||
return ref[8:], nil
|
||||
}
|
||||
return "", fmt.Errorf("unreachable key ref branch")
|
||||
}
|
||||
|
||||
// redact masks everything after a recognized prefix so malformed refs can
|
||||
// be reported without echoing a possibly-pasted secret. Refs without a
|
||||
// recognizable prefix are never echoed at all.
|
||||
func redact(ref string) string {
|
||||
if i := strings.IndexByte(ref, ':'); i >= 0 && len(ref) > i+1 {
|
||||
return ref[:i+1] + "****"
|
||||
}
|
||||
return "<redacted>"
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package config implements the harness.toml surface for the MOPAC harness.
|
||||
//
|
||||
// The TOML parser in toml.go is a deliberate stdlib-only subset: tables,
|
||||
// bare keys, basic strings, integers, booleans, and (single- or multi-line)
|
||||
// string/int/bool arrays. Anything richer is a parse error so misconfigured
|
||||
// files fail loudly instead of silently.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TOMLDoc is the parsed representation of harness.toml: nested tables are
|
||||
// TOMLDoc values; leaves are string, int64, bool, or []any of those.
|
||||
type TOMLDoc map[string]any
|
||||
|
||||
// Table returns the nested table at the given key path, or an empty doc.
|
||||
func (d TOMLDoc) Table(keys ...string) TOMLDoc {
|
||||
cur := any(d)
|
||||
for _, k := range keys {
|
||||
m, ok := cur.(TOMLDoc)
|
||||
if !ok {
|
||||
return TOMLDoc{}
|
||||
}
|
||||
cur = m[k]
|
||||
}
|
||||
if m, ok := cur.(TOMLDoc); ok {
|
||||
return m
|
||||
}
|
||||
return TOMLDoc{}
|
||||
}
|
||||
|
||||
func (d TOMLDoc) String(key string) (string, bool) {
|
||||
v, ok := d[key].(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (d TOMLDoc) Int(key string) (int64, bool) {
|
||||
v, ok := d[key].(int64)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (d TOMLDoc) Bool(key string) (bool, bool) {
|
||||
v, ok := d[key].(bool)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (d TOMLDoc) StringList(key string) ([]string, bool) {
|
||||
raw, ok := d[key].([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// Keys returns the table's key names in sorted order.
|
||||
func (d TOMLDoc) Keys() []string {
|
||||
out := make([]string, 0, len(d))
|
||||
for k := range d {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseTOML parses the harness.toml subset.
|
||||
func ParseTOML(src string) (TOMLDoc, error) {
|
||||
lines, err := logicalLines(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := TOMLDoc{}
|
||||
cur := root
|
||||
for _, ln := range lines {
|
||||
if strings.HasPrefix(ln.text, "[") {
|
||||
path, err := parseTableName(ln.text, ln.no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err = descend(root, path, ln.no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := parseKeyValue(cur, ln.text, ln.no); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
type logicalLine struct {
|
||||
no int
|
||||
text string
|
||||
}
|
||||
|
||||
// logicalLines joins physical lines into logical ones: comments are stripped
|
||||
// (quote-aware), blank lines dropped, and unbalanced array brackets pull the
|
||||
// next line in so multi-line arrays work. Strings may not span lines.
|
||||
func logicalLines(src string) ([]logicalLine, error) {
|
||||
var out []logicalLine
|
||||
var buf strings.Builder
|
||||
curNo := 0
|
||||
depth := 0
|
||||
for i, raw := range strings.Split(src, "\n") {
|
||||
no := i + 1
|
||||
line := stripComment(strings.TrimSuffix(raw, "\r"))
|
||||
if buf.Len() == 0 {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
curNo = no
|
||||
buf.WriteString(trimmed)
|
||||
} else {
|
||||
buf.WriteString(" ")
|
||||
buf.WriteString(strings.TrimSpace(line))
|
||||
}
|
||||
inStr, esc := false, false
|
||||
d := 0
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case inStr && r == '\\':
|
||||
esc = true
|
||||
case r == '"':
|
||||
inStr = !inStr
|
||||
case !inStr && r == '[':
|
||||
d++
|
||||
case !inStr && r == ']':
|
||||
d--
|
||||
}
|
||||
}
|
||||
if inStr {
|
||||
return nil, fmt.Errorf("harness.toml:%d: unterminated string", no)
|
||||
}
|
||||
if depth+d < 0 {
|
||||
return nil, fmt.Errorf("harness.toml:%d: unbalanced ']'", no)
|
||||
}
|
||||
depth += d
|
||||
if depth == 0 {
|
||||
out = append(out, logicalLine{no: curNo, text: buf.String()})
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
return nil, fmt.Errorf("harness.toml:%d: unterminated array (missing ']')", curNo)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripComment removes a trailing # comment, ignoring # inside strings.
|
||||
func stripComment(line string) string {
|
||||
inStr, esc := false, false
|
||||
for i, r := range line {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case inStr && r == '\\':
|
||||
esc = true
|
||||
case r == '"':
|
||||
inStr = !inStr
|
||||
case r == '#' && !inStr:
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func parseTableName(text string, no int) ([]string, error) {
|
||||
if !strings.HasSuffix(text, "]") {
|
||||
return nil, fmt.Errorf("harness.toml:%d: malformed table header %q", no, text)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(text, "["), "]")
|
||||
if inner == "" {
|
||||
return nil, fmt.Errorf("harness.toml:%d: empty table name", no)
|
||||
}
|
||||
parts := strings.Split(inner, ".")
|
||||
for _, p := range parts {
|
||||
if !isBareKey(p) {
|
||||
return nil, fmt.Errorf("harness.toml:%d: invalid table name segment %q", no, p)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func descend(root TOMLDoc, path []string, no int) (TOMLDoc, error) {
|
||||
cur := root
|
||||
for _, k := range path {
|
||||
if existing, ok := cur[k]; ok {
|
||||
sub, ok := existing.(TOMLDoc)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("harness.toml:%d: key %q already has a value, cannot become table", no, k)
|
||||
}
|
||||
cur = sub
|
||||
continue
|
||||
}
|
||||
sub := TOMLDoc{}
|
||||
cur[k] = sub
|
||||
cur = sub
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
func parseKeyValue(m TOMLDoc, text string, no int) error {
|
||||
eq := strings.IndexByte(text, '=')
|
||||
if eq < 0 {
|
||||
return fmt.Errorf("harness.toml:%d: expected key = value, got %q", no, text)
|
||||
}
|
||||
key := strings.TrimSpace(text[:eq])
|
||||
if !isBareKey(key) {
|
||||
return fmt.Errorf("harness.toml:%d: invalid key %q", no, key)
|
||||
}
|
||||
valStr := strings.TrimSpace(text[eq+1:])
|
||||
if valStr == "" {
|
||||
return fmt.Errorf("harness.toml:%d: missing value for key %q", no, key)
|
||||
}
|
||||
val, err := parseValue(valStr, no)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := m[key]; exists {
|
||||
return fmt.Errorf("harness.toml:%d: duplicate key %q", no, key)
|
||||
}
|
||||
m[key] = val
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBareKey(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseValue(v string, no int) (any, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(v, `"`):
|
||||
return parseStringValue(v, no)
|
||||
case strings.HasPrefix(v, "["):
|
||||
return parseArrayValue(v, no)
|
||||
default:
|
||||
return parseScalar(v, no)
|
||||
}
|
||||
}
|
||||
|
||||
func parseScalar(v string, no int) (any, error) {
|
||||
switch v {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
n, err := parseTOMLInt(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("harness.toml:%d: unsupported value %q (want string, int, bool, or array)", no, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseTOMLInt(v string) (int64, error) {
|
||||
s := strings.ReplaceAll(v, "_", "")
|
||||
return parseSignedInt(s)
|
||||
}
|
||||
|
||||
func parseSignedInt(s string) (int64, error) {
|
||||
var n int64
|
||||
i := 0
|
||||
neg := false
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
neg = s[i] == '-'
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
return 0, fmt.Errorf("not an integer: %q", s)
|
||||
}
|
||||
for ; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0, fmt.Errorf("not an integer: %q", s)
|
||||
}
|
||||
n = n*10 + int64(s[i]-'0')
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("integer overflow: %q", s)
|
||||
}
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseStringValue(v string, no int) (string, error) {
|
||||
if len(v) < 2 {
|
||||
return "", fmt.Errorf("harness.toml:%d: unterminated string %q", no, v)
|
||||
}
|
||||
var b strings.Builder
|
||||
i := 1
|
||||
for i < len(v) {
|
||||
c := v[i]
|
||||
if c == '"' {
|
||||
if rest := strings.TrimSpace(v[i+1:]); rest != "" {
|
||||
return "", fmt.Errorf("harness.toml:%d: unexpected trailing content after string: %q", no, rest)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
if c != '\\' {
|
||||
b.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(v) {
|
||||
return "", fmt.Errorf("harness.toml:%d: dangling escape in %q", no, v)
|
||||
}
|
||||
switch v[i+1] {
|
||||
case '"':
|
||||
b.WriteByte('"')
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
default:
|
||||
return "", fmt.Errorf("harness.toml:%d: unsupported escape \\%c", no, v[i+1])
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return "", fmt.Errorf("harness.toml:%d: unterminated string", no)
|
||||
}
|
||||
|
||||
func parseArrayValue(v string, no int) ([]any, error) {
|
||||
if !strings.HasSuffix(v, "]") {
|
||||
return nil, fmt.Errorf("harness.toml:%d: unterminated array %q", no, v)
|
||||
}
|
||||
inner := strings.TrimSpace(v[1 : len(v)-1])
|
||||
if inner == "" {
|
||||
return []any{}, nil
|
||||
}
|
||||
var out []any
|
||||
elems := splitTopLevel(inner, ',')
|
||||
if len(elems) > 0 && strings.TrimSpace(elems[len(elems)-1]) == "" {
|
||||
elems = elems[:len(elems)-1] // tolerate a trailing comma
|
||||
}
|
||||
for _, elem := range elems {
|
||||
elem = strings.TrimSpace(elem)
|
||||
if elem == "" {
|
||||
return nil, fmt.Errorf("harness.toml:%d: empty array element in %q", no, v)
|
||||
}
|
||||
val, err := parseValue(elem, no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, val)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitTopLevel splits on sep outside of double-quoted strings.
|
||||
func splitTopLevel(s string, sep rune) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inStr, esc := false, false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
cur.WriteRune(r)
|
||||
case inStr && r == '\\':
|
||||
esc = true
|
||||
cur.WriteRune(r)
|
||||
case r == '"':
|
||||
inStr = !inStr
|
||||
cur.WriteRune(r)
|
||||
case r == sep && !inStr:
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out = append(out, cur.String())
|
||||
return out
|
||||
}
|
||||
|
||||
// Load reads, parses, and validates harness.toml at path.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||
}
|
||||
doc, err := ParseTOML(string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.apply(doc); err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTOMLValid(t *testing.T) {
|
||||
src := `
|
||||
# top comment
|
||||
vertical = "demo" # trailing comment
|
||||
work_root = "."
|
||||
|
||||
[loop]
|
||||
max_rounds = 4
|
||||
|
||||
[redmine]
|
||||
url = "https://rm.example.org"
|
||||
scope_query_id = 42
|
||||
class_field = "Class"
|
||||
|
||||
[litellm]
|
||||
base_url = "http://h:4001"
|
||||
timeout_secs = 30
|
||||
max_retries = 3
|
||||
|
||||
[models]
|
||||
mopac-study = "glm-4.7-flash" # hyphenated bare key
|
||||
default_tier = "mopac-study"
|
||||
|
||||
[models.classes]
|
||||
study = "mopac-study"
|
||||
code = "mopac-code"
|
||||
|
||||
[tools.bash]
|
||||
enabled = false
|
||||
default = "deny"
|
||||
allow = ["pwd", "ls *", "cat *"]
|
||||
deny = [
|
||||
"sudo *", # comment inside array
|
||||
"ssh *",
|
||||
]
|
||||
|
||||
[deep.nested.table]
|
||||
flag = true
|
||||
note = "quote \" and # inside string"
|
||||
empty = []
|
||||
`
|
||||
doc, err := ParseTOML(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTOML: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
got any
|
||||
want any
|
||||
}{
|
||||
{"vertical", str(doc.String("vertical")), "demo"},
|
||||
{"work_root", str(doc.String("work_root")), "."},
|
||||
{"max_rounds", intg(doc.Table("loop").Int("max_rounds")), int64(4)},
|
||||
{"scope_query_id", intg(doc.Table("redmine").Int("scope_query_id")), int64(42)},
|
||||
{"class_field", str(doc.Table("redmine").String("class_field")), "Class"},
|
||||
{"timeout_secs", intg(doc.Table("litellm").Int("timeout_secs")), int64(30)},
|
||||
{"max_retries", intg(doc.Table("litellm").Int("max_retries")), int64(3)},
|
||||
{"tier", str(doc.Table("models").String("mopac-study")), "glm-4.7-flash"},
|
||||
{"default_tier", str(doc.Table("models").String("default_tier")), "mopac-study"},
|
||||
{"class map", str(doc.Table("models", "classes").String("study")), "mopac-study"},
|
||||
{"bash enabled", boolean(doc.Table("tools", "bash").Bool("enabled")), false},
|
||||
{"deep bool", boolean(doc.Table("deep", "nested", "table").Bool("flag")), true},
|
||||
{"escaped string", str(doc.Table("deep", "nested", "table").String("note")), `quote " and # inside string`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
allow, ok := doc.Table("tools", "bash").StringList("allow")
|
||||
if !ok || len(allow) != 3 || allow[1] != "ls *" {
|
||||
t.Errorf("allow list = %v ok=%v", allow, ok)
|
||||
}
|
||||
deny, ok := doc.Table("tools", "bash").StringList("deny")
|
||||
if !ok || len(deny) != 2 || deny[0] != "sudo *" {
|
||||
t.Errorf("deny list = %v ok=%v", deny, ok)
|
||||
}
|
||||
if empty, ok := doc.Table("deep", "nested", "table").StringList("empty"); !ok || len(empty) != 0 {
|
||||
t.Errorf("empty array = %v ok=%v", empty, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func str(v string, ok bool) string {
|
||||
if !ok {
|
||||
panic("string key missing")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func intg(v int64, ok bool) int64 {
|
||||
if !ok {
|
||||
panic("int key missing")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boolean(v bool, ok bool) bool {
|
||||
if !ok {
|
||||
panic("bool key missing")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestParseTOMLErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{"unterminated string", `a = "oops`, "unterminated string"},
|
||||
{"bare value", `a = sk-123`, "unsupported value"},
|
||||
{"missing value", `a =`, "missing value"},
|
||||
{"bad key", `a b = "c"`, "invalid key"},
|
||||
{"duplicate key", "a = \"1\"\na = \"2\"", "duplicate key"},
|
||||
{"scalar to table", "a = \"1\"\n[a.b]", "cannot become table"},
|
||||
{"unterminated array", "a = [\n\"x\",", "unterminated array"},
|
||||
{"bad table name", `[a.]`, "invalid table name"},
|
||||
{"no equals", `[t]\njustakey`, "expected key = value"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseTOML(strings.ReplaceAll(tc.src, `\n`, "\n"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error %q does not contain %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user