Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f0cc20bbf | ||
|
|
f14846d758 | ||
|
|
e2dcbc1fc3 | ||
|
|
731d6b51d1 |
@ -246,6 +246,9 @@ message BridgeCallRequest {
|
||||
string service_name = 1;
|
||||
string method = 2;
|
||||
bytes payload = 3;
|
||||
// Host-authenticated identity of the consumer plugin. The host derives this
|
||||
// from the loaded caller; providers must not trust identity in payload.
|
||||
string caller_plugin = 4;
|
||||
}
|
||||
|
||||
message BridgeCallResponse {
|
||||
|
||||
@ -1346,10 +1346,13 @@ func (x *AiToolCallResponse) GetErrorMessage() string {
|
||||
// capability; payload encoding is agreed between the two plugins (JSON by
|
||||
// convention).
|
||||
type BridgeCallRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
||||
Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`
|
||||
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
||||
Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`
|
||||
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
// Host-authenticated identity of the consumer plugin. The host derives this
|
||||
// from the loaded caller; providers must not trust identity in payload.
|
||||
CallerPlugin string `protobuf:"bytes,4,opt,name=caller_plugin,json=callerPlugin,proto3" json:"caller_plugin,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -1405,6 +1408,13 @@ func (x *BridgeCallRequest) GetPayload() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *BridgeCallRequest) GetCallerPlugin() string {
|
||||
if x != nil {
|
||||
return x.CallerPlugin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type BridgeCallResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
@ -1842,11 +1852,12 @@ const file_v1_invoke_proto_rawDesc = "" +
|
||||
"paramsJson\"S\n" +
|
||||
"\x12AiToolCallResponse\x12\x18\n" +
|
||||
"\acontent\x18\x01 \x01(\tR\acontent\x12#\n" +
|
||||
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"h\n" +
|
||||
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x8d\x01\n" +
|
||||
"\x11BridgeCallRequest\x12!\n" +
|
||||
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" +
|
||||
"\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" +
|
||||
"\apayload\x18\x03 \x01(\fR\apayload\".\n" +
|
||||
"\apayload\x18\x03 \x01(\fR\apayload\x12#\n" +
|
||||
"\rcaller_plugin\x18\x04 \x01(\tR\fcallerPlugin\".\n" +
|
||||
"\x12BridgeCallResponse\x12\x18\n" +
|
||||
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
|
||||
"\x1cDirectoryPanelSectionRequest\x12\x14\n" +
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -97,10 +98,8 @@ func TestCaptchaVerifiedReadsTrustedHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) {
|
||||
for _, h := range AllTrustedHeaders() {
|
||||
if h == HeaderVerifiedCaptcha {
|
||||
return
|
||||
}
|
||||
if slices.Contains(AllTrustedHeaders(), HeaderVerifiedCaptcha) {
|
||||
return
|
||||
}
|
||||
t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha)
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Plugin manifests declare admin API compatibility
|
||||
|
||||
Plugin admin bundles consume the versioned `@block-ninja/api` browser package.
|
||||
The existing `block_core` compatibility constraint describes the host plugin
|
||||
runtime, but it cannot tell a registry or CMS whether an admin bundle is safe
|
||||
to load against the host's browser API. Treating those surfaces as one version
|
||||
would couple independent release cycles and allow an otherwise compatible
|
||||
plugin to fail only after its admin page loads.
|
||||
|
||||
Decision: `[compatibility]` gains an optional `admin_api` string. Its value is
|
||||
a semantic-version constraint for the host-provided browser admin API, for
|
||||
example `admin_api = ">=0.1.2"`. The manifest/parser layer preserves the value
|
||||
verbatim; registry and host resolvers own constraint validation and matching.
|
||||
|
||||
An omitted or empty value means the plugin has not declared browser API
|
||||
compatibility. It is not equivalent to an unconstrained wildcard. Automated
|
||||
latest-compatible resolution that filters by a host admin API version must
|
||||
therefore fail closed for an undeclared value. This does not change the
|
||||
separate `block_core` constraint or imply that every plugin has an admin
|
||||
bundle.
|
||||
|
||||
The field is part of the shared manifest model and the CLI's hand-written
|
||||
`plugin.mod` serializer so version bumps, tag edits, and initialization cannot
|
||||
silently discard it.
|
||||
|
||||
Consequences:
|
||||
|
||||
- Registries and hosts can select plugin releases compatible with both the
|
||||
runtime ABI and browser admin API.
|
||||
- Headless plugins can continue to omit `admin_api`.
|
||||
- Plugin authors express a range rather than pinning a single browser package
|
||||
version.
|
||||
- Constraint syntax and matching remain the resolver's responsibility rather
|
||||
than being duplicated in manifest parsers.
|
||||
|
||||
Keywords: plugin.mod, compatibility, admin_api, AdminAPI, @block-ninja/api,
|
||||
semantic version, browser API, fail closed, plugin resolver
|
||||
27
docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md
Normal file
27
docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Plugin manifests declare Plugin SDK compatibility
|
||||
|
||||
The historical `block_core` compatibility field predates the standalone
|
||||
Plugin SDK and is ambiguous: CMS core and the plugin-facing SDK have separate
|
||||
release cycles. Interpreting the same constraint against both versions can
|
||||
select an artifact that the host cannot load or reject one that is compatible.
|
||||
|
||||
Decision: `[compatibility]` gains an optional `plugin_sdk` string containing a
|
||||
semantic-version constraint for the plugin-facing SDK/ABI, for example
|
||||
`plugin_sdk = ">=0.3.7"`. Manifest parsing preserves the value verbatim;
|
||||
registry and host resolvers own constraint validation and matching.
|
||||
|
||||
An omitted or empty value is undeclared compatibility, not a wildcard.
|
||||
Automated resolution against a host SDK version must fail closed, except for
|
||||
an explicitly bounded migration policy for releases published before this
|
||||
field existed. `block_core` remains a separate historical constraint and is
|
||||
not reinterpreted as the CMS application's own version.
|
||||
|
||||
Consequences:
|
||||
|
||||
- Plugins can state the actual SDK/ABI range they require.
|
||||
- CMS core can evolve independently of the public Plugin SDK.
|
||||
- New releases must declare `plugin_sdk` to participate in automatic
|
||||
SDK-compatible selection.
|
||||
|
||||
Keywords: plugin.mod, compatibility, plugin_sdk, PluginSDK, ABI, semantic
|
||||
version, plugin resolver, fail closed
|
||||
@ -0,0 +1,27 @@
|
||||
# Bridge providers receive authenticated caller identity
|
||||
|
||||
Bridge payloads are guest-controlled. A provider that uses a payload field as
|
||||
an ownership namespace lets one plugin impersonate another plugin and mutate
|
||||
or claim its managed records. The consumer-to-host `BridgeInvokeRequest`
|
||||
cannot safely carry identity because the consumer constructs that message.
|
||||
|
||||
Decision: the host derives the caller plugin from the loaded module or native
|
||||
plugin registration. It passes that identity to the provider in
|
||||
`BridgeCallRequest.caller_plugin` and, for Go providers, through
|
||||
`plugin.WithBridgeCallerPlugin`. Providers read it with
|
||||
`plugin.BridgeCallerPlugin` and fail closed when an ownership-sensitive call
|
||||
has no authenticated caller.
|
||||
|
||||
The new host-to-provider protobuf field is additive. Older providers ignore
|
||||
it. New ownership-sensitive providers intentionally reject calls from older
|
||||
hosts that cannot authenticate a caller. Hosts must never copy a caller name
|
||||
from opaque bridge payloads or a consumer-authored capability field.
|
||||
|
||||
Consequences:
|
||||
|
||||
- Providers can derive durable ownership from an authenticated principal.
|
||||
- Bridge payload schemas do not need security-sensitive source fields.
|
||||
- Native and Wasm providers observe the same caller context contract.
|
||||
|
||||
Keywords: plugin bridge, caller identity, authentication, ownership,
|
||||
BridgeCallRequest, WithBridgeCallerPlugin, Wasm, confused deputy
|
||||
@ -0,0 +1,36 @@
|
||||
# Register sees the current guest host services
|
||||
|
||||
Go guest plugins can declare capability-backed runtime surfaces from their
|
||||
`Register` callback. Bridge providers are the first such surface: the provider
|
||||
stores its invokable value in the guest-local bridge stub while the stub tells
|
||||
the CMS host that the named service exists.
|
||||
|
||||
`Serve` previously assigned the package-level guest runtime only after
|
||||
`newGuest` returned. Because `newGuest` runs `Register` synchronously,
|
||||
`HostServices()` returned an empty `CoreServices` value during registration.
|
||||
Plugins that correctly nil-checked the bridge silently skipped registration.
|
||||
The CMS could therefore mark a Wasm plugin loaded while consumers failed with
|
||||
`bridge: no service registered` during a later load hook.
|
||||
|
||||
`newGuest` now makes the guest under construction current for the synchronous
|
||||
registration pass and restores the previous runtime before returning. `Serve`
|
||||
then installs the completed guest as before. This keeps construction isolated
|
||||
for native tests while making the documented `HostServices()` escape hatch
|
||||
truthful during `Register` on every pooled Wasm instance.
|
||||
|
||||
Alternatives rejected were moving bridge registration into `Load`, which runs
|
||||
on only one pooled instance, and adding bridge names only to the manifest,
|
||||
which would advertise host availability without installing the guest-local
|
||||
`BridgeInvokable` value needed by `HOOK_BRIDGE_CALL`.
|
||||
|
||||
Consequences:
|
||||
|
||||
- Bridge services registered from `Register` exist on every pooled guest
|
||||
instance and remain callable after CMS startup and hot swap.
|
||||
- A regression test exercises the observable `Serve` plus
|
||||
`HOOK_BRIDGE_CALL` contract instead of relying on initialization internals.
|
||||
- No ABI or protobuf change is required; consumers need a plugin SDK release
|
||||
containing the corrected Go guest runtime.
|
||||
|
||||
Keywords: wasmguest.Serve, newGuest, HostServices, PluginRegistration.Register,
|
||||
plugin bridge, RegisterService, BridgeInvokable, HOOK_BRIDGE_CALL, wiki content
|
||||
@ -166,12 +166,12 @@ func validHostname(h string) bool {
|
||||
if len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(h, ".") {
|
||||
for label := range strings.SplitSeq(h, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,26 @@ package plugin
|
||||
|
||||
import "context"
|
||||
|
||||
type bridgeCallerPluginContextKey struct{}
|
||||
|
||||
// WithBridgeCallerPlugin records the host-authenticated plugin name of a
|
||||
// bridge caller. Hosts and ABI adapters must derive pluginName from the loaded
|
||||
// caller, never from guest-controlled request data.
|
||||
func WithBridgeCallerPlugin(ctx context.Context, pluginName string) context.Context {
|
||||
return context.WithValue(ctx, bridgeCallerPluginContextKey{}, pluginName)
|
||||
}
|
||||
|
||||
// BridgeCallerPlugin returns the host-authenticated plugin name of the
|
||||
// current bridge caller. Ownership-sensitive providers should fail closed
|
||||
// when no caller identity is present.
|
||||
func BridgeCallerPlugin(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
pluginName, ok := ctx.Value(bridgeCallerPluginContextKey{}).(string)
|
||||
return pluginName, ok && pluginName != ""
|
||||
}
|
||||
|
||||
// PluginBridge allows plugins to share services with each other.
|
||||
// Plugins register named services during startup; other plugins look them up at runtime.
|
||||
//
|
||||
|
||||
25
plugin/bridge_test.go
Normal file
25
plugin/bridge_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBridgeCallerPlugin(t *testing.T) {
|
||||
if got, ok := BridgeCallerPlugin(context.Background()); ok || got != "" {
|
||||
t.Fatalf("BridgeCallerPlugin(background) = %q, %t, want empty, false", got, ok)
|
||||
}
|
||||
|
||||
ctx := WithBridgeCallerPlugin(context.Background(), "website")
|
||||
if got, ok := BridgeCallerPlugin(ctx); !ok || got != "website" {
|
||||
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithBridgeCallerPluginEmptyDoesNotAuthenticateCaller(t *testing.T) {
|
||||
ctx := WithBridgeCallerPlugin(context.Background(), "spoofed")
|
||||
ctx = WithBridgeCallerPlugin(ctx, "")
|
||||
if got, ok := BridgeCallerPlugin(ctx); ok || got != "" {
|
||||
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want empty, false", got, ok)
|
||||
}
|
||||
}
|
||||
@ -91,6 +91,8 @@ type ModPublicRoute struct {
|
||||
|
||||
type ModCompat struct {
|
||||
BlockCore string `toml:"block_core"`
|
||||
AdminAPI string `toml:"admin_api"`
|
||||
PluginSDK string `toml:"plugin_sdk"`
|
||||
}
|
||||
|
||||
type ModRequirement struct {
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tomlpkg "github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
func TestParseModFull_BasicFields(t *testing.T) {
|
||||
@ -278,6 +281,8 @@ version = "0.2.0"
|
||||
|
||||
[compatibility]
|
||||
block_core = ">=1.5 <2.0"
|
||||
admin_api = ">=0.1.2 <0.2.0"
|
||||
plugin_sdk = ">=0.3.7 <0.4.0"
|
||||
|
||||
[[requires]]
|
||||
name = "@blockninja/smartblock"
|
||||
@ -291,8 +296,17 @@ version = ">=1.2"
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull err: %v", err)
|
||||
}
|
||||
if m.Compatibility == nil || m.Compatibility.BlockCore != ">=1.5 <2.0" {
|
||||
t.Errorf("Compat = %+v", m.Compatibility)
|
||||
if m.Compatibility == nil {
|
||||
t.Fatal("Compatibility is nil")
|
||||
}
|
||||
if m.Compatibility.BlockCore != ">=1.5 <2.0" {
|
||||
t.Errorf("Compat.BlockCore = %q", m.Compatibility.BlockCore)
|
||||
}
|
||||
if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" {
|
||||
t.Errorf("Compat.AdminAPI = %q", m.Compatibility.AdminAPI)
|
||||
}
|
||||
if m.Compatibility.PluginSDK != ">=0.3.7 <0.4.0" {
|
||||
t.Errorf("Compat.PluginSDK = %q", m.Compatibility.PluginSDK)
|
||||
}
|
||||
if len(m.Requires) != 2 {
|
||||
t.Fatalf("Requires len = %d, want 2", len(m.Requires))
|
||||
@ -305,6 +319,33 @@ version = ">=1.2"
|
||||
}
|
||||
}
|
||||
|
||||
func TestModCompatPluginSDKRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := &ModFile{
|
||||
Plugin: ModPlugin{Name: "wiki", Version: "0.2.0"},
|
||||
Compatibility: &ModCompat{
|
||||
BlockCore: ">=0.3.4",
|
||||
AdminAPI: ">=0.1.2",
|
||||
PluginSDK: ">=0.3.7 <0.4.0",
|
||||
},
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := tomlpkg.NewEncoder(&encoded).Encode(want); err != nil {
|
||||
t.Fatalf("encode plugin.mod: %v", err)
|
||||
}
|
||||
if !strings.Contains(encoded.String(), `plugin_sdk = ">=0.3.7 <0.4.0"`) {
|
||||
t.Fatalf("encoded plugin.mod omitted plugin_sdk:\n%s", encoded.String())
|
||||
}
|
||||
got, err := ParseModFull(encoded.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull(round trip): %v", err)
|
||||
}
|
||||
if got.Compatibility == nil || got.Compatibility.PluginSDK != want.Compatibility.PluginSDK {
|
||||
t.Fatalf("round-trip PluginSDK = %#v, want %q", got.Compatibility, want.Compatibility.PluginSDK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_HappyPath(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
|
||||
if err != nil {
|
||||
|
||||
@ -68,7 +68,7 @@ func checkRoutePath(r ModPublicRoute) string {
|
||||
if strings.HasSuffix(p, "/") {
|
||||
return "no trailing slash; set prefix = true to claim the subtree"
|
||||
}
|
||||
for _, seg := range strings.Split(p[1:], "/") {
|
||||
for seg := range strings.SplitSeq(p[1:], "/") {
|
||||
switch {
|
||||
case seg == "":
|
||||
return "empty path segment"
|
||||
|
||||
@ -100,7 +100,15 @@ func newGuest(reg plugin.PluginRegistration) *guest {
|
||||
// Pool whose calls fail cleanly, matching the capability stubs.
|
||||
g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport))
|
||||
g.rag, _ = g.services.RAGService.(*caps.RAGStub)
|
||||
// Register callbacks may use HostServices for capability-backed surfaces
|
||||
// that are not passed as Register parameters, such as bridge services.
|
||||
// Publish this partially initialized guest only for the synchronous
|
||||
// registration pass, then restore the previous runtime. Serve installs g
|
||||
// permanently after newGuest returns.
|
||||
previous := current
|
||||
current = g
|
||||
g.registerErr = runRegister(reg, g.templates, g.blocks)
|
||||
current = previous
|
||||
return g
|
||||
}
|
||||
|
||||
@ -713,6 +721,7 @@ func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message,
|
||||
Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable",
|
||||
}
|
||||
}
|
||||
ctx = plugin.WithBridgeCallerPlugin(ctx, req.GetCallerPlugin())
|
||||
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
|
||||
if err != nil {
|
||||
return nil, internalError(err.Error())
|
||||
|
||||
@ -25,6 +25,12 @@ func (c textComponent) Render(_ context.Context, w io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type bridgeInvokableFunc func(context.Context, string, []byte) ([]byte, error)
|
||||
|
||||
func (f bridgeInvokableFunc) InvokeBridge(ctx context.Context, method string, payload []byte) ([]byte, error) {
|
||||
return f(ctx, method, payload)
|
||||
}
|
||||
|
||||
func fixtureRegistration() plugin.PluginRegistration {
|
||||
return plugin.PluginRegistration{
|
||||
Name: "fixture",
|
||||
@ -124,6 +130,76 @@ func TestServeInstallsRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeExposesHostServicesDuringRegister(t *testing.T) {
|
||||
previous := current
|
||||
t.Cleanup(func() { current = previous })
|
||||
|
||||
Serve(plugin.PluginRegistration{
|
||||
Name: "bridge-provider",
|
||||
Version: "0.1.0",
|
||||
Register: func(_ templates.TemplateRegistry, _ blocks.BlockRegistry) error {
|
||||
bridge := HostServices().Bridge
|
||||
if bridge == nil {
|
||||
return fmt.Errorf("HostServices bridge is unavailable during Register")
|
||||
}
|
||||
bridge.RegisterService("bridge-provider", "content", bridgeInvokableFunc(
|
||||
func(_ context.Context, _ string, _ []byte) ([]byte, error) {
|
||||
return []byte("registered"), nil
|
||||
},
|
||||
))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
resp := invokeHook(t, current, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
|
||||
ServiceName: "content",
|
||||
Method: "apply",
|
||||
})
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("bridge call returned error: %v", resp.GetError())
|
||||
}
|
||||
bridgeResp := &abiv1.BridgeCallResponse{}
|
||||
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
|
||||
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
|
||||
}
|
||||
if got := string(bridgeResp.GetPayload()); got != "registered" {
|
||||
t.Fatalf("bridge payload = %q, want registered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeCallProvidesAuthenticatedCallerContext(t *testing.T) {
|
||||
g := newGuest(fixtureRegistration())
|
||||
g.services.Bridge.RegisterService("fixture", "content", bridgeInvokableFunc(
|
||||
func(ctx context.Context, method string, payload []byte) ([]byte, error) {
|
||||
caller, ok := plugin.BridgeCallerPlugin(ctx)
|
||||
if !ok || caller != "website" {
|
||||
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", caller, ok)
|
||||
}
|
||||
if method != "ApplyBundle" || string(payload) != `{"revision":"1"}` {
|
||||
t.Fatalf("bridge call = %q %q", method, payload)
|
||||
}
|
||||
return []byte(`{"created":1}`), nil
|
||||
},
|
||||
))
|
||||
|
||||
resp := invokeHook(t, g, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
|
||||
ServiceName: "content",
|
||||
Method: "ApplyBundle",
|
||||
Payload: []byte(`{"revision":"1"}`),
|
||||
CallerPlugin: "website",
|
||||
})
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("bridge call returned error: %v", resp.GetError())
|
||||
}
|
||||
bridgeResp := &abiv1.BridgeCallResponse{}
|
||||
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
|
||||
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
|
||||
}
|
||||
if got := string(bridgeResp.GetPayload()); got != `{"created":1}` {
|
||||
t.Fatalf("bridge payload = %q, want report", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeManifest(t *testing.T) {
|
||||
g := newGuest(fixtureRegistration())
|
||||
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
|
||||
|
||||
@ -74,8 +74,8 @@ func methodDocumentation(method protoreflect.MethodDescriptor) (description, doc
|
||||
}
|
||||
paragraph = strings.TrimSpace(strings.ReplaceAll(paragraph, "\n", " "))
|
||||
methodName := string(method.Name())
|
||||
if strings.HasPrefix(paragraph, methodName+" ") {
|
||||
paragraph = strings.TrimSpace(strings.TrimPrefix(paragraph, methodName+" "))
|
||||
if after, ok := strings.CutPrefix(paragraph, methodName+" "); ok {
|
||||
paragraph = strings.TrimSpace(after)
|
||||
if paragraph != "" {
|
||||
runes := []rune(paragraph)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
|
||||
@ -65,30 +65,30 @@ func registerMCPFixture(t *testing.T) {
|
||||
return
|
||||
}
|
||||
file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{
|
||||
Name: proto.String("test/mcpfixture.proto"),
|
||||
Package: proto.String("mcpfixture.v1"),
|
||||
Syntax: proto.String("proto3"),
|
||||
Name: new("test/mcpfixture.proto"),
|
||||
Package: new("mcpfixture.v1"),
|
||||
Syntax: new("proto3"),
|
||||
Dependency: []string{"google/protobuf/timestamp.proto"},
|
||||
MessageType: []*descriptorpb.DescriptorProto{{
|
||||
Name: proto.String("ListArticlesRequest"),
|
||||
Name: new("ListArticlesRequest"),
|
||||
Field: []*descriptorpb.FieldDescriptorProto{
|
||||
{Name: proto.String("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()},
|
||||
{Name: proto.String("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()},
|
||||
{Name: proto.String("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".google.protobuf.Timestamp")},
|
||||
{Name: new("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()},
|
||||
{Name: new("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()},
|
||||
{Name: new("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: new(".google.protobuf.Timestamp")},
|
||||
},
|
||||
}, {
|
||||
Name: proto.String("ListArticlesResponse"),
|
||||
Name: new("ListArticlesResponse"),
|
||||
}},
|
||||
Service: []*descriptorpb.ServiceDescriptorProto{{
|
||||
Name: proto.String("WikiService"),
|
||||
Name: new("WikiService"),
|
||||
Method: []*descriptorpb.MethodDescriptorProto{{
|
||||
Name: proto.String("ListArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"),
|
||||
Name: new("ListArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"),
|
||||
}, {
|
||||
Name: proto.String("StreamArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: proto.Bool(true),
|
||||
Name: new("StreamArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: new(true),
|
||||
}},
|
||||
}},
|
||||
SourceCodeInfo: &descriptorpb.SourceCodeInfo{Location: []*descriptorpb.SourceCodeInfo_Location{{
|
||||
Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: proto.String("ListArticles lists Wiki articles.\n\nSupports pagination."),
|
||||
Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: new("ListArticles lists Wiki articles.\n\nSupports pagination."),
|
||||
}}},
|
||||
}, protoregistry.GlobalFiles)
|
||||
if err != nil {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user