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:
2026-10-01 10:35:02 +00:00
co-authored by Cursor
parent 268f4a9abf
commit cd5b82186d
35 changed files with 2144 additions and 49 deletions
+158
View File
@@ -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)
}
+62
View File
@@ -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)
}
}