mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(plugins): expose globally versioned public API Co-authored-by: multica-agent <[email protected]> * fix(plugins): proxy same-origin public API Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
486 lines
19 KiB
Go
486 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
|
"github.com/multica-ai/multica/server/pkg/plugincontract"
|
|
"github.com/multica-ai/multica/server/pkg/remotemcp"
|
|
)
|
|
|
|
// examples/plugins/deploy-sentinel, driven end to end.
|
|
//
|
|
// This installs the REAL manifest from the repository, not a fixture written
|
|
// next to the assertions. That is the point: a hand-written fixture keeps
|
|
// passing after the example rots, and the example is what a plugin author
|
|
// copies. If somebody breaks the manifest, this is what says so.
|
|
//
|
|
// The plugin declares all four triggers and both transports, so one install
|
|
// covers the whole PR-4 surface: a skill resource lands in the skill table, its
|
|
// http hooks reach an agent as tools and are invoked over a real signed request,
|
|
// and its mcp hook is discovered and approved.
|
|
//
|
|
// The only thing rewritten on the way in is the two endpoint hosts — a test
|
|
// server's port is not knowable when the manifest is written. Everything else,
|
|
// including every tool description an agent reads, is the file's own text.
|
|
|
|
func exampleRepoDir(t *testing.T) string {
|
|
t.Helper()
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("cannot locate this test file")
|
|
}
|
|
// server/internal/handler -> repo root
|
|
dir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "examples", "plugins", "deploy-sentinel")
|
|
if _, err := os.Stat(filepath.Join(dir, plugincontract.ManifestFilename)); err != nil {
|
|
t.Fatalf("the example plugin is missing: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
type exampleServers struct {
|
|
sentinel *httptest.Server
|
|
metrics *httptest.Server
|
|
}
|
|
|
|
func hostOf(t *testing.T, rawURL string) string {
|
|
t.Helper()
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", rawURL, err)
|
|
}
|
|
return parsed.Hostname()
|
|
}
|
|
|
|
// stageExamplePlugin copies the example into a temp MULTICA_PLUGIN_DIR and
|
|
// repoints its two endpoints (and the net: scopes that authorise them) at the
|
|
// running test servers.
|
|
func stageExamplePlugin(t *testing.T, servers exampleServers) (string, []string) {
|
|
t.Helper()
|
|
source := exampleRepoDir(t)
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "deploy-sentinel")
|
|
|
|
if err := os.CopyFS(staged, os.DirFS(source)); err != nil {
|
|
t.Fatalf("stage the example: %v", err)
|
|
}
|
|
|
|
manifestPath := filepath.Join(staged, plugincontract.ManifestFilename)
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatalf("read the example manifest: %v", err)
|
|
}
|
|
sentinelHost, metricsHost := hostOf(t, servers.sentinel.URL), hostOf(t, servers.metrics.URL)
|
|
rewritten := strings.NewReplacer(
|
|
"https://sentinel.example.com", servers.sentinel.URL,
|
|
"https://metrics.example.com", servers.metrics.URL,
|
|
"net:sentinel.example.com", "net:"+sentinelHost,
|
|
"net:metrics.example.com", "net:"+metricsHost,
|
|
).Replace(string(raw))
|
|
// A net: scope names a host, not a URL, and both test servers land on the
|
|
// same loopback host with different ports. Two identical scope lines are a
|
|
// manifest error, so collapse them — this is an artefact of the rewrite,
|
|
// not of the example.
|
|
rewritten = dedupeScopes(t, rewritten)
|
|
if err := os.WriteFile(manifestPath, []byte(rewritten), 0o644); err != nil {
|
|
t.Fatalf("write the staged manifest: %v", err)
|
|
}
|
|
|
|
// Read the scopes back out of the staged file rather than listing them
|
|
// here: granting a set this test maintains by hand would let the manifest
|
|
// add a scope that nothing ever notices.
|
|
var parsed struct {
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
if err := json.Unmarshal([]byte(rewritten), &parsed); err != nil {
|
|
t.Fatalf("parse the staged manifest: %v", err)
|
|
}
|
|
return root, parsed.Scopes
|
|
}
|
|
|
|
// dedupeScopes rewrites the manifest's scope array in place, dropping repeats
|
|
// while preserving order.
|
|
func dedupeScopes(t *testing.T, manifest string) string {
|
|
t.Helper()
|
|
var document map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(manifest), &document); err != nil {
|
|
t.Fatalf("parse the staged manifest: %v", err)
|
|
}
|
|
var scopes []string
|
|
if err := json.Unmarshal(document["scopes"], &scopes); err != nil {
|
|
t.Fatalf("parse scopes: %v", err)
|
|
}
|
|
seen := map[string]bool{}
|
|
unique := make([]string, 0, len(scopes))
|
|
for _, scope := range scopes {
|
|
if seen[scope] {
|
|
continue
|
|
}
|
|
seen[scope] = true
|
|
unique = append(unique, scope)
|
|
}
|
|
encoded, err := json.Marshal(unique)
|
|
if err != nil {
|
|
t.Fatalf("encode scopes: %v", err)
|
|
}
|
|
document["scopes"] = encoded
|
|
out, err := json.Marshal(document)
|
|
if err != nil {
|
|
t.Fatalf("encode the staged manifest: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func installExamplePlugin(t *testing.T, servers exampleServers) string {
|
|
t.Helper()
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
root, scopes := stageExamplePlugin(t, servers)
|
|
|
|
plugins := testHandler.PluginService
|
|
previous := *plugins
|
|
plugins.LocalDir = root
|
|
plugins.Host = plugincontract.HostCapabilities()
|
|
// The two test servers run on loopback with self-signed certs, which the
|
|
// outbound guard refuses by design. DevOrigins is the operator opt-in a
|
|
// plugin author uses for exactly this, and it is scoped to these origins.
|
|
plugins.DevOrigins = []string{servers.sentinel.URL, servers.metrics.URL}
|
|
plugins.HookClient = servers.sentinel.Client()
|
|
// The manifest declares two secret config fields, so storing configuration
|
|
// needs the secretbox the deployment would normally supply.
|
|
box, err := secretbox.New(bytes.Repeat([]byte{9}, 32))
|
|
if err != nil {
|
|
t.Fatalf("secretbox.New: %v", err)
|
|
}
|
|
plugins.Secrets = box
|
|
// Signing is what makes a hook call verifiable by the plugin's own server;
|
|
// without a deployment key the engine refuses to call out at all.
|
|
plugins.DeploymentKey = bytes.Repeat([]byte{3}, 32)
|
|
plugins.Callbacks = service.NewCallbackTokens()
|
|
plugins.CallbackBaseURL = "https://plugin-api.multica.test/v1"
|
|
t.Cleanup(func() { *plugins = previous })
|
|
|
|
// Publishing is what validates the whole artifact: the manifest, the surface
|
|
// script, and the SKILL.md all have to be there and be loadable. The example
|
|
// is the file a plugin author copies, so this is the check that keeps it
|
|
// runnable rather than merely well-formed.
|
|
versionID := publishLocalPlugin(t, "deploy-sentinel")
|
|
body, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"granted_scopes": scopes,
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("install deploy-sentinel: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var installed struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
|
|
t.Fatalf("decode installation: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cleanup := httptest.NewRecorder()
|
|
testHandler.UninstallPlugin(cleanup, pluginHandlerRequest(http.MethodDelete, "/plugins", nil,
|
|
map[string]string{"id": testWorkspaceID, "installationId": installed.ID}))
|
|
})
|
|
return installed.ID
|
|
}
|
|
|
|
func configureExamplePlugin(t *testing.T, installationID string) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{"values": map[string]any{
|
|
"service_prefix": "prod-",
|
|
"rollback_window_minutes": 60,
|
|
"require_incident_label": true,
|
|
"environment": "production",
|
|
"sentinel_token": "sentinel-secret",
|
|
}})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.ConfigurePlugin(recorder, pluginHandlerRequest(http.MethodPut, "/config", body,
|
|
map[string]string{"id": testWorkspaceID, "installationId": installationID}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("configure: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
// quietServer is the plugin's own hook endpoint: TLS, because the http
|
|
// transport's client is supplied by the test and can trust its certificate.
|
|
func quietServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewTLSServer(handler)
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
// metricsServer is the plugin author's own MCP server: HTTPS, because the
|
|
// manifest validator requires it and a dev origin does not relax that.
|
|
//
|
|
// remotemcp.Discover builds its own HTTP client, so the only way for it to
|
|
// trust this certificate is the same one a plugin author uses locally —
|
|
// MULTICA_PLUGIN_DEV_CA, pointing at a CA bundle.
|
|
func metricsServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewTLSServer(http.HandlerFunc(serveMetricsMCP))
|
|
t.Cleanup(server.Close)
|
|
|
|
caPath := filepath.Join(t.TempDir(), "dev-ca.pem")
|
|
pem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
|
if err := os.WriteFile(caPath, pem, 0o600); err != nil {
|
|
t.Fatalf("write the dev CA: %v", err)
|
|
}
|
|
t.Setenv(remotemcp.DevCAEnv, caPath)
|
|
t.Setenv(remotemcp.DevOriginsEnv, server.URL)
|
|
return server
|
|
}
|
|
|
|
// The example's SKILL.md becomes an ordinary workspace skill. No bundle
|
|
// pipeline, no digest, no new table — that was the whole claim.
|
|
func TestExamplePluginContributesItsSkill(t *testing.T) {
|
|
servers := exampleServers{
|
|
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
|
|
metrics: metricsServer(t),
|
|
}
|
|
installationID := installExamplePlugin(t, servers)
|
|
|
|
names := skillNamesForInstallation(t, installationID)
|
|
if len(names) != 1 || names[0] != "incident-response" {
|
|
t.Fatalf("skills for the installation = %v, want [incident-response]", names)
|
|
}
|
|
|
|
var body string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT content FROM skill WHERE plugin_installation_id = $1`, installationID).Scan(&body); err != nil {
|
|
t.Fatalf("read the skill content: %v", err)
|
|
}
|
|
// Asserting on a sentence the skill actually teaches, not on its length: a
|
|
// truncated or mis-resolved entry would still have bytes in it.
|
|
if !strings.Contains(body, "Blast radius") {
|
|
t.Fatalf("the skill did not come from the example's SKILL.md; got %d bytes", len(body))
|
|
}
|
|
}
|
|
|
|
// What an agent working an incident actually sees.
|
|
func TestExamplePluginHooksReachAnAgentAsTools(t *testing.T) {
|
|
servers := exampleServers{
|
|
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
|
|
metrics: metricsServer(t),
|
|
}
|
|
installExamplePlugin(t, servers)
|
|
|
|
tools, err := testHandler.PluginService.AgentHookTools(context.Background(), parseUUID(testWorkspaceID))
|
|
if err != nil {
|
|
t.Fatalf("agent hook tools: %v", err)
|
|
}
|
|
byHook := make(map[string]service.PluginHookTool, len(tools))
|
|
for _, tool := range tools {
|
|
byHook[tool.HookKey] = tool
|
|
}
|
|
|
|
for _, hookKey := range []string{"correlate_deploys", "request_rollback"} {
|
|
if _, ok := byHook[hookKey]; !ok {
|
|
t.Fatalf("hook %q declares the agent trigger but is not offered as a tool", hookKey)
|
|
}
|
|
}
|
|
// page_oncall is event-only. An agent being able to page on-call by
|
|
// choosing to would defeat the point of the trigger being separate.
|
|
if _, offered := byHook["page_oncall"]; offered {
|
|
t.Fatal("page_oncall is event-only and must never appear in an agent's tool list")
|
|
}
|
|
|
|
rollback := byHook["request_rollback"]
|
|
// The description is the manifest's verbatim, because that text is how the
|
|
// agent decides which tool to reach for.
|
|
if !strings.Contains(rollback.Description, "does NOT roll anything back") {
|
|
t.Fatalf("the tool description must be the manifest's own text, got: %q", rollback.Description)
|
|
}
|
|
if !strings.HasPrefix(rollback.Name, "deploy_sentinel_") || !strings.HasSuffix(rollback.Name, "__request_rollback") {
|
|
t.Fatalf("tool name %q is not namespaced by plugin", rollback.Name)
|
|
}
|
|
if len(rollback.InputSchema) == 0 || !strings.Contains(string(rollback.InputSchema), "deploy_id") {
|
|
t.Fatalf("the manifest's input schema did not reach the agent: %s", rollback.InputSchema)
|
|
}
|
|
}
|
|
|
|
// The whole round trip: an agent calls the tool, Multica signs the request, the
|
|
// plugin's own server sees that signature and answers, and the answer comes
|
|
// back. Nothing is mocked except the author's business logic, which is theirs.
|
|
func TestExamplePluginAgentHookRoundTripIsSigned(t *testing.T) {
|
|
var seenSignature, seenTimestamp, seenHook, seenTrigger string
|
|
var seenConfig map[string]any
|
|
|
|
sentinel := quietServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
HookKey string `json:"hook_key"`
|
|
Trigger string `json:"trigger"`
|
|
Config map[string]any `json:"config"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
seenSignature = r.Header.Get("X-Multica-Signature")
|
|
seenTimestamp = r.Header.Get("X-Multica-Timestamp")
|
|
seenHook, seenTrigger, seenConfig = payload.HookKey, payload.Trigger, payload.Config
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprintf(w, `{"deploy_count":1,"summary":"1 deploy to %vcheckout-api"}`, payload.Config["service_prefix"])
|
|
})
|
|
servers := exampleServers{sentinel: sentinel, metrics: metricsServer(t)}
|
|
|
|
installationID := installExamplePlugin(t, servers)
|
|
configureExamplePlugin(t, installationID)
|
|
|
|
input := json.RawMessage(`{"service":"checkout-api","window_minutes":120}`)
|
|
result, err := testHandler.PluginService.InvokeAgentHook(
|
|
context.Background(), installationID, "correlate_deploys", parseUUID(testUserID), input)
|
|
if err != nil {
|
|
t.Fatalf("agent hook invocation: %v", err)
|
|
}
|
|
|
|
if seenHook != "correlate_deploys" || seenTrigger != "agent" {
|
|
t.Fatalf("the plugin saw hook=%q trigger=%q", seenHook, seenTrigger)
|
|
}
|
|
if seenSignature == "" || seenTimestamp == "" {
|
|
t.Fatal("the request reached the plugin unsigned")
|
|
}
|
|
// The plugin's configuration travels with the call — its business rules
|
|
// depend on it, and it must not have to keep a second copy.
|
|
if seenConfig["service_prefix"] != "prod-" {
|
|
t.Fatalf("config did not reach the hook: %v", seenConfig)
|
|
}
|
|
// Structural, not behavioural: a secret-typed value is written to a
|
|
// different table and never reaches installation.Config, so this cannot
|
|
// fail today. It is here for the day somebody merges those two tables.
|
|
if _, leaked := seenConfig["sentinel_token"]; leaked {
|
|
t.Fatal("a secret config value was sent to the hook endpoint")
|
|
}
|
|
if result.Status != "ok" {
|
|
t.Fatalf("hook result status = %q, want ok", result.Status)
|
|
}
|
|
body, _ := json.Marshal(result.Output)
|
|
if !strings.Contains(string(body), "prod-checkout-api") {
|
|
t.Fatalf("the plugin's answer did not come back to the agent: %s", body)
|
|
}
|
|
}
|
|
|
|
// Discovery lists what the metrics server offers; approval is what makes any of
|
|
// it callable. That gap is the difference between this transport and http.
|
|
func TestExamplePluginMCPToolsNeedApproval(t *testing.T) {
|
|
servers := exampleServers{
|
|
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
|
|
metrics: metricsServer(t),
|
|
}
|
|
installationID := installExamplePlugin(t, servers)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.ListPluginMCPTools(recorder, pluginHandlerRequest(http.MethodGet, "/mcp/tools", nil, map[string]string{
|
|
"id": testWorkspaceID, "installationId": installationID, "hookKey": "metrics",
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("discover: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var discovered struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Approved bool `json:"approved"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &discovered); err != nil {
|
|
t.Fatalf("decode tools: %v", err)
|
|
}
|
|
if len(discovered.Tools) != 3 {
|
|
t.Fatalf("discovered %d tools, want the metrics server's 3", len(discovered.Tools))
|
|
}
|
|
for _, tool := range discovered.Tools {
|
|
if tool.Approved {
|
|
t.Fatalf("tool %q reported as approved before anybody approved it", tool.Name)
|
|
}
|
|
}
|
|
|
|
// Nothing approved yet, so an agent gets no connection at all.
|
|
connections, err := testHandler.PluginService.AgentMCPConnections(context.Background(), parseUUID(testWorkspaceID))
|
|
if err != nil {
|
|
t.Fatalf("agent mcp connections: %v", err)
|
|
}
|
|
if len(connections) != 0 {
|
|
t.Fatalf("an unapproved mcp hook produced %d connections", len(connections))
|
|
}
|
|
|
|
approve, _ := json.Marshal(map[string]any{"tools": []string{"query_timeseries", "list_alerts"}})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.ApprovePluginMCPTools(recorder, pluginHandlerRequest(http.MethodPut, "/mcp/tools", approve, map[string]string{
|
|
"id": testWorkspaceID, "installationId": installationID, "hookKey": "metrics",
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("approve: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
connections, err = testHandler.PluginService.AgentMCPConnections(context.Background(), parseUUID(testWorkspaceID))
|
|
if err != nil {
|
|
t.Fatalf("agent mcp connections after approval: %v", err)
|
|
}
|
|
if len(connections) != 1 {
|
|
t.Fatalf("got %d connections after approval, want 1", len(connections))
|
|
}
|
|
pinned := map[string]bool{}
|
|
for _, tool := range connections[0].ApprovedTools {
|
|
pinned[tool.Name] = true
|
|
if tool.SchemaDigest == "" {
|
|
t.Fatalf("approved tool %q was pinned without a schema digest", tool.Name)
|
|
}
|
|
}
|
|
// The third tool was discovered and deliberately not ticked. If it rode
|
|
// along, the approval step would be decorative.
|
|
if len(pinned) != 2 || !pinned["query_timeseries"] || !pinned["list_alerts"] {
|
|
t.Fatalf("pinned tools = %v, want exactly the two that were approved", pinned)
|
|
}
|
|
}
|
|
|
|
// serveMetricsMCP mirrors examples/plugins/deploy-sentinel/server/metrics-mcp.mjs.
|
|
// The .mjs file is what a person runs by hand; this is what CI runs, so the two
|
|
// must offer the same tools.
|
|
func serveMetricsMCP(w http.ResponseWriter, r *http.Request) {
|
|
var request struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
send := func(result any) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": result})
|
|
}
|
|
switch request.Method {
|
|
case "initialize":
|
|
send(map[string]any{
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": "deploy-sentinel-metrics", "version": "1.0.0"},
|
|
})
|
|
case "notifications/initialized":
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case "tools/list":
|
|
send(map[string]any{"tools": []map[string]any{
|
|
{"name": "query_timeseries", "description": "Query a metric over a time window.",
|
|
"inputSchema": map[string]any{"type": "object", "required": []string{"metric", "window_minutes"}}},
|
|
{"name": "list_alerts", "description": "Alerts that fired in a window.",
|
|
"inputSchema": map[string]any{"type": "object"}},
|
|
{"name": "service_dependencies", "description": "Which services this one calls.",
|
|
"inputSchema": map[string]any{"type": "object", "required": []string{"service"}}},
|
|
}})
|
|
default:
|
|
send(map[string]any{})
|
|
}
|
|
}
|