From 164b14592bb5fd9ecf138fef86500b8ef2b9f001 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 21:38:06 -0500 Subject: [PATCH] events: config surface for the webhook receiver [events] listen/state_dir plus per-source secret refs (env:/file:/literal:) for redmine, discourse and gitea webhooks. Defaults: :4100, state/events, X-Redmine/X-Discourse-Webhook-Secret headers. Refs validate fail-fast; set refs must be well-formed or config load names the [events.*] section. --- harness.toml.example | 21 ++++++++ internal/config/config.go | 66 +++++++++++++++++++++++++ internal/config/config_test.go | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/harness.toml.example b/harness.toml.example index a5ea445..93e9b0f 100644 --- a/harness.toml.example +++ b/harness.toml.example @@ -102,3 +102,24 @@ id = "demo-1" subject = "MVP demo: GLM self-description" prompt = "tell me about yourself" class = "primary" + +# EVENTS: the `harness events` webhook receiver (Redmine/Discourse/Gitea +# punch the harness; DESIGN "Events are V1 scope"). Secrets are refs only, +# resolved at startup, never logged. At least one secret_ref must resolve +# or the receiver refuses to start. +[events] +listen = ":4100" # bind address; publish on the LAN via docker -p +state_dir = "state/events" # append-only events.jsonl + dedup index + +[events.redmine] +secret_ref = "env:HARNESS_REDMINE_WEBHOOK_SECRET" +# secret_header = "X-Redmine-Webhook-Secret" # default; match your plugin + +[events.discourse] +secret_ref = "env:HARNESS_DISCOURSE_WEBHOOK_SECRET" +# secret_header = "X-Discourse-Webhook-Secret" # default + +[events.gitea] +secret_ref = "env:HARNESS_GITEA_WEBHOOK_SECRET" +# Gitea always verifies via HMAC-SHA256 in X-Gitea-Signature; the +# secret_header override does not apply to it. diff --git a/internal/config/config.go b/internal/config/config.go index 43edcc4..22719e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { Models ModelsConfig Bash BashConfig Demo DemoConfig + Events EventsConfig } type LoopConfig struct { @@ -64,6 +65,23 @@ type DemoConfig struct { Class string } +// 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{ @@ -82,6 +100,16 @@ func Default() *Config { 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", + }, + }, Demo: DemoConfig{ ID: "demo-1", Subject: "MVP demo: GLM self-description", @@ -201,9 +229,29 @@ func (c *Config) apply(doc TOMLDoc) error { if v, ok := dm.String("class"); ok { c.Demo.Class = 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")) 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 == "" { @@ -261,5 +309,23 @@ func (c *Config) Validate() error { 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") + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b0be74..2a8da49 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -200,3 +200,92 @@ func TestResolveKeyRef(t *testing.T) { 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) + } +}