Merge branch 'fix/g08' into fix/integrated
# Conflicts: # internal/store/migrations.go
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/update"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
@@ -277,8 +278,20 @@ type MCPConfig struct {
|
||||
|
||||
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// HostInterval — minimum spacing between two requests to one MCP server.
|
||||
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
|
||||
// That default was sized for a feed poll loop, and this path is in a spoken
|
||||
// turn: one dial is three requests (initialize, initialized, tools/list),
|
||||
// so a second of spacing is two seconds of pure sleeping per dial and up to
|
||||
// another second before every tools/call leaves the box.
|
||||
HostInterval Duration `json:"host_interval,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
|
||||
// runaway loop hammering a server, small enough not to be heard.
|
||||
const DefaultMCPHostInterval = 50 * time.Millisecond
|
||||
|
||||
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
|
||||
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
|
||||
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
|
||||
@@ -429,6 +442,13 @@ type MCPServerConfig struct {
|
||||
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Headers — sent verbatim on every request to a url server. This is how a
|
||||
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
|
||||
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
|
||||
// telegram credentials. The Vikunja server on homesrv needs none only
|
||||
// because it is unauthenticated on loopback.
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a configured server described but
|
||||
// dark, so a block can be written and reviewed before it is switched on.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
@@ -437,13 +457,31 @@ type MCPServerConfig struct {
|
||||
// MCPServers maps the config blocks onto the mcp package's own type. It lives
|
||||
// here so config validation and daemon wiring cannot drift on the mapping.
|
||||
// Returns nil when nothing is configured or nothing is enabled.
|
||||
//
|
||||
// Disabled servers are dropped here, which is why validation does NOT use this
|
||||
// list — see allMCPServers.
|
||||
func (c *Config) MCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(true)
|
||||
}
|
||||
|
||||
// allMCPServers is every configured server, enabled or not, for validation.
|
||||
//
|
||||
// Validating only the enabled ones meant a block with both command and url, or
|
||||
// a bare hostname as the url, passed startup validation while it was dark. The
|
||||
// doc on Enabled says a block can be written and reviewed before it is switched
|
||||
// on; the review the config layer could give was the one thing skipped. Enabled
|
||||
// gates the dialing, not the shape check.
|
||||
func (c *Config) allMCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(false)
|
||||
}
|
||||
|
||||
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
|
||||
if c.MCP == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
|
||||
for _, s := range c.MCP.Servers {
|
||||
if !s.Enabled {
|
||||
if onlyEnabled && !s.Enabled {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(s.Timeout)
|
||||
@@ -460,8 +498,9 @@ func (c *Config) MCPServers() []mcp.ServerConfig {
|
||||
AllowPrivate: s.AllowPrivate,
|
||||
AllowTools: s.AllowTools,
|
||||
MaxTools: s.MaxTools,
|
||||
Headers: s.Headers,
|
||||
Timeout: timeout,
|
||||
Enabled: true,
|
||||
Enabled: s.Enabled,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -668,6 +707,12 @@ type MediaConfig struct {
|
||||
|
||||
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
|
||||
// The per-blob cap bounds one call; this one bounds the sum of them, which
|
||||
// is what actually decides whether the disk mavend's database lives on can
|
||||
// be filled from outside.
|
||||
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// StoreDir reports the configured blob directory, or "" when media is not
|
||||
@@ -755,9 +800,14 @@ type CaptureConfig struct {
|
||||
MaxChunks int `json:"max_chunks,omitempty"`
|
||||
|
||||
// SaveTranscript — write the full transcript as a note alongside the
|
||||
// summary. Default false: a verbatim record of what other people said in a
|
||||
// room is a heavier thing to keep than a four-line summary, so it takes a
|
||||
// deliberate yes. The audio blob is pruned by media.retention either way.
|
||||
// summary. Default false, and the cost is not disk: a note is embedded and
|
||||
// becomes recall corpus, so every later question can surface verbatim words
|
||||
// other people said in a room. That is the reason it takes a deliberate yes.
|
||||
// The audio blob is pruned by media.retention either way; the notes are not.
|
||||
//
|
||||
// A meeting with no summary writes its transcript regardless. The choice
|
||||
// here is transcript IN ADDITION to a summary, not whether the meeting is
|
||||
// remembered at all.
|
||||
SaveTranscript bool `json:"save_transcript,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1259,11 +1309,18 @@ func (c *Config) applyDefaults() {
|
||||
c.Feeds = nil
|
||||
}
|
||||
|
||||
// Same rule for MCP: a block with no server, or none enabled, is the same
|
||||
// as no block at all. Normalising it to nil keeps "off" in one place.
|
||||
if c.MCP != nil && len(c.MCPServers()) == 0 {
|
||||
// Same rule for MCP: a block with no server at all is the same as no block.
|
||||
// A block whose servers are all disabled is NOT normalised away, because
|
||||
// validate has to see their shape — a dark block with a typo in it should
|
||||
// fail at startup, which is the whole reason it can be written before it is
|
||||
// switched on. wireMCP builds nothing when nothing is enabled, so "off"
|
||||
// still holds.
|
||||
if c.MCP != nil && len(c.MCP.Servers) == 0 {
|
||||
c.MCP = nil
|
||||
}
|
||||
if c.MCP != nil && c.MCP.HostInterval <= 0 {
|
||||
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
|
||||
}
|
||||
|
||||
// Same rule for the house: a block that is not enabled is the same as no
|
||||
// block at all, so "off" stays in one place.
|
||||
@@ -1389,7 +1446,7 @@ func (c *Config) validate() error {
|
||||
// An MCP block with a typo (no name, both command and url, a bare hostname
|
||||
// as the url) fails here, at startup, rather than at the first turn that
|
||||
// needed the tool.
|
||||
if err := mcp.Validate(c.MCPServers()); err != nil {
|
||||
if err := mcp.Validate(c.allMCPServers()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Same for the house: a missing token or a bare hostname fails at startup,
|
||||
@@ -1409,6 +1466,36 @@ func (c *Config) validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A media dir that cannot be created, or a vision endpoint that is a typo,
|
||||
// used to be logged at wiring time and the capability just stayed off. A
|
||||
// capability silently not existing is the hardest kind of misconfiguration
|
||||
// to notice, so both fail here instead.
|
||||
if c.Media != nil {
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("media.dir is required when a media block is present")
|
||||
}
|
||||
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
|
||||
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
|
||||
}
|
||||
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
|
||||
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
|
||||
c.Media.MaxBytes, c.Media.MaxTotalBytes)
|
||||
}
|
||||
}
|
||||
if c.Vision != nil && c.Vision.Enabled {
|
||||
if strings.TrimSpace(c.Vision.Endpoint) == "" {
|
||||
return errors.New("vision.enabled set but vision.endpoint is empty")
|
||||
}
|
||||
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
|
||||
}
|
||||
}
|
||||
if c.Capture.Records() && c.Media.StoreDir() == "" {
|
||||
return errors.New("capture.enabled set but there is no media block to keep the audio in")
|
||||
}
|
||||
if len(c.MorningRoutines) > 0 {
|
||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,14 +26,55 @@ func TestMCPDisabledServerIsOff(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MCP != nil {
|
||||
t.Errorf("a block with nothing enabled must normalise to nil, got %+v", c.MCP)
|
||||
}
|
||||
if got := c.MCPServers(); len(got) != 0 {
|
||||
t.Errorf("MCPServers() = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A block with no servers at all is the same as no block.
|
||||
func TestMCPEmptyBlockNormalisesToNil(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{"servers":[]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MCP != nil {
|
||||
t.Errorf("mcp = %+v, want nil", c.MCP)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that is written but not switched on is still shape-checked. The
|
||||
// review the config layer can give is the point of writing a block dark, and
|
||||
// skipping it meant a typo only surfaced on the day it was enabled.
|
||||
func TestMCPDisabledServerIsStillValidated(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test"}]}}`,
|
||||
"bare host": `{"mcp":{"servers":[{"name":"a","url":"a.test"}]}}`,
|
||||
"no name": `{"mcp":{"servers":[{"command":"x"}]}}`,
|
||||
"duplicates": `{"mcp":{"servers":[{"name":"a","command":"x"},{"name":"a","command":"y"}]}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatal("a dark server with a typo must fail at startup")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Headers carry a bearer token to a real remote server.
|
||||
func TestMCPServerHeaders(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{"servers":[
|
||||
{"name":"remote","url":"https://mcp.example.test/mcp","enabled":true,
|
||||
"headers":{"Authorization":"Bearer sekret"}}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := c.MCPServers()
|
||||
if len(got) != 1 || got[0].Headers["Authorization"] != "Bearer sekret" {
|
||||
t.Fatalf("headers not mapped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPEnabledServerMapping(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{
|
||||
"timeout":"5s",
|
||||
|
||||
@@ -221,3 +221,40 @@ func TestSpeakerBlockParsesFromJSON(t *testing.T) {
|
||||
t.Errorf("thresholds = %+v", cfg.Speaker)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo in the vision endpoint, or a media block with no dir, used to be
|
||||
// logged once at wiring time and the capability just stayed off. A capability
|
||||
// that silently does not exist is the hardest misconfiguration to notice, so
|
||||
// both fail at startup now.
|
||||
func TestSensesBlocksAreValidatedAtStartup(t *testing.T) {
|
||||
bad := map[string]string{
|
||||
"media with no dir": `{"media":{"retention":"48h"}}`,
|
||||
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
|
||||
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
|
||||
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
||||
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
|
||||
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
|
||||
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
|
||||
"capture with no store": `{"capture":{"enabled":true}}`,
|
||||
}
|
||||
for name, body := range bad {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatal("want a startup error")
|
||||
}
|
||||
})
|
||||
}
|
||||
good := map[string]string{
|
||||
"media alone": `{"media":{"dir":"/srv/media"}}`,
|
||||
"media + vision": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
||||
"media + capture": `{"media":{"dir":"/srv/media"},"capture":{"enabled":true}}`,
|
||||
"vision off": `{"vision":{"endpoint":"http://8.8.8.8:8081"}}`,
|
||||
}
|
||||
for name, body := range good {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err != nil {
|
||||
t.Fatalf("valid config refused: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user