0ade0ec734
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.
315 lines
12 KiB
Go
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)
|
|
}
|
|
}
|