Why: Downstream products must extend the base through a public package and host API instead of editing internal code. Evidence: go test ./... passed, including plugin v2 assets, sidecar startup, migration v13, pending confirms, HITL timeout, hash embedder, command pairs, and the extension surface snapshot. Impact: apiVersion 2.0-experimental rejects empty network allowlists for declarative HTTP. Hash embeddings stay opt-in via models.embedding.provider=hash. Plugin secrets resolve per owner at execution time. Refs: docs/proposals/2026-10-01-plugin-contract-v2.md Co-authored-by: Cursor <cursoragent@cursor.com>
159 lines
4.6 KiB
Go
159 lines
4.6 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)
|
|
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
|
|
}
|
|
hits, err := s.Search(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)
|
|
}
|