Add the public extension surface and platform services
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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package hostapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
func TestHostClientRoundTrip(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "h.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
srv := &Server{Token: "sek", Store: st, Dispatch: func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error {
|
||||
return nil
|
||||
}}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
c := extension.NewHostClient(ts.URL, "sek", "rdm", "acme")
|
||||
if err := c.PutKV(context.Background(), "slice", "s1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := c.GetKV(context.Background(), "slice")
|
||||
if err != nil || got != "s1" {
|
||||
t.Fatalf("kv %q %v", got, err)
|
||||
}
|
||||
if err := c.Audit(context.Background(), "ingest", "doc", []byte(`{"ok":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs, err := st.ListAuditEvents("acme", 10)
|
||||
if err != nil || len(evs) != 1 || evs[0].Action != "ingest" {
|
||||
t.Fatalf("audit %+v %v", evs, err)
|
||||
}
|
||||
if err := c.Dispatch(context.Background(), "builtin.help", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := extension.NewHostClient(ts.URL, "nope", "rdm", "acme")
|
||||
if err := bad.PutKV(context.Background(), "x", "y"); err == nil {
|
||||
t.Fatal("bad token must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMUnconfigured(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "h.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
srv := &Server{Store: st}
|
||||
req := httptest.NewRequest(http.MethodPost, "/host/v1/llm", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user