Files
Maven/internal/tool/tool_test.go
T
kami dc4c5b7841 Read and control the house through Home Assistant (#256)
A `smarthome` block points Maven at a Home Assistant instance. She reads its
entity states to answer "что включено дома?", and every controllable device
becomes a PROPOSED row in the existing act allowlist — cmd
["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new
had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool,
tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec
routes such a row to the client instead of exec, and "smarthome" is never run as
a binary. This is the same trick overnight/mcp-tools used for #251, on purpose.

Discovery only ever PROPOSES, and every control row is destructive=true: there
is no read-only way to turn the heating off, so flipping something in his flat
always costs a confirm turn and always had to be enabled by hand on /tools,
behind step-up.

The entity and the service come from the row he enabled, never from the
utterance — Exec drops the spoken tail for a house row. A router that misheard
can pick the wrong lamp; it cannot compose a target of its own. The service is
checked against the domain's table on the way out too, so a hand-edited cmd
column cannot reach an arbitrary Home Assistant service. set_brightness and
set_temperature are deliberately absent: a spoken number the router got wrong is
a wrong act on real hardware, and on/off is the whole of what a voice turn can
defend.

The read side is a query source ("home", before calendar and the recall passes)
so "что нового дома?" is not answered from an old note. Its matcher needs a
house marker plus an ask plus a device word and bails out on weather wording,
because "какая температура на улице?" belongs to the weather source.

Off unless configured: the block is dark without "enabled": true, and
applyDefaults normalises a disabled block to nil so "off" stays in one place.
deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}.

NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the
sensor-to-fact and presence-probe pipelines. There is no broker and no Home
Assistant anywhere on this network — 8123 and 1883 are closed on every host in
192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be
added offline, and Home Assistant already fronts Zigbee2MQTT where it exists.
Writing a sensor pipeline with no sensor to test it against would be a guess.

Vikunja #256
2026-08-01 06:27:39 +04:00

265 lines
9.1 KiB
Go

package tool
import (
"context"
"errors"
"reflect"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// fakeAPI — an in-memory tool store for the executor/matcher tests.
type fakeAPI struct{ tools map[string]ipc.Tool }
func (f fakeAPI) LookupTool(_ context.Context, name string) (ipc.Tool, error) {
t, ok := f.tools[name]
if !ok {
return ipc.Tool{}, ipc.ErrToolNotFound
}
return t, nil
}
func (f fakeAPI) ListTools(_ context.Context, status string) ([]ipc.Tool, error) {
var out []ipc.Tool
for _, t := range f.tools {
if status == "" || t.Status == status {
out = append(out, t)
}
}
return out, nil
}
func (f fakeAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
return true, nil
}
// TestExec covers the allowlist boundary: enabled runs, unknown/proposed refuse,
// destructive needs confirm, and args land as argv (no shell) after the prefix.
func TestExec(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"restart": {Name: "restart", Scope: "homelab", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
"drop": {Name: "drop", Scope: "homelab", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
"draft": {Name: "draft", Scope: "homelab", Cmd: []string{"x"}, Status: "proposed"},
}}
var gotArgv []string
e := NewExecutor(api, 0)
e.run = func(_ context.Context, argv []string) (string, error) { gotArgv = argv; return "ok", nil }
// enabled → runs, args appended to the fixed prefix as argv.
out, err := e.Exec(context.Background(), "restart", []string{"nginx; rm -rf /"}, false)
if err != nil || out != "ok" {
t.Fatalf("enabled: out=%q err=%v", out, err)
}
want := []string{"systemctl", "restart", "nginx; rm -rf /"}
if !reflect.DeepEqual(gotArgv, want) {
t.Fatalf("argv=%v want %v (injection must stay one argv element)", gotArgv, want)
}
// unknown → refuse.
if _, err := e.Exec(context.Background(), "nope", nil, false); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("unknown: err=%v want ErrNotEnabled", err)
}
// proposed (not enabled) → refuse.
if _, err := e.Exec(context.Background(), "draft", nil, false); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("proposed: err=%v want ErrNotEnabled", err)
}
// destructive unconfirmed → needs confirm; confirmed → runs.
if _, err := e.Exec(context.Background(), "drop", nil, false); !errors.Is(err, ErrNeedsConfirm) {
t.Fatalf("destructive: err=%v want ErrNeedsConfirm", err)
}
if _, err := e.Exec(context.Background(), "drop", []string{"db"}, true); err != nil {
t.Fatalf("destructive confirmed: err=%v", err)
}
if want := []string{"dropdb", "db"}; !reflect.DeepEqual(gotArgv, want) {
t.Fatalf("confirmed argv=%v want %v", gotArgv, want)
}
// matcher allowlist = enabled names only (proposed excluded).
m := NewMatcher(api)
fn, args, ok := m.Match("restart nginx")
if !ok || fn != "restart" || !reflect.DeepEqual(args, []string{"nginx"}) {
t.Fatalf("match: fn=%q args=%v ok=%v", fn, args, ok)
}
if _, _, ok := m.Match("draft something"); ok {
t.Fatal("proposed tool must not match (not enabled)")
}
}
// fakeMCP records what the executor asked it to call.
type fakeMCP struct {
server, tool string
args []string
out string
err error
calls int
}
func (f *fakeMCP) CallPositional(_ context.Context, server, tool string, args []string) (string, error) {
f.calls++
f.server, f.tool, f.args = server, tool, args
return f.out, f.err
}
// An MCP row dispatches to the caller instead of a process, and the process
// seam is never touched.
func TestExecMCPRowDispatchesToMCP(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"vikunja_list_tasks": {
Name: "vikunja_list_tasks", Status: "enabled", Scope: "mcp:vikunja",
Cmd: []string{"mcp", "vikunja", "list_tasks"},
},
}}
m := &fakeMCP{out: "две задачи"}
ran := false
e := NewExecutor(api, time.Second).WithMCP(m)
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
out, err := e.Exec(context.Background(), "vikunja_list_tasks", []string{"мавен"}, false)
if err != nil {
t.Fatalf("exec: %v", err)
}
if out != "две задачи" {
t.Fatalf("out = %q", out)
}
if ran {
t.Fatal("an MCP row must not be executed as a process")
}
if m.server != "vikunja" || m.tool != "list_tasks" || len(m.args) != 1 || m.args[0] != "мавен" {
t.Fatalf("dispatched wrong: %+v", m)
}
}
// The allowlist rules still apply to an MCP row: destructive means a confirm
// turn first, and nothing is called until the second turn.
func TestExecMCPRowStillNeedsConfirm(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"vikunja_delete_task": {
Name: "vikunja_delete_task", Status: "enabled", Destructive: true,
Cmd: []string{"mcp", "vikunja", "delete_task"},
},
}}
m := &fakeMCP{out: "удалила"}
e := NewExecutor(api, time.Second).WithMCP(m)
if _, err := e.Exec(context.Background(), "vikunja_delete_task", nil, false); !errors.Is(err, ErrNeedsConfirm) {
t.Fatalf("err = %v, want ErrNeedsConfirm", err)
}
if m.calls != 0 {
t.Fatal("a destructive MCP tool must not reach the server before confirmation")
}
if _, err := e.Exec(context.Background(), "vikunja_delete_task", nil, true); err != nil {
t.Fatalf("confirmed exec: %v", err)
}
if m.calls != 1 {
t.Fatalf("calls = %d", m.calls)
}
}
// A proposed MCP row does not run, exactly like a proposed shell tool.
func TestExecMCPRowNotEnabled(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"vikunja_list_tasks": {Name: "vikunja_list_tasks", Status: "proposed", Cmd: []string{"mcp", "vikunja", "list_tasks"}},
}}
m := &fakeMCP{}
e := NewExecutor(api, time.Second).WithMCP(m)
if _, err := e.Exec(context.Background(), "vikunja_list_tasks", nil, false); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("err = %v", err)
}
if m.calls != 0 {
t.Fatal("a proposal must not call anything")
}
}
// With MCP unconfigured, an MCP row refuses rather than trying to exec "mcp".
func TestExecMCPRowWithoutCallerRefuses(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"vikunja_list_tasks": {Name: "vikunja_list_tasks", Status: "enabled", Cmd: []string{"mcp", "vikunja", "list_tasks"}},
}}
ran := false
e := NewExecutor(api, time.Second)
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
if _, err := e.Exec(context.Background(), "vikunja_list_tasks", nil, false); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("err = %v, want ErrNotEnabled", err)
}
if ran {
t.Fatal(`"mcp" must never be run as a binary`)
}
}
// fakeHome records what the executor asked the house to do.
type fakeHome struct {
entity, service string
calls int
}
func (f *fakeHome) CallService(_ context.Context, entityID, service string) (string, error) {
f.calls++
f.entity, f.service = entityID, service
return "готово", nil
}
// A house row goes through the same allowlist and the same confirm turn as any
// other act, and it is never exec'd as a binary (Vikunja #256).
func TestExecSmartHomeRow(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"home_light_x_off": {
Name: "home_light_x_off", Scope: "smarthome:light",
Cmd: []string{"smarthome", "light.x", "turn_off"}, Destructive: true, Status: "enabled",
},
"home_draft": {
Name: "home_draft", Scope: "smarthome:light",
Cmd: []string{"smarthome", "light.y", "turn_on"}, Destructive: true, Status: "proposed",
},
}}
ran := false
newExec := func(h HomeCaller) *Executor {
e := NewExecutor(api, time.Second)
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
if h != nil {
e = e.WithHome(h)
}
return e
}
// No house configured ⇒ the row refuses rather than being exec'd.
if _, err := newExec(nil).Exec(context.Background(), "home_light_x_off", nil, true); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("unconfigured house: err = %v, want ErrNotEnabled", err)
}
if ran {
t.Fatal(`"smarthome" was run as a binary`)
}
// Configured, but not confirmed ⇒ the confirm turn, before any call.
fh := &fakeHome{}
if _, err := newExec(fh).Exec(context.Background(), "home_light_x_off", nil, false); !errors.Is(err, ErrNeedsConfirm) {
t.Fatalf("err = %v, want ErrNeedsConfirm", err)
}
if fh.calls != 0 {
t.Fatal("an unconfirmed house act reached the house")
}
// A merely proposed row never runs, confirmed or not.
if _, err := newExec(fh).Exec(context.Background(), "home_draft", nil, true); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("proposed row: err = %v, want ErrNotEnabled", err)
}
if fh.calls != 0 {
t.Fatal("a proposed house row reached the house")
}
// Confirmed ⇒ the service call, with the entity from the ROW and the
// spoken tail dropped.
out, err := newExec(fh).Exec(context.Background(), "home_light_x_off", []string{"light.somewhere_else"}, true)
if err != nil {
t.Fatalf("Exec: %v", err)
}
if out != "готово" {
t.Errorf("out = %q", out)
}
if fh.entity != "light.x" || fh.service != "turn_off" {
t.Errorf("called %s/%s: the target must come from the enabled row, never from the utterance", fh.entity, fh.service)
}
if ran {
t.Fatal(`"smarthome" was run as a binary`)
}
}