From 0db31d21b97c26d238f9cd61c98e99d2e328d327 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 16:29:56 +0400 Subject: [PATCH] hexis: re-vendor the client so a configured token is actually sent The vendored copy of github.com/kami/hexis predated Client.WithToken: no token field, no setter, no header hook, and an unexported httpClient, so there was no way to attach auth from outside the package. wireEcosystem handled that by refusing to wire Hexis at all when a token was configured, which was the honest reading of the code but left the deployment silently without its executing service. go.mod already replaces the module with /home/kami/apps/hexis, and that source has had WithToken and the Bearer header for a while. Only the checked-in vendor/ copy was stale. Refreshed it (client.go plus the new capability.go) and wired Hexis like Nexus and Praxis. Two tests cover the outcome the refusal was standing in for: a configured token reaches the wire as Authorization, and no token still wires unauthed, because Hexis without auth is a valid deployment on a trusted box. Also corrected the discoverCapabilities comment. It claimed the client stamped the correlation header on Execute only; do() stamps it on every request, and did before the re-vendor too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/ecosystem.go | 23 ++-- cmd/mavend/ecosystem_auth_test.go | 65 +++++++++++ .../kami/hexis/pkg/client/capability.go | 62 ++++++++++ .../kami/hexis/pkg/client/client.go | 110 +++++++++++------- 4 files changed, 205 insertions(+), 55 deletions(-) create mode 100644 cmd/mavend/ecosystem_auth_test.go create mode 100644 vendor/github.com/kami/hexis/pkg/client/capability.go 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) }