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.
This commit is contained in:
2026-08-28 21:38:06 -05:00
parent c6da05fac5
commit 164b14592b
3 changed files with 176 additions and 0 deletions
+66
View File
@@ -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
}