Files
Agent-System-Backend/internal/hostapi/server.go
weichen 92ede3b970
ci / vet-and-test (pull_request) Successful in 37s
Close the EXT-8 remaining blocks: vector sharing, hostapi face, citation consistency
Why: EXT-8 closure needs the vector channel shared (not just fulltext), the /host/v1 knowledge face honoring the authorizer, and citation-ref-to-artifact consistency pinned as an acceptance test.
Evidence: TestRetrieveSharedVectorChannel (hash embedder: authorized shared vector hit retrievable, denied yields zero), TestSharedSearchFace (hostapi switches to SharedSearch when wired), TestSharedCitationRefDownloadable (shared hit -> Ref -> artifact content download for the authorized member); go test ./... 32/32 packages ok.
Impact: store.SearchKnowledgeVectorsShared; rag.Pipeline.RetrieveShared (own + shared fulltext + shared vector, RRF-merged); server searchKnowledge prefers RetrieveShared when the retriever implements it; hostapi Server.SharedSearch wired in the composition root through the same authorizer slot. Default behavior unchanged.
Refs: EXT-8 proposal acceptance; slices 1-2.
2026-10-08 17:16:10 +00:00

167 lines
5.0 KiB
Go

// Package hostapi 是 sidecar 与外部进程调用基座的本机 HTTP 面(/host/v1)。
package hostapi
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// Server 依赖由组合根注入。Dispatch/Search/Complete 可为 nil(对应路由返回 503)。
type Server struct {
Token string
Store *store.Store
Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error
Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
// SharedSearch EXT-8 余项:非 nil 时 search 端点改用之(组合根传入
// 「本人结果 + 适配器放行的共享追加」组合;nil=现状仅本人)。
SharedSearch func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
Complete func(ctx context.Context, owner, prompt string) (string, error)
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("PUT /host/v1/kv/{key}", s.auth(s.putKV))
mux.HandleFunc("GET /host/v1/kv/{key}", s.auth(s.getKV))
mux.HandleFunc("POST /host/v1/audit", s.auth(s.audit))
mux.HandleFunc("POST /host/v1/commands/{id}", s.auth(s.command))
mux.HandleFunc("POST /host/v1/llm", s.auth(s.llm))
mux.HandleFunc("GET /host/v1/knowledge/search", s.auth(s.search))
return mux
}
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.Token != "" {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if got != s.Token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
next(w, r)
}
}
func (s *Server) pluginID(r *http.Request) string {
id := r.Header.Get("X-Plugin-Id")
if id == "" {
id = "unknown"
}
return id
}
func (s *Server) owner(r *http.Request) string {
if o := r.Header.Get("X-Owner"); o != "" {
return o
}
return store.OwnerDefault
}
func (s *Server) putKV(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err := s.Store.PutPluginKV(s.pluginID(r), s.owner(r), r.PathValue("key"), string(body)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getKV(w http.ResponseWriter, r *http.Request) {
v, ok, err := s.Store.GetPluginKV(s.pluginID(r), s.owner(r), r.PathValue("key"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]string{"value": v})
}
func (s *Server) audit(w http.ResponseWriter, r *http.Request) {
var body struct {
Action string `json:"action"`
Target string `json:"target"`
Evidence string `json:"evidence"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" {
http.Error(w, "action required", http.StatusBadRequest)
return
}
if err := s.Store.AppendAuditEvent(store.AuditEvent{
Owner: s.owner(r), Action: body.Action, Actor: s.pluginID(r), Target: body.Target,
Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) command(w http.ResponseWriter, r *http.Request) {
if s.Dispatch == nil {
http.Error(w, "command bus unavailable", http.StatusServiceUnavailable)
return
}
var body struct {
Params map[string]any `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if err := s.Dispatch(r.Context(), s.pluginID(r), s.owner(r), r.PathValue("id"), body.Params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) llm(w http.ResponseWriter, r *http.Request) {
if s.Complete == nil {
http.Error(w, "llm route not configured", http.StatusServiceUnavailable)
return
}
var body struct {
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Prompt) == "" {
http.Error(w, "prompt required", http.StatusBadRequest)
return
}
text, err := s.Complete(r.Context(), s.owner(r), body.Prompt)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"text": text})
}
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
if s.Search == nil {
http.Error(w, "retriever unavailable", http.StatusServiceUnavailable)
return
}
// EXT-8 余项:共享版可用时整体切换(本人+共享追加)。
fn := s.Search
if s.SharedSearch != nil {
fn = s.SharedSearch
}
hits, err := fn(r.Context(), s.owner(r), r.URL.Query().Get("q"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, hits)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}