[Build 2025] AI Plugin Bridge (#34216)

* initial commit for POC of Plugin Bridge

* Updates

* POC for plugin bridge

* Updates from collaboration

* Fixes

* Refactor Plugin Bridge to use HTTP/REST instead of RPC

- Remove ExecuteBridgeCall hook and Context.SourcePluginId
- Implement HTTP-based bridge using existing PluginHTTP infrastructure
- Add CallPlugin API method with endpoint parameter instead of method name
- Update CallPluginBridge to construct HTTP POST requests
- Add proper headers: Mattermost-User-Id, Mattermost-Plugin-ID
- Use 'com.mattermost.server' as plugin ID for core server calls
- Update ai.go to use REST endpoint /inter-plugin/v1/completion
- Add comprehensive spec documentation in server/spec.md
- Add MIGRATION_GUIDE.md for plugin developers
- Fix 401/404 issues by setting correct headers and URL paths

* Improve Plugin Bridge security and architecture

- Create ServeInternalPluginRequest for internal plugin calls (core + plugin-to-plugin)
- Move header-setting logic from CallPluginBridge to ServeInternalPluginRequest
- Improve separation of concerns: business logic vs HTTP transport
- Add security documentation explaining header protection

Security Improvements:
- ServeInternalPluginRequest is NOT exposed as HTTP route (internal only)
- Headers (Mattermost-User-Id, Mattermost-Plugin-ID) are set by trusted server code
- External requests cannot spoof these headers (stripped by servePluginRequest)
- Core calls use 'com.mattermost.server' as plugin ID for authorization
- Plugin-to-plugin calls use real plugin ID (enforced by server)

Backward Compatibility:
- Keep ServeInterPluginRequest for existing API.PluginHTTP callers (deprecated)
- All tests pass

Docs:
- Update spec.md with security model explanation
- Update MIGRATION_GUIDE.md with correct header usage examples

* Space

* cursor please stop creating markdown files

* Fix style

* Fix i18n, linter

* REMOVE MARKDOWN

* Remove CallPlugin method from plugin API interface

Per review feedback, this method is no longer needed.

Co-authored-by: Nick Misasi <[email protected]>

* Remove CallPlugin method implementation from PluginAPI

Co-authored-by: Nick Misasi <[email protected]>

* fixes

* Add AI OpenAPI spec

* fix openapi spec

* Use agents client (#34225)

* Use agents client

* Remove default agent

* Fixes

* fix: modify system prompts to ensure JSON is being returned

* remove webapp changes

* Add feature flags for rewrites and ai bridge, clean up

* Remove comments that aren't helpful

* Fix i18n

* Remove rewrites

* Fix tests

* Fix i18n

* adjust i18n again

* Add back translations

* Remove leftover mock code

* remove model file

* Make the real substitutions

* Include a basic invokation of the client with noop to ensure build works

* Remove unneeded change

* Updates from review

* Fixes

* Use v1.5.0 of agents plugin

---------

Co-authored-by: Nick Misasi <[email protected]>
Co-authored-by: Christopher Speller <[email protected]>
Co-authored-by: Felipe Martin <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Nick Misasi
2025-10-30 19:09:27 +00:00
committed by GitHub
co-authored by Hanzo Dev
parent 3c478c97a6
commit da7a8352f2
5 changed files with 72 additions and 7 deletions
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
)
// getAIClient returns an AI client for making requests to the AI plugin
func (a *App) getAIClient(userID string) *agentclient.Client {
return agentclient.NewClientFromApp(a, userID)
}
// Placeholder - NewClientFromApp needs to be called to initialize the AI client in order to ensure everything lines up from a build perspective, and getAIClient can't be uncalled because of linter
// TODO: Remove once a proper feature actually uses the AI Client
func (a *App) AIClient() error {
aiClient := a.getAIClient("")
if aiClient == nil {
return errors.New("failed to get AI client")
}
return nil
}
+34 -7
View File
@@ -49,10 +49,19 @@ func (ch *Channels) ServePluginRequest(w http.ResponseWriter, r *http.Request) {
ch.servePluginRequest(w, r, hooks.ServeHTTP)
}
func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) {
// ServeInternalPluginRequest handles internal requests to plugins from either core server or other plugins.
// This is used by the Plugin Bridge to route requests with proper authentication headers.
//
// Parameters:
// - userID: User ID to set in the authentication header (empty string if no user context)
// - w: HTTP response writer
// - r: HTTP request (should have URL path set to the endpoint, NOT including plugin ID)
// - sourcePluginID: ID of calling plugin (empty string if from core)
// - targetPluginID: ID of target plugin to call
func (a *App) ServeInternalPluginRequest(userID string, w http.ResponseWriter, r *http.Request, sourcePluginID, targetPluginID string) {
pluginsEnvironment := a.ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
appErr := model.NewAppError("ServeInterPluginRequest", "app.plugin.disabled.app_error", nil, "Plugin environment not found.", http.StatusNotImplemented)
appErr := model.NewAppError("ServeInternalPluginRequest", "app.plugin.disabled.app_error", nil, "Plugin environment not found.", http.StatusNotImplemented)
a.Log().Error(appErr.Error())
w.WriteHeader(appErr.StatusCode)
w.Header().Set("Content-Type", "application/json")
@@ -62,11 +71,11 @@ func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, so
return
}
hooks, err := pluginsEnvironment.HooksForPlugin(destinationPluginId)
hooks, err := pluginsEnvironment.HooksForPlugin(targetPluginID)
if err != nil {
a.Log().Error("Access to route for non-existent plugin in inter plugin request",
mlog.String("source_plugin_id", sourcePluginId),
mlog.String("destination_plugin_id", destinationPluginId),
a.Log().Error("Access to route for non-existent plugin in internal plugin request",
mlog.String("source_plugin_id", sourcePluginID),
mlog.String("target_plugin_id", targetPluginID),
mlog.String("url", r.URL.String()),
mlog.Err(err),
)
@@ -79,11 +88,29 @@ func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, so
UserAgent: r.UserAgent(),
}
r.Header.Set("Mattermost-Plugin-ID", sourcePluginId)
// Set authentication headers - these are trusted because this function is internal
// and not exposed to external HTTP routes
r.Header.Set("Mattermost-User-Id", userID)
// Set plugin ID header to identify the caller
// Use a special ID for core server calls to distinguish them from plugin-to-plugin calls
if sourcePluginID != "" {
r.Header.Set("Mattermost-Plugin-ID", sourcePluginID)
} else {
// Core server call - use special identifier
r.Header.Set("Mattermost-Plugin-ID", "com.mattermost.server")
}
hooks.ServeHTTP(context, w, r)
}
// ServeInterPluginRequest handles inter-plugin HTTP requests.
// This function does not set user authentication headers, unlike ServeInternalPluginRequest.
func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) {
// Call ServeInternalPluginRequest with empty userID since this function doesn't handle user authentication
a.ServeInternalPluginRequest("", w, r, sourcePluginId, destinationPluginId)
}
// ServePluginPublicRequest serves public plugin files
// at the URL http(s)://$SITE_URL/plugins/$PLUGIN_ID/public/{anything}
func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -139,6 +139,7 @@ require (
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect
github.com/mattermost/mattermost-plugin-ai v1.5.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+6
View File
@@ -380,6 +380,12 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI=
github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4=
github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s=
github.com/mattermost/mattermost-plugin-ai v1.4.1-0.20251021135930-4d1083cef23e h1:sl1hyq4YJMwefpi/AittJDxLbv5iuTZDcaqs+sgN6Ps=
github.com/mattermost/mattermost-plugin-ai v1.4.1-0.20251021135930-4d1083cef23e/go.mod h1:sgR9+nLFCjYSE9vlqxLZxHZ+6Kz2NJw9Qko+ywVX2k0=
github.com/mattermost/mattermost-plugin-ai v1.4.1-0.20251029144844-4c2eb7a5feb9 h1:d+ZdIU2saRH09BOnHGWXBjw9LbKOOqzcQJISQ6eOM8g=
github.com/mattermost/mattermost-plugin-ai v1.4.1-0.20251029144844-4c2eb7a5feb9/go.mod h1:sgR9+nLFCjYSE9vlqxLZxHZ+6Kz2NJw9Qko+ywVX2k0=
github.com/mattermost/mattermost-plugin-ai v1.5.0 h1:64P8CadbrglgiQMiYqE9kZngrvIb5Ze7Jv+iK832RbI=
github.com/mattermost/mattermost-plugin-ai v1.5.0/go.mod h1:sgR9+nLFCjYSE9vlqxLZxHZ+6Kz2NJw9Qko+ywVX2k0=
github.com/mattermost/mattermost/server/public v0.1.20 h1:N39ZOyYvCVZXABetpb0tVrm/08Od3q7d/D45QZjJ63s=
github.com/mattermost/mattermost/server/public v0.1.20/go.mod h1:ZSdLCHIFYDTmX5FNCJ7LoI2CLZ1rQqZcfJ3ZF81QadY=
github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw=
+6
View File
@@ -82,6 +82,9 @@ type FeatureFlags struct {
// FEATURE_FLAG_REMOVAL: AutoTranslation - Remove this when MVP is to be released
// Enable auto-translation feature for messages in channels
AutoTranslation bool
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge
EnableAIPluginBridge bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -118,6 +121,9 @@ func (f *FeatureFlags) SetDefaults() {
// FEATURE_FLAG_REMOVAL: AutoTranslation - Remove this default when MVP is to be released
f.AutoTranslation = false
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released
f.EnableAIPluginBridge = false
}
// ToMap returns the feature flags as a map[string]string