diff --git a/internal/plugin/contributions.go b/internal/plugin/contributions.go index c4f8ece..37f062f 100644 --- a/internal/plugin/contributions.go +++ b/internal/plugin/contributions.go @@ -147,12 +147,6 @@ func (c InterfaceContract) EffectiveBacking() string { return c.Backing } -// DeclaresBacking reports whether the manifest said who serves this service, rather -// than leaving the host to infer it. -func (c InterfaceContract) DeclaresBacking() bool { - return c.Backing != "" -} - func (c InterfaceContract) MarshalJSON() ([]byte, error) { type stringMethods struct { Service string `json:"service"` @@ -297,6 +291,14 @@ func validateContributions(m Manifest) error { if c.Backing != "" && m.Schema != ManifestSchemaV2 { return fmt.Errorf("interface %q backing requires manifest schema v2", c.Service) } + // A v2 manifest must say who serves each method. Leaving it out is what let a + // plugin declare methods its own artifact could not answer while core quietly + // answered them instead. Rejecting the manifest outright is louder, and safer, + // than resolving it by inference and failing somewhere further downstream. + if m.Schema == ManifestSchemaV2 && c.Backing == "" { + return fmt.Errorf("interface %q must declare backing (%q or %q)", + c.Service, BackingRuntime, BackingCore) + } // Declaring that core serves a method is a claim on the host's own trust base, // so it is confined to system plugins. Every v2 manifest already requires a // trusted-publisher signature, so the declaration is signed by construction. diff --git a/internal/plugin/manifest_v2_test.go b/internal/plugin/manifest_v2_test.go index 5809727..8132496 100644 --- a/internal/plugin/manifest_v2_test.go +++ b/internal/plugin/manifest_v2_test.go @@ -40,6 +40,7 @@ func validManifestV2() Manifest { }, Interfaces: []InterfaceContract{{ Service: "latticenet.example/items", + Backing: BackingRuntime, MethodSpecs: []InterfaceMethod{ {Name: "list", Effect: InterfaceEffectRead, Scopes: []string{"proxy:read"}}, {Name: "save", Effect: InterfaceEffectWrite, Scopes: []string{"proxy:admin"}}, @@ -149,7 +150,8 @@ func TestManifestV2RejectsLegacyAndIncompleteContracts(t *testing.T) { {"write without method scopes", func(m *Manifest) { m.Interfaces[0].MethodSpecs[1].Scopes = nil }, "method scopes"}, {"legacy string methods", func(m *Manifest) { m.Interfaces[0] = InterfaceContract{ - Service: "latticenet.example/items", Methods: []string{"list"}, Scopes: []string{"proxy:read"}, + Service: "latticenet.example/items", Backing: BackingRuntime, + Methods: []string{"list"}, Scopes: []string{"proxy:read"}, } }, "typed method"}, {"duplicate interface service", func(m *Manifest) { @@ -387,7 +389,7 @@ func TestBackingValidation(t *testing.T) { } } - for _, valid := range []string{"", BackingRuntime, BackingCore} { + for _, valid := range []string{BackingRuntime, BackingCore} { if err := ValidateManifest(newManifest(TypeSystem, valid)); err != nil { t.Fatalf("backing %q should be valid for a system plugin: %v", valid, err) } @@ -395,6 +397,12 @@ func TestBackingValidation(t *testing.T) { if err := ValidateManifest(newManifest(TypeSystem, "wasm")); err == nil { t.Fatal("an unknown backing must be rejected") } + // A v2 manifest that says nothing about who serves a method is what let a plugin + // declare methods its artifact could not answer. It is now rejected outright rather + // than resolved by inference. + if err := ValidateManifest(newManifest(TypeSystem, "")); err == nil { + t.Fatal("a v2 interface without a backing declaration must be rejected") + } // Claiming core is a claim on the host's own trust base. if err := ValidateManifest(newManifest(TypeWasm, BackingCore)); err == nil { t.Fatal("a non-system plugin must not declare core backing") diff --git a/internal/server/server.go b/internal/server/server.go index 2827cce..2e75cee 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,10 +178,6 @@ type Server struct { // party in-core providers register services on it; plugins reach it through // the capability-scoped broker (HostServices.RPC). pluginRPC *plugin.RPCRegistry - - // undeclaredBackingOnce tracks which legacy services have already been reported - // as core-backed-by-inference, so the warning names each one exactly once. - undeclaredBackingOnce sync.Map // pluginTrust is the operator policy used by both startup loading and // pre-install verification endpoints. It is intentionally not client supplied. pluginTrust plugin.TrustPolicy diff --git a/internal/server/server_plugin_assets_test.go b/internal/server/server_plugin_assets_test.go index ef08404..d430802 100644 --- a/internal/server/server_plugin_assets_test.go +++ b/internal/server/server_plugin_assets_test.go @@ -79,6 +79,7 @@ func newPluginAssetTestServer(t *testing.T) (*Server, http.Handler, []*http.Cook Compatibility: &plugin.CompatibilitySpec{Server: ">=0.2.1", DashboardHost: ">=1", RuntimeProtocol: ">=1"}, Interfaces: []plugin.InterfaceContract{{ Service: "test.assets/items", + Backing: plugin.BackingRuntime, MethodSpecs: []plugin.InterfaceMethod{{Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}}}, }}, UI: &plugin.ManifestUI{ diff --git a/internal/server/server_plugin_invoke.go b/internal/server/server_plugin_invoke.go index ed7db28..1e66135 100644 --- a/internal/server/server_plugin_invoke.go +++ b/internal/server/server_plugin_invoke.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net/http" "net/url" "strconv" @@ -277,17 +276,17 @@ func (s *Server) handlePluginCall(w http.ResponseWriter, r *http.Request, p prin // in-core handler, so a manifest could declare methods its own artifact could not serve // and no operator could see the difference. // -// Backing is now a per-service declaration inside the signed manifest, and dispatch -// follows it exactly: +// Backing is a per-service declaration inside the signed manifest, and dispatch follows +// it exactly: // // - backing "core": a core provider owned by this plugin must exist. If it does not, // the call fails — a manifest cannot name core as its backend and have the host // quietly find something else. // - backing "runtime": the artifact serves it, and core never answers in its place. // This is what closes the silent-fallback hole. -// - undeclared: manifests signed before the field existed. Resolved through the legacy -// inference, logged once per service so the remaining ones are visible, and refused -// the moment a re-signed manifest declares its backing. +// +// There is no third case. A v2 manifest that declares no backing is rejected at load +// (see validateInterfaces), so the host never has to guess who owns a method. func (s *Server) dispatchV2PluginCall( ctx context.Context, loaded plugin.Loaded, @@ -301,17 +300,6 @@ func (s *Server) dispatchV2PluginCall( } coreOwns := s.pluginRPC != nil && s.pluginRPC.Owns(pluginID, service) - if !contract.DeclaresBacking() { - // Reproduce the pre-backing inference exactly, publisher constraint included, so - // an already-signed manifest keeps working unchanged. A third party still cannot - // reach a core provider by naming a service core happens to own. - if coreOwns && loaded.Manifest.Publisher == trustedCorePublisher { - s.warnUndeclaredBacking(pluginID, service) - return s.pluginRPC.CallOperator(ctx, service, method, []byte(payload)) - } - return s.callRuntimePluginService(ctx, pluginID, service, method, payload, operatorTargets) - } - switch contract.EffectiveBacking() { case plugin.BackingCore: if !coreOwns { @@ -330,21 +318,6 @@ func (s *Server) dispatchV2PluginCall( } } -// warnUndeclaredBacking logs each legacy core-backed service once, so the set of -// manifests still relying on inference is visible rather than silent. -func (s *Server) warnUndeclaredBacking(pluginID, service string) { - if _, alreadyWarned := s.undeclaredBackingOnce.LoadOrStore(pluginID+"/"+service, true); alreadyWarned { - return - } - const format = "plugin gateway: %s does not declare backing for %q; core is answering it by inference. " + - "Re-sign the manifest with \"backing\":\"core\" — inference will be removed." - if s.logger != nil { - s.logger.Printf(format, pluginID, service) - return - } - log.Printf(format, pluginID, service) -} - func (s *Server) callRuntimePluginService(ctx context.Context, pluginID, service, method string, payload json.RawMessage, operatorTargets []string) ([]byte, error) { if s.pluginRuntime == nil { return nil, errors.New("plugin runtime unavailable") @@ -534,11 +507,6 @@ var diagnosticPluginActions = map[string]bool{ "health": true, } -// trustedCorePublisher is the publisher whose manifests may be inferred as core-backed -// while they predate the explicit `backing` declaration. It gates only the legacy path; -// a declared backing is authorized by the manifest signature itself. -const trustedCorePublisher = "latticenet" - // handlePluginInvoke runs one DIAGNOSTIC action on an ACTIVE plugin via the // runtime (the Tier-2 system runner execs the artifact's {action,payload}-> // {ok,result} protocol). It exists so an operator can interrogate a staged diff --git a/internal/server/server_plugin_invoke_test.go b/internal/server/server_plugin_invoke_test.go index aafe9e8..8f580b7 100644 --- a/internal/server/server_plugin_invoke_test.go +++ b/internal/server/server_plugin_invoke_test.go @@ -275,6 +275,7 @@ func TestPluginCallV2UsesExactMethodScopes(t *testing.T) { Schema: plugin.ManifestSchemaV2, ID: "test.v2", Name: "V2", Type: plugin.TypeSystem, Interfaces: []plugin.InterfaceContract{{ Service: "test.v2/items", + Backing: plugin.BackingRuntime, MethodSpecs: []plugin.InterfaceMethod{ {Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}}, {Name: "save", Effect: plugin.InterfaceEffectWrite, Scopes: []string{"proxy:admin"}, OperatorTargetFields: []string{"base_url"}}, @@ -322,6 +323,7 @@ func TestPluginCallV2DispatchesOwnedCoreService(t *testing.T) { Publisher: "latticenet", Interfaces: []plugin.InterfaceContract{{ Service: "test.v2-owned/items", + Backing: plugin.BackingCore, MethodSpecs: []plugin.InterfaceMethod{{ Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}, }}, @@ -368,6 +370,7 @@ func TestPluginCallV2DoesNotDispatchCoreServiceForForeignPublisher(t *testing.T) Publisher: "other", Interfaces: []plugin.InterfaceContract{{ Service: "test.v2-foreign/items", + Backing: plugin.BackingRuntime, MethodSpecs: []plugin.InterfaceMethod{{ Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}, }},