Files
mrcharles c54a5a4f82 config: [serve] section for the OpenAI-compatible front door
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
2026-08-29 01:10:35 -05:00

489 lines
14 KiB
Go

package config
import (
"fmt"
"strings"
)
// 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
Events EventsConfig
Serve ServeConfig
KeyProxy KeyProxyConfig
Gitea GiteaConfig
}
// LoopConfig is both the per-turn bound and the `harness loop` daemon
// cadence + state location.
type LoopConfig struct {
MaxRounds int
PollIntervalSecs int // loop: Redmine scan interval (default 120)
StateDir string // loop: append-only loop.jsonl + dedup index
}
type RedmineConfig struct {
URL string
KeyRef string
ScopeQuery string
ScopeQueryID int
ClassField string
DefaultClass string
Limit int
// StatusMap: issue status name -> status name to set after a REPORT
// lands (loop path only; e.g. [redmine.status_map] "In Progress" =
// "Done"). Empty map = leave status alone.
StatusMap map[string]string
}
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
}
// ServeConfig is the `harness serve` OpenAI-compatible front door (the
// OpenWebUI connection). Own port — coexists with [events].
type ServeConfig struct {
Listen string // bind address (default ":8090"; publish via docker -p)
VKeyRef string // bearer vkey ref; the key OWUI connections present
EnabledModels []string // optional subset of the catalog (mopac-<class>); empty = all classes
}
// KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs
// POST here. All hosts/paths live in this config, never in code.
type KeyProxyConfig struct {
URL string // hop base url (e.g. http://127.0.0.1:8082)
TokenRef string // local ref (env:/file:/literal:) holding the bearer token
CacheTTLSecs int // in-memory mpk resolution cache (default 60)
}
// GiteaConfig is the optional REPORT-commit step (off by default). When
// commit_reports is true the loop commits each REPORT file to the
// configured repo right after writing it.
type GiteaConfig struct {
URL string
KeyRef string
Owner string
Repo string
Branch string
CommitReports bool
}
// EventsConfig is the `harness events` webhook receiver surface.
type EventsConfig struct {
Listen string // bind address (publish via docker -p)
StateDir string // append-only events.jsonl + dedup index
Redmine EventSourceConfig
Discourse EventSourceConfig
Gitea EventSourceConfig
}
// EventSourceConfig is one provider's webhook verification setup. Secrets
// are refs only (env:/file:/literal:); values never live in this file, are
// never logged, and never reach the event log.
type EventSourceConfig struct {
SecretRef string
SecretHeader string // shared-secret header name; gitea ignores it (HMAC)
}
// Default returns the built-in defaults for every field.
func Default() *Config {
return &Config{
WorkRoot: ".",
ReportDir: "reports",
Loop: LoopConfig{
MaxRounds: 8,
PollIntervalSecs: 120,
StateDir: "state/loop",
},
Redmine: RedmineConfig{
ClassField: "Class",
DefaultClass: "primary",
Limit: 50,
StatusMap: map[string]string{},
},
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},
Events: EventsConfig{
Listen: ":4100",
StateDir: "state/events",
Redmine: EventSourceConfig{
SecretHeader: "X-Redmine-Webhook-Secret",
},
Discourse: EventSourceConfig{
SecretHeader: "X-Discourse-Webhook-Secret",
},
},
Serve: ServeConfig{Listen: ":8090"},
KeyProxy: KeyProxyConfig{CacheTTLSecs: 60},
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)
}
if v, ok := doc.Table("loop").Int("poll_interval_secs"); ok {
c.Loop.PollIntervalSecs = int(v)
}
if v, ok := doc.Table("loop").String("state_dir"); ok {
c.Loop.StateDir = 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)
}
for _, k := range doc.Table("redmine", "status_map").Keys() {
if v, ok := doc.Table("redmine", "status_map").String(k); ok {
if c.Redmine.StatusMap == nil {
c.Redmine.StatusMap = map[string]string{}
}
c.Redmine.StatusMap[k] = 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
}
kp := doc.Table("keyproxy")
if v, ok := kp.String("url"); ok {
c.KeyProxy.URL = v
}
if v, ok := kp.String("token_ref"); ok {
c.KeyProxy.TokenRef = v
}
if v, ok := kp.Int("cache_ttl_secs"); ok {
c.KeyProxy.CacheTTLSecs = int(v)
}
gt := doc.Table("gitea")
if v, ok := gt.String("url"); ok {
c.Gitea.URL = v
}
if v, ok := gt.String("key_ref"); ok {
c.Gitea.KeyRef = v
}
if v, ok := gt.String("owner"); ok {
c.Gitea.Owner = v
}
if v, ok := gt.String("repo"); ok {
c.Gitea.Repo = v
}
if v, ok := gt.String("branch"); ok {
c.Gitea.Branch = v
}
if v, ok := gt.Bool("commit_reports"); ok {
c.Gitea.CommitReports = v
}
ev := doc.Table("events")
if v, ok := ev.String("listen"); ok {
c.Events.Listen = v
}
if v, ok := ev.String("state_dir"); ok {
c.Events.StateDir = v
}
applyEventSource(&c.Events.Redmine, doc.Table("events", "redmine"))
applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse"))
applyEventSource(&c.Events.Gitea, doc.Table("events", "gitea"))
sv := doc.Table("serve")
if v, ok := sv.String("listen"); ok {
c.Serve.Listen = v
}
if v, ok := sv.String("vkey_ref"); ok {
c.Serve.VKeyRef = v
}
if v, ok := sv.StringList("enabled_models"); ok {
c.Serve.EnabledModels = v
}
return nil
}
func applyEventSource(dst *EventSourceConfig, src TOMLDoc) {
if v, ok := src.String("secret_ref"); ok {
dst.SecretRef = v
}
if v, ok := src.String("secret_header"); ok {
dst.SecretHeader = v
}
}
// 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")
}
// Events are optional (`once` configs need none); refs that ARE set must
// be well-formed so the receiver fails at startup, not mid-webhook.
for name, src := range map[string]EventSourceConfig{
"redmine": c.Events.Redmine,
"discourse": c.Events.Discourse,
"gitea": c.Events.Gitea,
} {
if src.SecretRef == "" {
continue
}
if err := CheckKeyRef(src.SecretRef); err != nil {
return fmt.Errorf("[events.%s]: %w", name, err)
}
}
if c.Events.Listen == "" || c.Events.StateDir == "" {
return fmt.Errorf("[events]: listen and state_dir must not be empty")
}
// Serve is optional (`once`/`loop` configs need none); a vkey that IS
// set must be well-formed, and enabled_models entries must name real
// catalog models (mopac-<class>) so the front door fails at startup.
if c.Serve.VKeyRef != "" {
if err := CheckKeyRef(c.Serve.VKeyRef); err != nil {
return fmt.Errorf("[serve]: %w", err)
}
}
for _, m := range c.Serve.EnabledModels {
class := strings.TrimPrefix(m, "mopac-")
if !strings.HasPrefix(m, "mopac-") || class == "" || c.Models.Classes[class] == "" {
return fmt.Errorf("[serve]: enabled_models entry %q is not servable (must be mopac-<class> from [models.classes])", m)
}
}
if c.Serve.Listen == "" {
return fmt.Errorf("[serve]: listen must not be empty")
}
if c.Loop.PollIntervalSecs < 1 {
return fmt.Errorf("[loop]: poll_interval_secs must be >= 1")
}
if c.Loop.StateDir == "" {
return fmt.Errorf("[loop]: state_dir must not be empty")
}
// Keyproxy is optional; if any piece is set, url + token_ref must be.
if c.KeyProxy.URL != "" || c.KeyProxy.TokenRef != "" {
if c.KeyProxy.URL == "" {
return fmt.Errorf("[keyproxy]: url is required when keyproxy is configured")
}
if c.KeyProxy.TokenRef == "" {
return fmt.Errorf("[keyproxy]: token_ref is required when keyproxy is configured (the bearer token for /v1/resolve)")
}
if err := CheckKeyRef(c.KeyProxy.TokenRef); err != nil {
return fmt.Errorf("[keyproxy]: %w", err)
}
if strings.HasPrefix(c.KeyProxy.TokenRef, "mpk:") {
return fmt.Errorf("[keyproxy]: token_ref must be a local ref (env:/file:/literal:), not mpk:")
}
if c.KeyProxy.CacheTTLSecs < 1 {
return fmt.Errorf("[keyproxy]: bad cache_ttl_secs")
}
}
// Gitea REPORT commit is optional; when on, the minimum set must be set.
if c.Gitea.CommitReports {
if c.Gitea.URL == "" || c.Gitea.KeyRef == "" || c.Gitea.Owner == "" || c.Gitea.Repo == "" {
return fmt.Errorf("[gitea]: url, key_ref, owner and repo are required when commit_reports = true")
}
if err := CheckKeyRef(c.Gitea.KeyRef); err != nil {
return fmt.Errorf("[gitea]: %w", err)
}
}
return nil
}