Files
Maven/vendor/github.com/kami/hexis/pkg/client/client.go
T
kami 0db31d21b9 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 16:29:56 +04:00

228 lines
6.9 KiB
Go

package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// APIVersion is sent as X-Hexis-Version on every request so the server can
// negotiate/reject unsupported client versions.
const APIVersion = "v1"
type correlationKey struct{}
type causationKey struct{}
// WithCorrelationID returns a context that carries a correlation ID to be
// sent as X-Correlation-ID on every Hexis request made with it.
func WithCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, correlationKey{}, id)
}
// WithCausationID returns a context that carries a causation ID to be sent
// as X-Causation-ID on every Hexis request made with it.
func WithCausationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, causationKey{}, id)
}
func correlationIDFrom(ctx context.Context) string {
id, _ := ctx.Value(correlationKey{}).(string)
return id
}
func causationIDFrom(ctx context.Context) string {
id, _ := ctx.Value(causationKey{}).(string)
return id
}
type Client struct {
baseURL string
httpClient *http.Client
token string
}
func New(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// 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 {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
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)
}
if id := causationIDFrom(ctx); id != "" {
req.Header.Set("X-Causation-ID", id)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var errResp struct {
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp.Error != "" {
return fmt.Errorf("%s: %s", http.StatusText(resp.StatusCode), errResp.Error)
}
return fmt.Errorf("%s", http.StatusText(resp.StatusCode))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// 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"`
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=" + url.QueryEscape(entityID)
}
if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return result, nil
}
func (c *Client) GetCapability(ctx context.Context, id string) (*Capability, error) {
var result Capability
if err := c.do(ctx, http.MethodGet, "/api/v1/capabilities/"+id, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) CreateCapability(ctx context.Context, req CreateCapabilityRequest) (*Capability, error) {
var result Capability
if err := c.do(ctx, http.MethodPost, "/api/v1/capabilities", req, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) Execute(ctx context.Context, req ExecuteRequest) (*Execution, error) {
var result Execution
if err := c.do(ctx, http.MethodPost, "/api/v1/execute", req, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) GetExecution(ctx context.Context, id string) (*Execution, error) {
var result Execution
if err := c.do(ctx, http.MethodGet, "/api/v1/executions/"+id, nil, &result); err != nil {
return nil, err
}
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)
}