listen (default :8090, own port so it coexists with [events]), vkey_ref
(the bearer key OpenWebUI connections present; refs only, resolved at
startup), and an optional enabled_models subset of the catalog. Entries
must name a real servable model (mopac-<class> from [models.classes]) so
a typo fails at config load, not mid-request. Example file extended;
once/loop configs without [serve] stay valid.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
506 lines
14 KiB
Go
506 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"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:, mpk:, 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)
|
|
}
|
|
}
|
|
|
|
func TestEventsDefaults(t *testing.T) {
|
|
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.Events.Listen != ":4100" {
|
|
t.Errorf("default listen = %q, want :4100", cfg.Events.Listen)
|
|
}
|
|
if cfg.Events.StateDir != "state/events" {
|
|
t.Errorf("default state_dir = %q, want state/events", cfg.Events.StateDir)
|
|
}
|
|
if cfg.Events.Redmine.SecretHeader != "X-Redmine-Webhook-Secret" {
|
|
t.Errorf("default redmine secret_header = %q", cfg.Events.Redmine.SecretHeader)
|
|
}
|
|
if cfg.Events.Discourse.SecretHeader != "X-Discourse-Webhook-Secret" {
|
|
t.Errorf("default discourse secret_header = %q", cfg.Events.Discourse.SecretHeader)
|
|
}
|
|
}
|
|
|
|
func TestEventsConfigParsed(t *testing.T) {
|
|
cfg, err := Load(writeTemp(t, `
|
|
vertical = "demo"
|
|
[litellm]
|
|
base_url = "http://x:4001"
|
|
key_ref = "literal:test-key"
|
|
[models]
|
|
mopac-primary = "glm-5.3"
|
|
|
|
[events]
|
|
listen = "127.0.0.1:9999"
|
|
state_dir = "st/ev"
|
|
|
|
[events.redmine]
|
|
secret_ref = "env:HARNESS_RM_HOOK"
|
|
secret_header = "X-Custom-Redmine"
|
|
|
|
[events.discourse]
|
|
secret_ref = "file:/run/secrets/discourse"
|
|
|
|
[events.gitea]
|
|
secret_ref = "env:HARNESS_GITEA_HOOK"
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Events.Listen != "127.0.0.1:9999" || cfg.Events.StateDir != "st/ev" {
|
|
t.Errorf("events core wrong: %+v", cfg.Events)
|
|
}
|
|
if cfg.Events.Redmine.SecretRef != "env:HARNESS_RM_HOOK" ||
|
|
cfg.Events.Redmine.SecretHeader != "X-Custom-Redmine" {
|
|
t.Errorf("redmine source wrong: %+v", cfg.Events.Redmine)
|
|
}
|
|
if cfg.Events.Discourse.SecretRef != "file:/run/secrets/discourse" {
|
|
t.Errorf("discourse source wrong: %+v", cfg.Events.Discourse)
|
|
}
|
|
if cfg.Events.Gitea.SecretRef != "env:HARNESS_GITEA_HOOK" {
|
|
t.Errorf("gitea source wrong: %+v", cfg.Events.Gitea)
|
|
}
|
|
}
|
|
|
|
func TestEventsValidateBadRef(t *testing.T) {
|
|
_, err := Load(writeTemp(t, `
|
|
vertical = "demo"
|
|
[litellm]
|
|
base_url = "http://x:4001"
|
|
key_ref = "literal:test-key"
|
|
[models]
|
|
mopac-primary = "glm-5.3"
|
|
[events.gitea]
|
|
secret_ref = "HARNESS_GITEA_HOOK"
|
|
`))
|
|
if err == nil {
|
|
t.Fatalf("expected validation error for unprefixed secret_ref")
|
|
}
|
|
if !strings.Contains(err.Error(), "[events.gitea]") {
|
|
t.Errorf("error %q does not name [events.gitea]", err)
|
|
}
|
|
if strings.Contains(err.Error(), "HARNESS_GITEA_HOOK") {
|
|
t.Errorf("error leaks ref value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoopAndKeyProxyAndGiteaParsed(t *testing.T) {
|
|
cfg, err := Load(writeTemp(t, `
|
|
vertical = "demo"
|
|
[litellm]
|
|
base_url = "http://x:4001"
|
|
key_ref = "mpk:mpk-litellm"
|
|
[models]
|
|
mopac-primary = "glm-5.3"
|
|
|
|
[loop]
|
|
max_rounds = 6
|
|
poll_interval_secs = 45
|
|
state_dir = "st/loop"
|
|
|
|
[redmine]
|
|
url = "https://rm.test"
|
|
key_ref = "mpk:redmine"
|
|
scope_query = "project=x"
|
|
|
|
[redmine.status_map]
|
|
"In Progress" = "Done"
|
|
New = "In Progress"
|
|
|
|
[keyproxy]
|
|
url = "http://127.0.0.1:8082"
|
|
token_ref = "literal:kp-token"
|
|
cache_ttl_secs = 30
|
|
|
|
[gitea]
|
|
url = "https://git.test"
|
|
key_ref = "mpk:gitea"
|
|
owner = "ukrrs"
|
|
repo = "MOPAC"
|
|
branch = "reports"
|
|
commit_reports = true
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Loop.PollIntervalSecs != 45 || cfg.Loop.StateDir != "st/loop" || cfg.Loop.MaxRounds != 6 {
|
|
t.Errorf("loop wrong: %+v", cfg.Loop)
|
|
}
|
|
if cfg.Redmine.StatusMap["In Progress"] != "Done" || cfg.Redmine.StatusMap["New"] != "In Progress" {
|
|
t.Errorf("status map wrong: %+v", cfg.Redmine.StatusMap)
|
|
}
|
|
if cfg.KeyProxy.URL != "http://127.0.0.1:8082" || cfg.KeyProxy.TokenRef != "literal:kp-token" || cfg.KeyProxy.CacheTTLSecs != 30 {
|
|
t.Errorf("keyproxy wrong: %+v", cfg.KeyProxy)
|
|
}
|
|
if !cfg.Gitea.CommitReports || cfg.Gitea.Owner != "ukrrs" || cfg.Gitea.Repo != "MOPAC" || cfg.Gitea.Branch != "reports" {
|
|
t.Errorf("gitea wrong: %+v", cfg.Gitea)
|
|
}
|
|
}
|
|
|
|
func TestLoopDefaults(t *testing.T) {
|
|
cfg, err := Load(writeTemp(t, `
|
|
vertical = "demo"
|
|
[litellm]
|
|
base_url = "http://x:4001"
|
|
key_ref = "literal:k"
|
|
[models]
|
|
mopac-primary = "glm-5.3"
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Loop.PollIntervalSecs != 120 {
|
|
t.Errorf("default poll_interval_secs = %d, want 120", cfg.Loop.PollIntervalSecs)
|
|
}
|
|
if cfg.Loop.StateDir != "state/loop" {
|
|
t.Errorf("default loop state_dir = %q", cfg.Loop.StateDir)
|
|
}
|
|
if cfg.KeyProxy.URL != "" || cfg.Gitea.CommitReports {
|
|
t.Errorf("keyproxy/gitea must default off: %+v %+v", cfg.KeyProxy, cfg.Gitea)
|
|
}
|
|
}
|
|
|
|
func TestKeyProxyValidation(t *testing.T) {
|
|
cases := []struct{ name, extra, want string }{
|
|
{
|
|
name: "token without url",
|
|
extra: "[keyproxy]\ntoken_ref = \"literal:t\"\n",
|
|
want: "[keyproxy]: url is required",
|
|
},
|
|
{
|
|
name: "url without token",
|
|
extra: "[keyproxy]\nurl = \"http://127.0.0.1:8082\"\n",
|
|
want: "[keyproxy]: token_ref is required",
|
|
},
|
|
{
|
|
name: "mpk token ref (recursion)",
|
|
extra: "[keyproxy]\nurl = \"http://127.0.0.1:8082\"\ntoken_ref = \"mpk:self\"\n",
|
|
want: "must be a local ref",
|
|
},
|
|
{
|
|
name: "gitea on but incomplete",
|
|
extra: "[gitea]\ncommit_reports = true\nurl = \"https://git.test\"\n",
|
|
want: "[gitea]: url, key_ref, owner and repo are required",
|
|
},
|
|
{
|
|
name: "loop poll too small",
|
|
extra: "[loop]\npoll_interval_secs = 0\n",
|
|
want: "[loop]: poll_interval_secs must be >= 1",
|
|
},
|
|
}
|
|
base := `
|
|
vertical = "demo"
|
|
[litellm]
|
|
base_url = "http://x:4001"
|
|
key_ref = "literal:k"
|
|
[models]
|
|
mopac-primary = "glm-5.3"
|
|
`
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := Load(writeTemp(t, base+tc.extra))
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q", tc.want)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error %q does not contain %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckKeyRefMPK(t *testing.T) {
|
|
for _, ref := range []string{"mpk:mpk-redmine", "mpk:redmine"} {
|
|
if err := CheckKeyRef(ref); err != nil {
|
|
t.Errorf("CheckKeyRef(%q) = %v, want nil", ref, err)
|
|
}
|
|
}
|
|
if err := CheckKeyRef("mpk:"); err == nil {
|
|
t.Errorf("CheckKeyRef(\"mpk:\") should fail")
|
|
}
|
|
// The local resolver must point mpk: users at the configured resolver.
|
|
_, err := ResolveKeyRef("mpk:redmine")
|
|
if err == nil || !strings.Contains(err.Error(), "[keyproxy]") {
|
|
t.Errorf("local ResolveKeyRef on mpk: should name [keyproxy], got %v", err)
|
|
}
|
|
}
|
|
|
|
const serveCfg = testCfg + `
|
|
[serve]
|
|
listen = "127.0.0.1:9099"
|
|
vkey_ref = "file:/run/secrets/owui-vkey"
|
|
enabled_models = ["mopac-study", "mopac-primary"]
|
|
`
|
|
|
|
func TestServeSectionParsed(t *testing.T) {
|
|
cfg, err := Load(writeTemp(t, serveCfg))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Serve.Listen != "127.0.0.1:9099" {
|
|
t.Errorf("serve listen = %q", cfg.Serve.Listen)
|
|
}
|
|
if cfg.Serve.VKeyRef != "file:/run/secrets/owui-vkey" {
|
|
t.Errorf("serve vkey_ref = %q", cfg.Serve.VKeyRef)
|
|
}
|
|
if fmt.Sprint(cfg.Serve.EnabledModels) != "[mopac-study mopac-primary]" {
|
|
t.Errorf("serve enabled_models = %v", cfg.Serve.EnabledModels)
|
|
}
|
|
}
|
|
|
|
func TestServeDefaults(t *testing.T) {
|
|
// No [serve] section: once/loop configs stay valid, default port 8090.
|
|
cfg, err := Load(writeTemp(t, testCfg))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Serve.Listen != ":8090" {
|
|
t.Errorf("default serve listen = %q, want :8090", cfg.Serve.Listen)
|
|
}
|
|
if cfg.Serve.VKeyRef != "" || len(cfg.Serve.EnabledModels) != 0 {
|
|
t.Errorf("serve must default empty: %+v", cfg.Serve)
|
|
}
|
|
}
|
|
|
|
func TestServeValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mut func(string) string
|
|
want string
|
|
}{
|
|
{
|
|
name: "bare vkey value",
|
|
mut: func(s string) string { return strings.Replace(s, `vkey_ref = "file:/run/secrets/owui-vkey"`, `vkey_ref = "sk-owui"`, 1) },
|
|
want: "[serve]:",
|
|
},
|
|
{
|
|
name: "enabled model not mopac-prefixed",
|
|
mut: func(s string) string { return strings.Replace(s, `"mopac-study", "mopac-primary"`, `"glm-5.2", "mopac-primary"`, 1) },
|
|
want: `enabled_models entry "glm-5.2" is not servable`,
|
|
},
|
|
{
|
|
name: "enabled model names no class",
|
|
mut: func(s string) string { return strings.Replace(s, `"mopac-study", "mopac-primary"`, `"mopac-urgent", "mopac-primary"`, 1) },
|
|
want: `enabled_models entry "mopac-urgent" is not servable`,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := Load(writeTemp(t, tc.mut(serveCfg)))
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|