Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
This commit is contained in:
+24
-12
@@ -332,12 +332,11 @@ func Load(path string) (*Config, error) {
|
||||
// file committed to git.
|
||||
expanded, missing := expandEnv(string(b))
|
||||
if len(missing) > 0 {
|
||||
// An unset variable expands to "", which every block reads as "not
|
||||
// configured" and none of them complains about. That is the intended
|
||||
// behaviour and it stays: CI parses this same file with no secrets
|
||||
// present. What was missing is the line telling the operator which
|
||||
// capability he just turned off by forgetting an env file.
|
||||
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
|
||||
// Expansion happens before typed validation. A disabled block may carry
|
||||
// empty placeholders; an enabled block that needs one of these values is
|
||||
// rejected below. Log the names too so a failed deploy says which secret
|
||||
// source was absent without ever printing a value.
|
||||
log.Printf("config: %s references unset environment variables %v — expanded them to empty; enabled integrations will reject missing credentials", path, missing)
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
|
||||
@@ -459,6 +458,12 @@ func (c *Config) validate() error {
|
||||
if err := c.validateTelegram(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateNtfy(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateWorkstation(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -468,16 +473,23 @@ func (c *Config) validate() error {
|
||||
// answers nothing — the failure is invisible from the chat. Same shape as
|
||||
// validateNetScan: fail the config rather than the turn.
|
||||
func (c *Config) validateTelegram() error {
|
||||
if c.Telegram == nil || !c.Telegram.Intake {
|
||||
if c.Telegram == nil || c.Telegram.Disabled {
|
||||
return nil
|
||||
}
|
||||
// An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an
|
||||
// empty token or chat id as telegram not being wired at all. Validating a
|
||||
// block that wires nothing would fail a box that merely has no bot.
|
||||
if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" {
|
||||
if err := telegramsink.Validate(*c.Telegram); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Telegram.Intake {
|
||||
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validateNtfy() error {
|
||||
if c.Ntfy == nil {
|
||||
return nil
|
||||
}
|
||||
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
|
||||
return ntfysink.Validate(*c.Ntfy)
|
||||
}
|
||||
|
||||
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||
|
||||
@@ -435,10 +435,21 @@ func TestNormaliseDropsAddresslessWorkstation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelDisabledKeepsLiveSTT(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"model_disabled":true,"stt":{"url":"http://192.168.1.105:8081/transcribe","token":"secret"}}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Workstation == nil || c.Workstation.Stt == nil || !c.Workstation.ModelDisabled {
|
||||
t.Fatalf("model-only dark state did not preserve STT: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
// The health endpoint defaults to the supervisor's, not llama-server's: mavgpud
|
||||
// answers 503 while the card is held, and that refusal is the whole signal.
|
||||
func TestNormaliseFillsWorkstationDefaults(t *testing.T) {
|
||||
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}}
|
||||
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/", Token: "secret"}}
|
||||
c.applyDefaults()
|
||||
if c.Workstation == nil {
|
||||
t.Fatal("dropped a usable workstation block")
|
||||
@@ -460,6 +471,7 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||
c := &Config{Workstation: &WorkstationConfig{
|
||||
URL: "http://192.168.1.105:8080",
|
||||
Health: "http://192.168.1.105:9000/ready",
|
||||
Token: "secret",
|
||||
}}
|
||||
c.applyDefaults()
|
||||
if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want {
|
||||
@@ -467,6 +479,112 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationLANEndpointsRequireSecrets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"model", `{"workstation":{"url":"http://192.168.1.105:8080"}}`},
|
||||
{"stt", `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, tc.body)); err == nil {
|
||||
t.Fatal("Load accepted an enabled LAN endpoint with no secret")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationDisabledAllowsEmptySecrets(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Workstation != nil {
|
||||
t.Fatalf("disabled workstation survived normalisation: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationArmsMayBeDisabledIndependently(t *testing.T) {
|
||||
modelOff := writeConfig(t, `{"workstation":{"model_disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://127.0.0.1:8081/transcribe"}}}`)
|
||||
if _, err := Load(modelOff); err != nil {
|
||||
t.Fatalf("Load model-disabled config: %v", err)
|
||||
}
|
||||
|
||||
sttOff := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"disabled":true,"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
||||
c, err := Load(sttOff)
|
||||
if err != nil {
|
||||
t.Fatalf("Load STT-disabled config: %v", err)
|
||||
}
|
||||
if c.Workstation == nil || c.Workstation.Stt != nil {
|
||||
t.Fatalf("STT-only dark state changed model arm: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationLoopbackMayRunWithoutSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://localhost:8081/transcribe"}}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load loopback development endpoints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLiveBlockRequiresBothSecrets(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"telegram":{"chat_id":"42"}}`,
|
||||
`{"telegram":{"bot_token":"token"}}`,
|
||||
} {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatalf("Load accepted enabled Telegram block: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramDisabledAllowsEmptySecrets(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, `{"telegram":{"disabled":true}}`)); err != nil {
|
||||
t.Fatalf("Load disabled Telegram: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfyLiveBlockRequiresSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"ntfy":{"base_url":"https://ntfy.example","topic":"maven"}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load accepted enabled ntfy block with no credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfyDisabledAllowsEmptySecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"ntfy":{"disabled":true,"base_url":"https://ntfy.example","topic":"maven"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load disabled ntfy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartHomeLiveBlockRequiresSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"smarthome":{"enabled":true,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load accepted enabled Home Assistant block with no token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartHomeDisabledAllowsEmptySecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"smarthome":{"enabled":false,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load disabled Home Assistant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDBKeyEnvCannotBeEmpty(t *testing.T) {
|
||||
t.Setenv("MAVEN_TEST_DB_KEY", "")
|
||||
c := &Config{DBKeyEnv: "MAVEN_TEST_DB_KEY"}
|
||||
if _, err := c.DBEncryptionKey(); err == nil {
|
||||
t.Fatal("configured empty database key did not fail startup resolution")
|
||||
}
|
||||
if key, err := (&Config{}).DBEncryptionKey(); err != nil || key != nil {
|
||||
t.Fatalf("explicit plaintext config = (%v, %v), want (nil, nil)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIntakeRefusesNamedChat(t *testing.T) {
|
||||
// The push half accepts an @channelusername and the intake half cannot use
|
||||
// one, so a box with both boots clean and answers nothing. Refuse the
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,14 +15,20 @@ import (
|
||||
// a config change and a code change have to agree here or the suite is red.
|
||||
//
|
||||
// The ${VAR} expansions come from a gitignored deploy/telegram.env that is not
|
||||
// present in CI. An unset var expands to the empty string, which is exactly the
|
||||
// "not configured" state every block already has to handle, so the parse is
|
||||
// still meaningful without the secrets.
|
||||
// present in CI. Live blocks reject empty secrets; this test supplies inert
|
||||
// Telegram values and verifies that every credential-less block is explicitly
|
||||
// disabled.
|
||||
func TestDeployConfigLoads(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "mavend.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("no deploy config at %s: %v", path, err)
|
||||
}
|
||||
// Live blocks fail on expanded-empty credentials. CI provides inert values
|
||||
// so this test exercises the committed shape; explicitly disabled blocks
|
||||
// (ntfy, workstation and Home Assistant) require none.
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
|
||||
t.Setenv("TELEGRAM_CHAT_ID", "-1001234567890")
|
||||
t.Setenv("MAVEN_STT_TOKEN", "test-token")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%s): %v", path, err)
|
||||
@@ -46,11 +53,10 @@ func TestDeployConfigLoads(t *testing.T) {
|
||||
t.Error("router threshold did not get its default")
|
||||
}
|
||||
|
||||
// The second reach (V-649). Deleting this block is how you turn ntfy off,
|
||||
// so its absence has to be loud: sev3-away nudges and away reminders route
|
||||
// to ntfy and to nothing else, and a nil sink drops them with no log and no
|
||||
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
|
||||
// the wiring and not the credential.
|
||||
// The second reach (V-649). The token is a ${VAR} that CI cannot resolve, so
|
||||
// the committed deployment makes the dark state explicit. Removing disabled
|
||||
// without provisioning a credential makes runtime wiring fail startup; it
|
||||
// can never silently publish anonymously.
|
||||
if cfg.Ntfy == nil {
|
||||
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
|
||||
"would have nowhere to land, and would vanish silently rather than fail")
|
||||
@@ -58,4 +64,47 @@ func TestDeployConfigLoads(t *testing.T) {
|
||||
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
|
||||
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
|
||||
}
|
||||
if !cfg.Ntfy.Disabled {
|
||||
t.Fatal("deploy ntfy reach has no checked-in credential and must remain explicitly disabled")
|
||||
}
|
||||
if cfg.Workstation == nil || !cfg.Workstation.ModelDisabled || cfg.Workstation.Stt == nil {
|
||||
t.Fatalf("deploy must disable only its uncredentialed model arm and retain authenticated STT: %+v", cfg.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDeployEnvExampleNamesEverySecret(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "telegram.env.example")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
example := string(b)
|
||||
references := map[string]bool{
|
||||
// These two live outside ${...}: DBKeyEnv names an environment variable
|
||||
// as JSON data, while the separately deployed Python child reads its own
|
||||
// environment directly.
|
||||
"CW2_TOKEN": true,
|
||||
"MAVEN_DB_KEY": true,
|
||||
}
|
||||
for _, source := range []string{
|
||||
filepath.Join("..", "..", "deploy", "mavend.json"),
|
||||
filepath.Join("..", "..", "docker-compose.yml"),
|
||||
} {
|
||||
raw, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("read deploy source %s: %v", source, err)
|
||||
}
|
||||
_ = os.Expand(string(raw), func(expr string) string {
|
||||
// Compose supports ${NAME:-default}; os.Expand deliberately hands the
|
||||
// full braced expression to this callback.
|
||||
name, _, _ := strings.Cut(expr, ":-")
|
||||
references[name] = true
|
||||
return ""
|
||||
})
|
||||
}
|
||||
for name := range references {
|
||||
if !strings.Contains(example, name+"=") {
|
||||
t.Errorf("canonical deploy env example omits %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +20,13 @@ import (
|
||||
// Only the prompt crosses the LAN, and the workstation is not "the box". The
|
||||
// rules in CLAUDE.md about what may leave still apply.
|
||||
type WorkstationConfig struct {
|
||||
// Disabled keeps both written workpc arms explicitly dark. ModelDisabled is
|
||||
// the narrower switch: CW2 STT may remain live while the large-model
|
||||
// supervisor has no provisioned client token.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
ModelDisabled bool `json:"model_disabled,omitempty"`
|
||||
|
||||
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
|
||||
// the whole block is normalised to nil and nothing probes anything.
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -30,8 +39,8 @@ type WorkstationConfig struct {
|
||||
// Token — the bearer credential mavgpud requires, expanded from the
|
||||
// environment like every other secret here. It is what stops anything on
|
||||
// the LAN spending the card, so a URL that is not loopback needs one.
|
||||
// Wrong or missing reads as a workstation that is down, and Maven falls
|
||||
// back to the resident model.
|
||||
// A missing token on a LAN URL fails config validation. Loopback development
|
||||
// endpoints may omit it.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||
@@ -60,6 +69,10 @@ type WorkstationConfig struct {
|
||||
// the ggml-small.bin homesrv loads
|
||||
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
|
||||
type WorkstationSttConfig struct {
|
||||
// Disabled keeps the written CW2 endpoint dark without also disabling the
|
||||
// independently authenticated model supervisor.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// URL — the transcribe endpoint, e.g.
|
||||
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
|
||||
// to nil and mavsttd takes every turn.
|
||||
@@ -97,29 +110,85 @@ const (
|
||||
// model: an unconfigured workstation is the default deploy and must be
|
||||
// indistinguishable from today.
|
||||
func (c *Config) normaliseWorkstation() {
|
||||
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
|
||||
if c.Workstation != nil && c.Workstation.Disabled {
|
||||
c.Workstation = nil
|
||||
}
|
||||
if c.Workstation == nil {
|
||||
return
|
||||
}
|
||||
w := c.Workstation
|
||||
if strings.TrimSpace(w.Health) == "" {
|
||||
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||
// Preserve the historical empty-block meaning. A live STT sub-block makes
|
||||
// the parent non-empty; in that case omitting the model URL is an error
|
||||
// unless model_disabled states the operator's intent.
|
||||
if !w.ModelDisabled && strings.TrimSpace(w.URL) == "" &&
|
||||
(w.Stt == nil || w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
|
||||
c.Workstation = nil
|
||||
return
|
||||
}
|
||||
if w.Probe <= 0 {
|
||||
w.Probe = Duration(DefaultWorkstationProbe)
|
||||
}
|
||||
if w.Timeout <= 0 {
|
||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||
if !w.ModelDisabled {
|
||||
if strings.TrimSpace(w.Health) == "" {
|
||||
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||
}
|
||||
if w.Probe <= 0 {
|
||||
w.Probe = Duration(DefaultWorkstationProbe)
|
||||
}
|
||||
if w.Timeout <= 0 {
|
||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||
}
|
||||
}
|
||||
normaliseWorkstationStt(w)
|
||||
if w.ModelDisabled && w.Stt == nil {
|
||||
c.Workstation = nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkstation rejects a live LAN endpoint without its bearer secret.
|
||||
// Loopback remains useful for local development without manufacturing a secret;
|
||||
// malformed or non-HTTP endpoints are rejected before any probe starts.
|
||||
func (c *Config) validateWorkstation() error {
|
||||
if c.Workstation == nil {
|
||||
return nil
|
||||
}
|
||||
w := c.Workstation
|
||||
if !w.ModelDisabled {
|
||||
if err := validateWorkstationEndpoint("workstation.url", w.URL, w.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWorkstationEndpoint("workstation.health", w.Health, w.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.Stt != nil {
|
||||
if err := validateWorkstationEndpoint("workstation.stt.url", w.Stt.URL, w.Stt.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWorkstationEndpoint("workstation.stt.health", w.Stt.Health, w.Stt.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkstationEndpoint(name, raw, token string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return fmt.Errorf("%s must be an absolute http(s) URL", name)
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
loopback := strings.EqualFold(host, "localhost")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
loopback = ip.IsLoopback()
|
||||
}
|
||||
if !loopback && strings.TrimSpace(token) == "" {
|
||||
return fmt.Errorf("%s is not loopback, so its token is required while enabled", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
|
||||
// address, no remote: mavsttd then takes every utterance, which is today.
|
||||
func normaliseWorkstationStt(w *WorkstationConfig) {
|
||||
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
|
||||
if w.Stt != nil && (w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
|
||||
w.Stt = nil
|
||||
}
|
||||
if w.Stt == nil {
|
||||
|
||||
Reference in New Issue
Block a user