diff --git a/cmd/mavend/ecosystem.go b/cmd/mavend/ecosystem.go index a7c36d8..bca3a5a 100644 --- a/cmd/mavend/ecosystem.go +++ b/cmd/mavend/ecosystem.go @@ -396,18 +396,8 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring { // Hexis capability service if cfg.Hexis != nil && cfg.Hexis.URL != "" { - if cfg.Hexis.Token != "" { - // Upstream hexis grew Client.WithToken, but the copy vendored here - // predates it, so the token cannot be sent. Hexis is the only one - // of the three that executes anything, and configuring auth on the - // executing service is not a best-effort request: refuse to wire it - // rather than execute unauthenticated for weeks behind one boot-time - // log line. - log.Printf("ecosystem: hexis token configured but the vendored hexis client cannot send it — hexis stays disabled; re-vendor github.com/kami/hexis to enable bearer auth") - } else { - w.hexis = hexisclient.New(cfg.Hexis.URL) - log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL) - } + w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token) + log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL) } else { log.Printf("ecosystem: hexis not configured") } @@ -472,10 +462,11 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin // not conflate the two: a dependency failure must not silently read as "no // capabilities" and fall through to unrelated local execution. // -// The vendored Hexis client stamps a correlation header on Execute only, so -// discovery and execution cannot be joined on the Hexis side. Maven's own -// traces still share one ID for both hops; the gap is on the far side and -// closes when the client is re-vendored. +// The correlation header is stamped in the client's do(), so discovery and +// execution can be joined on the Hexis side as long as both hops carry the +// same ID through ctx. (This used to say the header went out on Execute only; +// that was never true of the vendored code and is not true after the 2026-08-01 +// re-vendor.) func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) { if w == nil || w.hexis == nil || entityID == "" { return nil, nil diff --git a/cmd/mavend/ecosystem_auth_test.go b/cmd/mavend/ecosystem_auth_test.go new file mode 100644 index 0000000..41c748f --- /dev/null +++ b/cmd/mavend/ecosystem_auth_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kami/maven/internal/config" +) + +// TestWireEcosystem_HexisToken — a configured Hexis token reaches the wire. +// +// This is the regression that closes the 2026-08-01 re-vendor. The copy of +// github.com/kami/hexis checked into vendor/ used to predate Client.WithToken, +// so a configured token could not be sent at all; wireEcosystem refused to wire +// Hexis rather than execute unauthenticated. Both halves of that are gone. The +// test asserts the outcome the refusal was standing in for: the header goes +// out, so nobody has to trust a boot log to know auth is on. +func TestWireEcosystem_HexisToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL, Token: "s3cret"}} + w := wireEcosystem(cfg) + if w.hexis == nil { + t.Fatal("hexis not wired with a token configured") + } + if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil { + t.Fatalf("discoverCapabilities: %v", err) + } + if want := "Bearer s3cret"; gotAuth != want { + t.Errorf("Authorization = %q; want %q", gotAuth, want) + } +} + +// TestWireEcosystem_HexisNoToken — no token configured still wires, unauthed. +// Hexis without auth is a valid deployment on a trusted box, and the re-vendor +// must not have turned the token into a requirement. +func TestWireEcosystem_HexisNoToken(t *testing.T) { + var sawAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") != "" + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL}} + w := wireEcosystem(cfg) + if w.hexis == nil { + t.Fatal("hexis not wired without a token") + } + if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil { + t.Fatalf("discoverCapabilities: %v", err) + } + if sawAuth { + t.Error("Authorization header sent with no token configured") + } +} diff --git a/vendor/github.com/kami/hexis/pkg/client/capability.go b/vendor/github.com/kami/hexis/pkg/client/capability.go new file mode 100644 index 0000000..99ff964 --- /dev/null +++ b/vendor/github.com/kami/hexis/pkg/client/capability.go @@ -0,0 +1,62 @@ +package client + +import "time" + +// Capability is THE wire shape for a Hexis capability. +// +// There is exactly one definition of it, here, and every producer in this +// repository serializes through it: the HTTP handler (GET/POST +// /api/v1/capabilities, GET /api/v1/capabilities/{id}), the MCP adapter +// (hexis.list_capabilities), and this client's decode path. It lives in +// pkg/client rather than internal/ so that external consumers get the shape +// without vendoring internal packages; internal/wire holds the +// domain.Capability -> Capability conversion. +// +// Compatibility note — `id` and `capability_id` are BOTH emitted, deliberately. +// They always carry the same value. Maven's vendored consumer decodes `id` +// (cmd/mavend/voice.go matches on Capability.ID); the ECOSYSTEM-SPEC.md §4.1 +// schema and the rest of the Hexis API name the column `capability_id`. Hexis +// is mid-rollout of bearer auth on /api/v1/, which is already one breaking +// change for that consumer; dropping either alias here would stack a second, +// silent one on top. Both stay until every consumer is confirmed to read +// `capability_id`, at which point `id` can be removed in a deliberate, +// announced change. Do not "clean this up" incidentally. +type Capability struct { + // CapabilityID is the canonical field (ECOSYSTEM-SPEC.md §4.1). + CapabilityID string `json:"capability_id"` + // ID is a deprecated alias for CapabilityID, kept for wire compatibility. + // Always identical to CapabilityID. Prefer CapabilityID in new code. + ID string `json:"id"` + + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetTypes []string `json:"target_types"` + TargetEntityID string `json:"target_entity_id,omitempty"` + Provider string `json:"provider"` + Operation string `json:"operation"` + Risk string `json:"risk,omitempty"` + ReadOnly bool `json:"read_only"` + ExpectedSideEffects string `json:"expected_side_effects,omitempty"` + + // RequiresConfirmation and Enabled are server-derived from the risk tier + // and are never settable by a caller. listCapabilities used to omit both, + // which left clients unable to tell a callable capability from one that + // would be rejected with 403; the unified shape always carries them. + RequiresConfirmation bool `json:"requires_confirmation"` + Enabled bool `json:"enabled"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + + Attributes map[string]any `json:"attributes,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Version int64 `json:"version,omitempty"` +} + +// EffectiveID returns the capability ID, tolerating a peer that sends only one +// of the two aliases. +func (c Capability) EffectiveID() string { + if c.CapabilityID != "" { + return c.CapabilityID + } + return c.ID +} diff --git a/vendor/github.com/kami/hexis/pkg/client/client.go b/vendor/github.com/kami/hexis/pkg/client/client.go index ec06047..41dda1c 100644 --- a/vendor/github.com/kami/hexis/pkg/client/client.go +++ b/vendor/github.com/kami/hexis/pkg/client/client.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strconv" "time" ) @@ -42,6 +44,7 @@ func causationIDFrom(ctx context.Context) string { type Client struct { baseURL string httpClient *http.Client + token string } func New(baseURL string) *Client { @@ -51,6 +54,12 @@ func New(baseURL string) *Client { } } +// WithToken sets the shared bearer token sent on every /api/v1/ request. +func (c *Client) WithToken(token string) *Client { + c.token = token + return c +} + func (c *Client) do(ctx context.Context, method, path string, body, result any) error { var reqBody io.Reader if body != nil { @@ -67,6 +76,9 @@ func (c *Client) do(ctx context.Context, method, path string, body, result any) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Hexis-Version", APIVersion) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } if id := correlationIDFrom(ctx); id != "" { req.Header.Set("X-Correlation-ID", id) } @@ -97,8 +109,37 @@ func (c *Client) do(ctx context.Context, method, path string, body, result any) return nil } -type Capability struct { - ID string `json:"id"` +// Capability is defined in capability.go — the single wire shape shared by +// the HTTP handler, the MCP adapter and this client. + +type Execution struct { + // Seq is the pagination cursor for Executions; see the `since` parameter. + // Zero on single-execution reads. + Seq int64 `json:"seq,omitempty"` + ID string `json:"id"` + CapabilityID string `json:"capability_id"` + TargetEntityID string `json:"target_entity_id"` + Status string `json:"status"` + Result map[string]any `json:"result,omitempty"` + Error string `json:"error,omitempty"` + RequestedBy map[string]string `json:"requested_by,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + CausationID string `json:"causation_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +type ExecuteRequest struct { + CapabilityID string `json:"capability_id"` + TargetEntityID string `json:"target_entity_id"` + Arguments map[string]any `json:"arguments,omitempty"` + RequestedBy map[string]string `json:"requested_by,omitempty"` + Origin map[string]string `json:"origin,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + CausationID string `json:"causation_id,omitempty"` +} + +type CreateCapabilityRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` TargetTypes []string `json:"target_types"` @@ -110,47 +151,11 @@ type Capability struct { ExpectedSideEffects string `json:"expected_side_effects,omitempty"` } -type Execution struct { - ID string `json:"id"` - CapabilityID string `json:"capability_id"` - TargetEntityID string `json:"target_entity_id"` - Status string `json:"status"` - Result map[string]any `json:"result,omitempty"` - Error string `json:"error,omitempty"` - RequestedBy map[string]string `json:"requested_by,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - CausationID string `json:"causation_id,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` -} - -type ExecuteRequest struct { - CapabilityID string `json:"capability_id"` - TargetEntityID string `json:"target_entity_id"` - Arguments map[string]any `json:"arguments,omitempty"` - RequestedBy map[string]string `json:"requested_by,omitempty"` - Origin map[string]string `json:"origin,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - CausationID string `json:"causation_id,omitempty"` -} - -type CreateCapabilityRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - TargetTypes []string `json:"target_types"` - TargetEntityID string `json:"target_entity_id,omitempty"` - Provider string `json:"provider"` - Operation string `json:"operation"` - Risk string `json:"risk,omitempty"` - ReadOnly bool `json:"read_only"` - ExpectedSideEffects string `json:"expected_side_effects,omitempty"` -} - func (c *Client) Capabilities(ctx context.Context, entityID string) ([]Capability, error) { var result []Capability path := "/api/v1/capabilities" if entityID != "" { - path += "?entity_id=" + entityID + path += "?entity_id=" + url.QueryEscape(entityID) } if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { return nil, err @@ -190,6 +195,33 @@ func (c *Client) GetExecution(ctx context.Context, id string) (*Execution, error return &result, nil } +// Executions returns execution history, newest last, ordered by ascending +// `seq` (ECOSYSTEM-SPEC.md §4.5). +// +// entityID, when non-empty, filters to executions against that target entity. +// since is an exclusive cursor: pass 0 for the first page, then the Seq of the +// last element returned. The server caps a page at 100 rows, so a full page +// means "call again with the new cursor". +func (c *Client) Executions(ctx context.Context, entityID string, since int64) ([]Execution, error) { + q := url.Values{} + if entityID != "" { + q.Set("entity_id", entityID) + } + if since > 0 { + q.Set("since", strconv.FormatInt(since, 10)) + } + path := "/api/v1/executions" + if len(q) > 0 { + path += "?" + q.Encode() + } + + var result []Execution + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + return result, nil +} + func (c *Client) Health(ctx context.Context) error { return c.do(ctx, http.MethodGet, "/health", nil, nil) }