Version, authenticate and fully trace ecosystem calls #84
+7
-16
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
+71
-39
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user