Files
hyperguild/internal/brain/client.go
T
mathiasandClaude Opus 5.5 99fedeaa98 fix(ingestion): authenticate the REST API as a whole, not route by route
Only /mcp was wrapped in BearerMiddleware; /query, /write, /ingest*,
/promote, /pending and the rest on the same mux had no auth, and
infra#517 found them on the internet (mitigated there by narrowing the
ingress). They are still open on the tailnet and LAN NodePort 30330.

mountREST puts the REST routes on their own mux, mounted at "/" behind
the same bearer middleware and credential as /mcp. Routes registered on
the outer mux stay explicitly public (/mcp has its own auth, /capture,
the HMAC webhook, OAuth metadata, /metrics); anything else, including a
route added later, is authenticated by default.

The hyperguild CLI (cmd/hyperguild and internal/brain) now sends
BRAIN_MCP_TOKEN, which it never did. brain-ui and brain-ingest-ntfy
already send theirs.

Refs #149

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:17:45 +02:00

82 lines
2.3 KiB
Go

// internal/brain/client.go
// Package brain provides a lightweight client for querying the ingestion server.
// Skill handlers call Query before spawning workers to inject relevant knowledge
// from the brain into the task prompt. Errors are suppressed — the brain is
// optional context; its absence must never block a skill invocation.
package brain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
)
type queryResult struct {
Path string `json:"path"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Score int `json:"score"`
}
// Query calls the ingestion server and returns relevant knowledge as a
// formatted string ready to prepend to a worker task prompt.
// Returns empty string (no error) when baseURL or query is empty,
// when the brain is unreachable, or when no results are found.
func Query(ctx context.Context, baseURL, query string, limit int) (string, error) {
if baseURL == "" || strings.TrimSpace(query) == "" {
return "", nil
}
if limit <= 0 {
limit = 3
}
body, _ := json.Marshal(map[string]any{"query": query, "limit": limit})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/query", bytes.NewReader(body))
if err != nil {
slog.Warn("brain: build request failed", "err", err)
return "", nil
}
req.Header.Set("Content-Type", "application/json")
// The REST API requires a bearer token since hyperguild#149.
if tok := os.Getenv("BRAIN_MCP_TOKEN"); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Warn("brain: ingestion server unreachable", "err", err)
return "", nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
slog.Warn("brain: ingestion server returned non-OK", "status", resp.StatusCode)
return "", nil
}
out, _ := io.ReadAll(resp.Body)
var result struct {
Results []queryResult `json:"results"`
}
if err := json.Unmarshal(out, &result); err != nil || len(result.Results) == 0 {
return "", nil
}
var b strings.Builder
b.WriteString("## Relevant knowledge\n\n")
for _, r := range result.Results {
title := r.Title
if title == "" {
title = r.Path
}
fmt.Fprintf(&b, "### %s\n%s\n\n", title, r.Excerpt)
}
return b.String(), nil
}