Compare commits
2
Commits
v0.24.0
...
21a53c4159
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a53c4159 | ||
|
|
99fedeaa98 |
@@ -20,6 +20,7 @@ const defaultBrainURL = "http://koala:30330"
|
||||
// shell-friendly client; MCP framing is unnecessary.
|
||||
type brainClient struct {
|
||||
baseURL string
|
||||
token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ func newBrainClient() *brainClient {
|
||||
}
|
||||
return &brainClient{
|
||||
baseURL: u,
|
||||
token: os.Getenv("BRAIN_MCP_TOKEN"),
|
||||
http: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.authorize(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -105,6 +108,7 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.authorize(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -158,3 +162,10 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// authorize adds the bearer token when one is configured.
|
||||
func (c *brainClient) authorize(req *http.Request) {
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI
|
||||
// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call.
|
||||
func TestBrainClientSendsBrainMCPToken(t *testing.T) {
|
||||
var got []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = append(got, r.URL.Path+" "+r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/write" {
|
||||
_, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("BRAIN_URL", srv.URL)
|
||||
t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token")
|
||||
|
||||
c := newBrainClient()
|
||||
_, err := c.Query(context.Background(), "x", 1)
|
||||
require.NoError(t, err)
|
||||
_, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token"}, got)
|
||||
}
|
||||
|
||||
func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) {
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("BRAIN_URL", srv.URL)
|
||||
t.Setenv("BRAIN_MCP_TOKEN", "")
|
||||
_, err := newBrainClient().Query(context.Background(), "x", 1)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, auth)
|
||||
}
|
||||
@@ -504,17 +504,6 @@ func main() {
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /query", h.Query)
|
||||
mux.HandleFunc("POST /write", h.Write)
|
||||
mux.HandleFunc("POST /index", h.Index)
|
||||
mux.HandleFunc("POST /ingest", h.Ingest)
|
||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||
mux.HandleFunc("GET /pending", h.Pending)
|
||||
mux.HandleFunc("POST /promote", h.Promote)
|
||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||
if webhookHandler != nil {
|
||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||
}
|
||||
@@ -532,6 +521,13 @@ func main() {
|
||||
}
|
||||
|
||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||
// The REST API, authenticated as a whole (hyperguild#149). Same credential
|
||||
// as /mcp: callers (brain-ui, brain-ingest-ntfy, the hyperguild CLI) send
|
||||
// BRAIN_MCP_TOKEN. Registered after the public routes on purpose only for
|
||||
// reading order; ServeMux precedence is by pattern specificity.
|
||||
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||
return chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, next)
|
||||
})
|
||||
|
||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||
// tracker to file action items, so it only mounts when Gitea is
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// mountREST registers the REST API on its own mux and mounts it at "/" behind
|
||||
// auth (hyperguild#149). Authentication is a property of the mux, not of each
|
||||
// route: anything not registered on the outer mux as explicitly public (/mcp,
|
||||
// /capture, the HMAC webhook, OAuth metadata, /metrics) lands here and is
|
||||
// authenticated, including routes added later. Before this, only /mcp was
|
||||
// wrapped, and the REST routes beside it were open; infra#517 found them on
|
||||
// the internet.
|
||||
func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) {
|
||||
rest := http.NewServeMux()
|
||||
rest.HandleFunc("POST /query", h.Query)
|
||||
rest.HandleFunc("POST /write", h.Write)
|
||||
rest.HandleFunc("POST /index", h.Index)
|
||||
rest.HandleFunc("POST /ingest", h.Ingest)
|
||||
rest.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||
rest.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||
rest.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||
rest.HandleFunc("GET /pending", h.Pending)
|
||||
rest.HandleFunc("POST /promote", h.Promote)
|
||||
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||
rest.HandleFunc("GET /pass-rate", h.PassRate)
|
||||
mux.Handle("/", auth(rest))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
)
|
||||
|
||||
const routesTok = "routes-test-token"
|
||||
|
||||
// hyperguild#149: only /mcp was authenticated; every REST route on the same
|
||||
// mux was open, and infra#517 found them on the internet. The REST API is now
|
||||
// mounted at "/" behind the bearer middleware, so any route not registered
|
||||
// as explicitly public is authenticated, including routes added later.
|
||||
func testRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||
return chassisauth.BearerMiddleware(routesTok, nil, "brain", "", next)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, authz string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) {
|
||||
r := testRouter(t)
|
||||
for _, rt := range []struct{ method, path string }{
|
||||
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
||||
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
||||
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
||||
} {
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, ""), "%s %s without a token", rt.method, rt.path)
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, "Bearer wrong"), "%s %s with a wrong token", rt.method, rt.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTRoutesServeAValidToken(t *testing.T) {
|
||||
r := testRouter(t)
|
||||
assert.NotEqual(t, http.StatusUnauthorized, do(t, r, "POST", "/query", "Bearer "+routesTok))
|
||||
assert.Equal(t, http.StatusOK, do(t, r, "GET", "/pending", "Bearer "+routesTok))
|
||||
}
|
||||
|
||||
// Default-deny: a path nobody registered is still behind auth, so a route
|
||||
// added to the REST mux later cannot ship open by omission.
|
||||
func TestUnregisteredPathIsAuthenticatedByDefault(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, testRouter(t), "POST", "/some-future-route", ""))
|
||||
}
|
||||
|
||||
// Explicitly public routes registered on the outer mux keep working.
|
||||
func TestMetricsStaysOpenForPrometheus(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, do(t, testRouter(t), "GET", "/metrics", ""))
|
||||
}
|
||||
@@ -447,6 +447,12 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "path is required")
|
||||
return
|
||||
}
|
||||
resolved, err := ResolveIngestPath(h.brainDir, req.Path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
req.Path = resolved
|
||||
|
||||
info, err := os.Stat(req.Path)
|
||||
if err != nil {
|
||||
@@ -469,6 +475,12 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
if !isSupportedExtension(ext) {
|
||||
return nil
|
||||
}
|
||||
// Each walked file is re-resolved: a symlinked file inside raw/
|
||||
// would otherwise lead extract.Text out of the inbox (#149).
|
||||
if _, rerr := ResolveIngestPath(h.brainDir, path); rerr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
|
||||
return nil
|
||||
}
|
||||
content, readErr := extract.Text(path)
|
||||
if readErr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||
|
||||
@@ -361,10 +361,11 @@ func TestIngestPath_MissingPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIngestPath_File(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
brainDir, h := setup(t)
|
||||
|
||||
// Create a temp file with content
|
||||
dir := t.TempDir()
|
||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
f := filepath.Join(dir, "doc.md")
|
||||
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
||||
|
||||
@@ -468,10 +469,11 @@ func TestIngestRaw_DryRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIngestPath_Directory(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
brainDir, h := setup(t)
|
||||
|
||||
// Create a temp dir with one .md file
|
||||
dir := t.TempDir()
|
||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
|
||||
@@ -40,9 +40,10 @@ func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||
|
||||
_, h := setup(t)
|
||||
brainDir, h := setup(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
f := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrOutsideIngestRoot means an ingest path does not resolve inside brain/raw/.
|
||||
var ErrOutsideIngestRoot = errors.New("ingest path must be inside brain/raw/")
|
||||
|
||||
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
|
||||
// inbox the watcher already owns (hyperguild#149). Relative paths resolve
|
||||
// against that root; symlinks are evaluated before the containment check, so
|
||||
// a link inside raw/ that points elsewhere is refused. The path must exist.
|
||||
func ResolveIngestPath(brainDir, p string) (string, error) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return "", fmt.Errorf("%w: path is empty", ErrOutsideIngestRoot)
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: inbox unavailable: %v", ErrOutsideIngestRoot, err)
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(root, p)
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(filepath.Clean(p))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrOutsideIngestRoot, err)
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// hyperguild#149: /ingest-path and MCP brain_ingest read a caller-chosen
|
||||
// server path, so any file the pod could read (the ServiceAccount token,
|
||||
// /etc/passwd) could be pulled into the brain and read back out. Ingest paths
|
||||
// now resolve inside brain/raw/ — the inbox the watcher already owns — with
|
||||
// symlinks evaluated, or are refused.
|
||||
func TestResolveIngestPathConfinesToRawInbox(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
raw := filepath.Join(brain, "raw")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(raw, "sub"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "sub", "ok.md"), []byte("x"), 0o644))
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("s"), 0o644))
|
||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "link.md")))
|
||||
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(raw, "dirlink")))
|
||||
|
||||
for _, ok := range []string{filepath.Join(raw, "sub", "ok.md"), "sub/ok.md", raw, "sub"} {
|
||||
_, err := api.ResolveIngestPath(brain, ok)
|
||||
assert.NoError(t, err, ok)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"/etc/hostname", outside, "../secret.txt", filepath.Join(raw, "..", "wiki"),
|
||||
filepath.Join(raw, "link.md"), filepath.Join(raw, "dirlink", "secret.txt"),
|
||||
raw + "-evil/x.md", "",
|
||||
} {
|
||||
_, err := api.ResolveIngestPath(brain, bad)
|
||||
assert.Error(t, err, "must refuse %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestPathRefusesAPathOutsideTheInbox(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"path": "/etc/hostname"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "raw")
|
||||
}
|
||||
|
||||
// A directory ingest must not follow a symlinked FILE inside raw/ out of it.
|
||||
func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
raw := filepath.Join(dir, "raw", "batch")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\nSENTINEL-149-OUTSIDE"), 0o644))
|
||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "leak.md")))
|
||||
body, _ := json.Marshal(map[string]any{"path": "batch", "source": "walk"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||
assert.Contains(t, rec.Body.String(), "skipped")
|
||||
assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE")
|
||||
}
|
||||
@@ -543,6 +543,12 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
}
|
||||
|
||||
if a.Path != "" {
|
||||
// Confined to brain/raw/ with symlinks evaluated (hyperguild#149).
|
||||
resolved, err := api.ResolveIngestPath(s.brainDir, a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Path = resolved
|
||||
text, err := extract.Text(a.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: %w", err)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
)
|
||||
|
||||
// hyperguild#149: brain_ingest's `path` read any file the pod could read and
|
||||
// ingested it into the brain, where brain_query reads it back out.
|
||||
func TestBrainIngestRefusesAPathOutsideTheRawInbox(t *testing.T) {
|
||||
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||
return `[{"title":"Leak","type":"concept","content":"x"}]`, nil
|
||||
}}
|
||||
srv := mcp.NewServer(newBrainRepo(t), &stub, nil, nil)
|
||||
resp := authCall(t, srv, "", "brain_ingest", map[string]any{"path": "/etc/hostname"})
|
||||
require.NotNil(t, resp["error"], "a path outside brain/raw/ must be refused")
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"], "brain/raw/")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,10 @@ func Query(ctx context.Context, baseURL, query string, limit int) (string, error
|
||||
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 {
|
||||
|
||||
@@ -65,3 +65,18 @@ func TestQueryUnavailableServerReturnsEmpty(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
// hyperguild#149: the REST API requires a bearer token.
|
||||
func TestQuerySendsBrainMCPToken(t *testing.T) {
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("BRAIN_MCP_TOKEN", "lib-test-token")
|
||||
_, _ = brain.Query(context.Background(), srv.URL, "x", 1)
|
||||
if auth != "Bearer lib-test-token" {
|
||||
t.Fatalf("Authorization = %q, want the bearer token", auth)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user