Skip to content

Commit e93505a

Browse files
authored
Merge pull request #164 from mhersson/feat/env-only-backends
feat(config): allow env-only backend configuration
2 parents 9e1fa8d + 3ae9866 commit e93505a

4 files changed

Lines changed: 116 additions & 41 deletions

File tree

config.yaml.example

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,7 @@ task_skills:
187187
# agent — contextmatrix-agent: executes cards only (no chat).
188188
# chat — contextmatrix-chat: serves global chat only (not yet released).
189189
#
190-
# Roles and callback paths are derived from the entry name — there are no
191-
# selector fields (default_backend / chat_backend are gone). CM picks:
190+
# Roles and callback paths are derived from the entry name. CM picks:
192191
# task backend → runner (if enabled) else agent (if enabled)
193192
# chat backend → runner (if enabled) else chat (if enabled)
194193
#
@@ -207,27 +206,17 @@ task_skills:
207206
#
208207
# RESTART REQUIRED: backends are read once at startup. Any change requires
209208
# a CM restart.
210-
#
211-
# MIGRATION (older configs): the old top-level `runner:` block is gone.
212-
# Move url/api_key into backends.runner (enabled defaults to true).
213-
# Rename env vars (CONTEXTMATRIX_RUNNER_* → CONTEXTMATRIX_BACKEND_RUNNER_*):
214-
# CONTEXTMATRIX_RUNNER_URL → ..._BACKEND_RUNNER_URL
215-
# CONTEXTMATRIX_RUNNER_API_KEY → ..._BACKEND_RUNNER_API_KEY
216-
# CONTEXTMATRIX_RUNNER_ENABLED → ..._BACKEND_RUNNER_ENABLED
217-
# CONTEXTMATRIX_RUNNER_ORCHESTRATOR_SONNET_MODEL → ..._BACKEND_RUNNER_ORCHESTRATOR_SONNET_MODEL
218-
# CONTEXTMATRIX_RUNNER_ORCHESTRATOR_OPUS_MODEL → ..._BACKEND_RUNNER_ORCHESTRATOR_OPUS_MODEL
219-
# CONTEXTMATRIX_RUNNER_RECONCILE_INTERVAL → ..._BACKEND_RUNNER_RECONCILE_INTERVAL
220-
# The server hard-fails if legacy CONTEXTMATRIX_RUNNER_* vars are still set.
221209

222210
# Backend entries. Valid names: runner, agent, chat.
223-
# Env per-entry (NAME = RUNNER | AGENT | CHAT):
211+
# Every field has an env override (NAME = RUNNER | AGENT | CHAT). Env values
212+
# take precedence over this file, and a backend can be configured through
213+
# env vars alone:
224214
# CONTEXTMATRIX_BACKEND_<NAME>_URL
225215
# CONTEXTMATRIX_BACKEND_<NAME>_API_KEY
226216
# CONTEXTMATRIX_BACKEND_<NAME>_ENABLED
227217
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_SONNET_MODEL
228218
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_OPUS_MODEL
229219
# CONTEXTMATRIX_BACKEND_<NAME>_RECONCILE_INTERVAL
230-
# The backend entry must be declared in YAML before env vars for it are read.
231220
backends: {}
232221
# runner:
233222
# # Base URL of the backend. Protocol paths are appended: /trigger, /kill,

docs/remote-execution.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,13 +1422,14 @@ Environment variable overrides:
14221422
- `CONTEXTMATRIX_CHAT_IDLE_TTL`
14231423
- `CONTEXTMATRIX_CHAT_MAX_CONCURRENT`
14241424

1425-
The `CONTEXTMATRIX_BACKEND_<NAME>_*` scheme generalises to any declared backend:
1425+
The `CONTEXTMATRIX_BACKEND_<NAME>_*` scheme generalises to any backend:
14261426
replace `RUNNER` with the uppercased entry name (`AGENT`, `CHAT`). Supported
14271427
suffixes are `_URL`, `_API_KEY`, `_ENABLED`, `_ORCHESTRATOR_SONNET_MODEL`,
1428-
`_ORCHESTRATOR_OPUS_MODEL`, and `_RECONCILE_INTERVAL`. The entry must be
1429-
declared in YAML; unrecognised names or suffixes fail loudly at startup, and
1430-
the task-only fields are still rejected on the `chat` entry whichever channel
1431-
sets them.
1428+
`_ORCHESTRATOR_OPUS_MODEL`, and `_RECONCILE_INTERVAL`. Env values take
1429+
precedence over the config file, and a backend can be configured through env
1430+
vars alone. Unrecognised names or suffixes fail loudly at startup, and
1431+
validation is identical whichever channel sets a field (URL required,
1432+
≥32-char key, task-only fields rejected on the `chat` entry).
14321433

14331434
**Migration from older configs:** the top-level `runner:` block and
14341435
`CONTEXTMATRIX_RUNNER_*` env vars are gone. Move `url`/`api_key` into

internal/config/config.go

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import (
55
"fmt"
66
"io"
77
"log/slog"
8-
"maps"
98
"os"
109
"path/filepath"
11-
"slices"
1210
"strconv"
1311
"strings"
1412
"time"
@@ -38,6 +36,18 @@ const (
3836
// allowlisting. Order is stable (runner, agent, chat) for error messages.
3937
var allowedBackendNames = []string{BackendNameRunner, BackendNameAgent, BackendNameChat}
4038

39+
// backendEnvSuffixes are the per-entry CONTEXTMATRIX_BACKEND_<NAME>_* env
40+
// var suffixes. applyEnvOverrides reads each one; checkBackendEnvKeys
41+
// allowlists the same set — keep the two in sync via this list.
42+
var backendEnvSuffixes = []string{
43+
"_URL",
44+
"_API_KEY",
45+
"_ENABLED",
46+
"_ORCHESTRATOR_SONNET_MODEL",
47+
"_ORCHESTRATOR_OPUS_MODEL",
48+
"_RECONCILE_INTERVAL",
49+
}
50+
4151
// BackendConfig is one entry in the backends map: an execution backend CM
4252
// can drive over the contextmatrix-protocol webhook contract. Read once at
4353
// startup — changing backends requires a CM restart.
@@ -581,7 +591,7 @@ func Load(path string) (*Config, error) {
581591
return nil, err
582592
}
583593

584-
if err := checkBackendEnvKeys(cfg); err != nil {
594+
if err := checkBackendEnvKeys(); err != nil {
585595
return nil, err
586596
}
587597

@@ -618,7 +628,7 @@ func Load(path string) (*Config, error) {
618628
return nil, err
619629
}
620630

621-
if err := checkBackendEnvKeys(cfg); err != nil {
631+
if err := checkBackendEnvKeys(); err != nil {
622632
return nil, err
623633
}
624634

@@ -950,9 +960,27 @@ func applyEnvOverrides(cfg *Config) error {
950960
}
951961
}
952962

953-
for name, b := range cfg.Backends {
963+
// Backend entries can be configured entirely via env: a variable for one
964+
// of the allowed names creates the entry when YAML does not declare it,
965+
// so pure-env deployments need no backends stub in the config file.
966+
for _, name := range allowedBackendNames {
954967
prefix := "CONTEXTMATRIX_BACKEND_" + strings.ToUpper(name)
955968

969+
anySet := false
970+
971+
for _, suffix := range backendEnvSuffixes {
972+
if os.Getenv(prefix+suffix) != "" {
973+
anySet = true
974+
975+
break
976+
}
977+
}
978+
979+
b, declared := cfg.Backends[name]
980+
if !declared && !anySet {
981+
continue
982+
}
983+
956984
if v := os.Getenv(prefix + "_URL"); v != "" {
957985
b.URL = v
958986
}
@@ -985,29 +1013,29 @@ func applyEnvOverrides(cfg *Config) error {
9851013
b.ReconcileInterval = v
9861014
}
9871015

1016+
if cfg.Backends == nil {
1017+
cfg.Backends = map[string]BackendConfig{}
1018+
}
1019+
9881020
cfg.Backends[name] = b
9891021
}
9901022

9911023
return nil
9921024
}
9931025

9941026
// checkBackendEnvKeys rejects CONTEXTMATRIX_BACKEND_<NAME>_* variables that
995-
// do not map to a known (name, suffix) pair. The backend name must be in the
996-
// closed set (runner, agent, chat) AND be declared in cfg.Backends; the suffix
997-
// must be one of the per-entry fields below. A typo'd or stale variable must
998-
// fail loudly, not silently configure nothing. NOTE: when adding a new
999-
// per-backend env field to applyEnvOverrides, add it to the known set here too.
1000-
func checkBackendEnvKeys(cfg *Config) error {
1027+
// do not map to a known (name, suffix) pair: the name must be in the closed
1028+
// set (runner, agent, chat) and the suffix in backendEnvSuffixes. A typo'd
1029+
// or stale variable must fail loudly, not silently configure nothing. The
1030+
// entry does not need to be declared in YAML — applyEnvOverrides creates it.
1031+
func checkBackendEnvKeys() error {
10011032
known := map[string]bool{}
10021033

1003-
for name := range cfg.Backends {
1034+
for _, name := range allowedBackendNames {
10041035
pfx := "CONTEXTMATRIX_BACKEND_" + strings.ToUpper(name)
1005-
known[pfx+"_URL"] = true
1006-
known[pfx+"_API_KEY"] = true
1007-
known[pfx+"_ENABLED"] = true
1008-
known[pfx+"_ORCHESTRATOR_SONNET_MODEL"] = true
1009-
known[pfx+"_ORCHESTRATOR_OPUS_MODEL"] = true
1010-
known[pfx+"_RECONCILE_INTERVAL"] = true
1036+
for _, suffix := range backendEnvSuffixes {
1037+
known[pfx+suffix] = true
1038+
}
10111039
}
10121040

10131041
for _, kv := range os.Environ() {
@@ -1017,11 +1045,11 @@ func checkBackendEnvKeys(cfg *Config) error {
10171045
}
10181046

10191047
if !known[key] {
1020-
return fmt.Errorf("%s references no configured backend "+
1021-
"(allowed names: %s; declared: %v) — fix the variable name or add the backends entry",
1048+
return fmt.Errorf("%s is not a recognised backend env var "+
1049+
"(names: %s; suffixes: %s) — fix the variable name",
10221050
key,
10231051
strings.Join(allowedBackendNames, ", "),
1024-
slices.Sorted(maps.Keys(cfg.Backends)))
1052+
strings.Join(backendEnvSuffixes, ", "))
10251053
}
10261054
}
10271055

internal/config/config_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2498,6 +2498,63 @@ backends:
24982498
assert.Equal(t, "http://override:9993", cb.URL)
24992499
}
25002500

2501+
func TestBackendEnvOnlyConfiguration(t *testing.T) {
2502+
// No backends block in YAML at all: env vars for a valid name create
2503+
// the entry, so pure-env deployments need no YAML stub.
2504+
dir := t.TempDir()
2505+
boardsDir := t.TempDir()
2506+
path := writeConfigFile(t, dir, minValidBase(boardsDir))
2507+
2508+
t.Setenv("CONTEXTMATRIX_BACKEND_RUNNER_URL", "http://env-only:9090")
2509+
t.Setenv("CONTEXTMATRIX_BACKEND_RUNNER_API_KEY", strings.Repeat("e", 32))
2510+
t.Setenv("CONTEXTMATRIX_BACKEND_RUNNER_ENABLED", "true")
2511+
2512+
cfg, err := Load(path)
2513+
require.NoError(t, err)
2514+
2515+
tb, ok := cfg.TaskBackendConfig()
2516+
require.True(t, ok, "env-only runner must resolve as task backend")
2517+
assert.Equal(t, "runner", tb.Name)
2518+
assert.Equal(t, "http://env-only:9090", tb.URL)
2519+
// The env-created entry gets the task defaults like a YAML-declared one.
2520+
assert.Equal(t, "claude-sonnet-4-6", tb.OrchestratorSonnetModel)
2521+
assert.Equal(t, "claude-opus-4-8", tb.OrchestratorOpusModel)
2522+
assert.Equal(t, "60s", tb.ReconcileInterval)
2523+
}
2524+
2525+
func TestBackendEnvOnlyChatConfiguration(t *testing.T) {
2526+
dir := t.TempDir()
2527+
boardsDir := t.TempDir()
2528+
path := writeConfigFile(t, dir, minValidBase(boardsDir))
2529+
2530+
t.Setenv("CONTEXTMATRIX_BACKEND_CHAT_URL", "http://env-only:9092")
2531+
t.Setenv("CONTEXTMATRIX_BACKEND_CHAT_API_KEY", strings.Repeat("e", 32))
2532+
2533+
cfg, err := Load(path)
2534+
require.NoError(t, err)
2535+
2536+
cb, ok := cfg.ChatBackendConfig()
2537+
require.True(t, ok, "env-only chat must resolve as chat backend")
2538+
assert.Equal(t, "chat", cb.Name)
2539+
2540+
_, ok = cfg.TaskBackendConfig()
2541+
assert.False(t, ok, "chat-only config must not resolve a task backend")
2542+
}
2543+
2544+
func TestBackendEnvOnlyIncompleteErrors(t *testing.T) {
2545+
// An env-created entry goes through full validation: enabling a backend
2546+
// via env without a URL fails loudly instead of half-configuring.
2547+
dir := t.TempDir()
2548+
boardsDir := t.TempDir()
2549+
path := writeConfigFile(t, dir, minValidBase(boardsDir))
2550+
2551+
t.Setenv("CONTEXTMATRIX_BACKEND_RUNNER_ENABLED", "true")
2552+
2553+
_, err := Load(path)
2554+
require.Error(t, err)
2555+
assert.Contains(t, err.Error(), "url")
2556+
}
2557+
25012558
func TestBackendEnvUnknownNameErrors(t *testing.T) {
25022559
cases := []struct {
25032560
name string

0 commit comments

Comments
 (0)