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:
@@ -102,3 +102,24 @@ id = "demo-1"
|
|||||||
subject = "MVP demo: GLM self-description"
|
subject = "MVP demo: GLM self-description"
|
||||||
prompt = "tell me about yourself"
|
prompt = "tell me about yourself"
|
||||||
class = "primary"
|
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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
Models ModelsConfig
|
Models ModelsConfig
|
||||||
Bash BashConfig
|
Bash BashConfig
|
||||||
Demo DemoConfig
|
Demo DemoConfig
|
||||||
|
Events EventsConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoopConfig struct {
|
type LoopConfig struct {
|
||||||
@@ -64,6 +65,23 @@ type DemoConfig struct {
|
|||||||
Class string
|
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.
|
// Default returns the built-in defaults for every field.
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
@@ -82,6 +100,16 @@ func Default() *Config {
|
|||||||
DefaultTier: "mopac-primary",
|
DefaultTier: "mopac-primary",
|
||||||
},
|
},
|
||||||
Bash: BashConfig{Enabled: true, TimeoutSecs: 60, MaxOutputBytes: 100_000},
|
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{
|
Demo: DemoConfig{
|
||||||
ID: "demo-1",
|
ID: "demo-1",
|
||||||
Subject: "MVP demo: GLM self-description",
|
Subject: "MVP demo: GLM self-description",
|
||||||
@@ -201,9 +229,29 @@ func (c *Config) apply(doc TOMLDoc) error {
|
|||||||
if v, ok := dm.String("class"); ok {
|
if v, ok := dm.String("class"); ok {
|
||||||
c.Demo.Class = v
|
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
|
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.
|
// Validate enforces the invariants the conductor relies on.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if c.Vertical == "" {
|
if c.Vertical == "" {
|
||||||
@@ -261,5 +309,23 @@ func (c *Config) Validate() error {
|
|||||||
if c.Demo.Prompt == "" {
|
if c.Demo.Prompt == "" {
|
||||||
return fmt.Errorf("[demo]: prompt is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,3 +200,92 @@ func TestResolveKeyRef(t *testing.T) {
|
|||||||
t.Errorf("bare ref error leaks value: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user