Files
Maven/cmd/mavend/ecosystem_test.go
claude 0ade0ec734 tool, mavend: Hexis owns the tier of a Hexis capability (V-523)
read_only was the whole decision on the Hexis act path, which flattened three
answers into two. A capability that wipes the thing it names got the same
single spoken "да" as one that restarts a service, and requires_confirmation —
which the Hexis contract calls server-derived and never settable by a caller —
was read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.

RiskOfCapability reads Hexis's risk, read_only and requires_confirmation and
returns one of the three tiers internal/tool already had. It takes plain values
rather than a Capability, so internal/tool keeps no dependency on the Hexis
client. RiskOf keeps deriving, because a shell row the owner ticked on /tools
has no upstream to ask.

Every disagreement between the three fields goes up, never down: safe and
mutating is a contradiction and takes the confirm, an unrecognised tier takes
the confirm, and requires_confirmation may only raise. Same default as an
unrecognised dispatch shape — argue your way down, never up.

The irreversible refusal was a Go literal in two places and is now one deck
entry, act_needs_authed_surface. It lost four words to the persona ceiling.
2026-08-04 18:01:28 +04:00

315 lines
12 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
// stubEcosystem wires nexus+hexis clients at the given base URLs.
func stubEcosystem(nexusURL, hexisURL string) *ecosystemWiring {
return &ecosystemWiring{
nexus: newNexusClient(nexusURL),
hexis: hexisclient.New(hexisURL),
}
}
// newHexisTestHandler builds a reactiveHandler backed by fake nexus+hexis
// servers. resolveBody is returned verbatim from nexus /resolve; caps is the
// capability list; executed records whether Hexis /execute was called.
func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reactiveHandler, *bool) {
t.Helper()
executed := new(bool)
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(resolveBody))
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/capabilities"):
w.Write([]byte(caps))
case r.URL.Path == "/api/v1/execute":
*executed = true
w.Write([]byte(`{"id":"exec_1","status":"succeeded"}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}, executed
}
// actDec builds an act decision about subject. The verb is always "restart":
// the argument is the utterance the entity is resolved from, never the verb,
// so actDec("restart") reads as a verb and is not one.
func actDec(subject string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
}
func TestHexisMutatingRequiresConfirm(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !strings.Contains(reply, "да") {
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
}
if *executed {
t.Fatal("mutating cap must NOT execute before confirmation")
}
if h.pendingHexis == nil || h.pendingHexis.capabilityID != "cap_restart" {
t.Fatalf("expected pending hexis bound to cap_restart, got %+v", h.pendingHexis)
}
// The follow-up "да" turn executes exactly the parked capability.
confirmReply, handled := h.resolveConfirm(ctx, "да")
if !handled || !actRan(confirmReply) {
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
}
if !*executed {
t.Fatal("confirmed mutating cap should have executed")
}
if h.pendingHexis != nil {
t.Fatal("pending should be cleared after confirm")
}
}
func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
h, executed := newHexisTestHandler(t, resolved, caps)
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
reply, handled := h.resolveConfirm(ctx, "нет")
if !handled || !strings.Contains(reply, "отменила") {
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
}
if *executed {
t.Fatal("declined cap must not execute")
}
}
func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !*executed {
t.Fatal("read-only cap should execute without confirmation")
}
if h.pendingHexis != nil {
t.Fatal("read-only cap should not park a confirmation")
}
if !actRan(reply) {
t.Fatalf("unexpected reply %q", reply)
}
}
func TestHexisAmbiguousAsksClarification(t *testing.T) {
ctx := context.Background()
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
reply := h.handleHexisAct(ctx, actDec("the indexer"))
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
t.Fatalf("ambiguous should list candidates, got %q", reply)
}
if *executed {
t.Fatal("ambiguous target must never execute")
}
}
// TestHexisResolveFlatShapeAccepted covers ECOSYSTEM-SPEC.md §1.5's documented
// flat resolve response (entity_id/entity_type/display_name at the top level,
// no nested entity object) alongside the nested shape Maven already decodes.
func TestHexisResolveFlatShapeAccepted(t *testing.T) {
ctx := context.Background()
flat := `{"status":"resolved","entity_id":"ent_muzick","entity_type":"service","display_name":"Muzick indexer"}`
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
h, executed := newHexisTestHandler(t, flat, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !*executed {
t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply)
}
}
// TestHexisNexusErrorFailsClosed covers the P0 audit finding: a genuine Nexus
// dependency failure must stop the ecosystem action and report degradation,
// never silently fall through to the local system command executor.
func TestHexisNexusErrorFailsClosed(t *testing.T) {
ctx := context.Background()
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("hexis must not be contacted when nexus resolve fails")
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
h := &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" {
t.Fatal("nexus dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
}
}
// TestHexisUnavailableFailsClosed covers the same invariant for a resolved
// entity whose Hexis capability discovery then fails.
func TestHexisUnavailableFailsClosed(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(resolved))
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
h := &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" {
t.Fatal("hexis dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
}
}
// TestHexisNotFoundStillFallsThrough ensures the fail-closed fix above is
// scoped to genuine dependency errors: a resolved-but-empty ("not_found")
// Nexus response — meaning the text simply isn't a known entity, not that
// Nexus is broken — must still fall through to the local command executor.
func TestHexisNotFoundStillFallsThrough(t *testing.T) {
ctx := context.Background()
notFound := `{"status":"not_found"}`
h, executed := newHexisTestHandler(t, notFound, `[]`)
reply := h.handleHexisAct(ctx, actDec("turn off the lights"))
if reply != "" {
t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply)
}
if *executed {
t.Fatal("not_found resolution must never execute a hexis capability")
}
}
// actRan — the reply is the line she says when a capability ran against an
// entity. The tests used to look for the substring "выполнена", which was a
// literal out of the act file: the review reworded that line to "готово: {name}"
// and seventeen assertions went with it (Vikunja #521).
func actRan(reply string) bool {
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
}
// muzickIndexer — the display name every ecosystem fixture resolves to.
const muzickIndexer = "Muzick indexer"
// read_only used to be the whole decision on this path, which meant a
// capability that destroys what it names got the same single spoken "да" as one
// that restarts a service. Hexis declares the tier and the voice path is not an
// authorised surface for the top one (Vikunja #523).
func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if *executed {
t.Fatal("an irreversible capability ran from the voice path")
}
if h.pendingHexis != nil {
t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
}
if !strings.Contains(reply, "не вернуть") {
t.Errorf("reply = %q; want it to name why she will not run it", reply)
}
}
// The other half: Hexis calling a capability safe is enough to run it, even
// though read_only is the field that used to decide. Nothing here re-derives.
func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !*executed {
t.Fatal("a capability Hexis calls safe should run")
}
if !actRan(reply) {
t.Fatalf("unexpected reply %q", reply)
}
}
// A mutating capability with no declared tier keeps the confirm turn it has
// always had, so the split does not quietly loosen an existing box.
func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if *executed {
t.Fatal("a mutating capability ran without a confirm")
}
if h.pendingHexis == nil {
t.Fatal("a mutating capability did not park a confirm")
}
if !strings.Contains(reply, "да или нет") {
t.Errorf("reply = %q; want the confirm question", reply)
}
}