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:
2026-08-13 02:50:59 +04:00
parent da9114b623
commit 35c6ff5a71
67 changed files with 3174 additions and 477 deletions
+24 -12
View File
@@ -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
+119 -1
View File
@@ -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
+57 -8
View File
@@ -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)
}
}
}
+80 -11
View File
@@ -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 {