fix(connectors): create credentials-only bindings, answer 403 on denial, allow 50 bindings

Creating a binding with no agent (home-assistant, jellyfin, pikvm,
github) failed with a 500. The token check for a new binding looked up
the agent even when the binding had none, so the host refused it. The
check now looks up an agent only when the binding has one. The token is
still required and a foreign agent is still refused.

A host denial (connectors.ErrForbidden) reached the client as a 500
server_error. The connector endpoints now answer 403 with a fixed text
that names no part of the check.

A person could have 10 bindings. Several bots plus one credential
binding per service need more, so the limit is now 50, like the limit
of resources and triggers. The docs and the API description say so.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 09:42:54 +00:00
1 parent ed8447e539
commit d4c268b644
9 files changed
+101 -7

No files matched your search

+4 -1
View File
@@ -138,6 +138,9 @@ var connectorRules = []errRule{
fixed(http.StatusNotFound, typeNotFound, "connector not found", connectors.ErrNotFound),
verbatim(http.StatusBadRequest, typeInvalid, connectors.ErrInvalid.Error()+": ", connectors.ErrInvalid),
verbatim(http.StatusConflict, typeConnectorOff, "", studiosvc.ErrConnectorsOff),
// A host denial is not a failure of the server. The text names no part of the
// check (the add-on, the agent, the token), so it tells a caller nothing to probe.
fixed(http.StatusForbidden, typeAuthz, "you are not allowed to do this with this connector", connectors.ErrForbidden),
}
func failConnector(c echo.Context, err error) error {
@@ -208,7 +211,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error {
// CreateConnector creates a binding.
// @Summary Create a connector binding.
// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings.
// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 50 bindings.
// @Tags chats
// @Param request body schema.ChatsConnectorWrite true "the binding"
// @Success 201 {object} schema.ChatsConnector "The binding"
@@ -14,6 +14,7 @@ import (
"github.com/mudler/LocalAI/core/schema"
studiosvc "github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/triggers"
"github.com/mudler/LocalAI/core/services/studio/workspace"
@@ -57,6 +58,8 @@ var _ = Describe("the studio endpoint helpers", func() {
{"triggers off", failTrigger, studiosvc.ErrTriggersOff, 409, "triggers_off", studiosvc.ErrTriggersOff.Error()},
{"a trigger error falls to the chat table", failTrigger, session.ErrNotFound, 404, "not_found", "conversation not found"},
{"an app refusal keeps its reason", failApp, fmt.Errorf("%w: not declared", studiosvc.ErrMCPAppRefused), 403, "mcp_app_refused", "not declared"},
{"a host denial for a connector", failConnector, fmt.Errorf("verifying: %w", connectors.ErrForbidden), 403, "authorization_error", "not allowed"},
{"a host denial does not name the part that failed", failConnector, fmt.Errorf("agent \"secret-agent\": %w", connectors.ErrForbidden), 403, "authorization_error", "not allowed"},
{"an app refusal with no reason", failApp, studiosvc.ErrMCPAppRefused, 403, "mcp_app_refused", ""},
{"an MCP content failure", failMCPContent, fmt.Errorf("%w: server said no", studiosvc.ErrMCPContent), 502, "mcp_error", "server said no"},
}
@@ -66,6 +69,7 @@ var _ = Describe("the studio endpoint helpers", func() {
Expect(apiErr.Type).To(Equal(tt.typ), tt.name)
Expect(apiErr.Code).To(BeEquivalentTo(tt.status), tt.name)
Expect(apiErr.Message).To(ContainSubstring(tt.msg), tt.name)
Expect(apiErr.Message).NotTo(ContainSubstring("secret-agent"), tt.name)
}
})
+1 -1
View File
@@ -85,7 +85,7 @@ const (
MaxNameRunes = 80
MaxAllow = 200
MaxAllowRunes = 64
MaxPerUser = 10
MaxPerUser = 50
MaxSendersKept = 200
MaxOptions = 8
MaxOptionRunes = 32
@@ -2,8 +2,12 @@ package studio_test
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mudler/LocalAI/addons/telegram/transport"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/notify"
@@ -100,6 +104,75 @@ var _ = Describe("a credentials-only connector binding", func() {
})
})
Describe("the number of bindings of a person", func() {
It("is 50, enough for several bots and a credential binding for each service", func() {
Expect(connectors.MaxPerUser).To(Equal(50))
newRig(true)
for i := range connectors.MaxPerUser {
in := credOnly(studio.ConnectorInput{})
in.Name = fmt.Sprintf("Connection %d", i)
_, err := r.conns.CreateConnector(ctx, alice, in)
Expect(err).NotTo(HaveOccurred(), "binding %d", i+1)
}
_, err := r.conns.CreateConnector(ctx, alice, studio.ConnectorInput{Kind: "telegram", Name: "One too many", Token: botToken})
Expect(err).To(MatchError(connectors.ErrInvalid))
Expect(err).To(MatchError(ContainSubstring("at most 50 bindings")))
})
})
Describe("creating with the token check of the platform", func() {
// The real check (who is the bot of this token) runs for a binding that is
// not stored yet, through the host authority. It must not look up an agent
// the binding does not have.
verified := func(optional bool) {
bot := newScriptedBot()
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
addon.Kind.AgentOptional = optional
tr := studiobot.NewTransport(addon)
st = connStore()
r = newConnRigOpts(newShared(), st, tr, connSay("never"), nil, func(o *studio.ConnectorOptions) { o.VerifyToken = studio.VerifyTokenWithMe(asApplication{tr}) })
r.bot, r.untrusted = bot, true
}
It("saves a binding with no agent", func() {
verified(true)
got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
Expect(err).NotTo(HaveOccurred())
Expect(got.CredentialsOnly()).To(BeTrue())
})
It("still refuses a token that the platform refuses", func() {
verified(true)
r.bot.setFail("getMe", func() (int, string) {
return http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`
})
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
Expect(err).To(MatchError(studio.ErrTokenRefused))
})
It("still needs an agent when the kind does not declare agentOptional", func() {
verified(false)
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
Expect(err).To(MatchError(connectors.ErrInvalid))
})
It("still refuses an agent that is not the owner's", func() {
verified(true)
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{AgentID: "someone-elses"}))
Expect(err).To(HaveOccurred())
})
It("authorizes the check of a new binding by its token, and by its agent only when it has one", func() {
verified(true)
me := func(scope studio.ConnectorCallScope, token string) error {
_, err := r.conns.AuthorizeConnector(ctx, scope, contract.ConnectorRequest{Op: contract.ConnectorOpMe, Kind: "telegram", Token: token})
return err
}
scope := studio.ConnectorCallScope{Purpose: studio.ConnectorPurposeVerifyToken, Principal: alice, AddonID: "telegram", Kind: "telegram"}
Expect(me(scope, botToken)).To(Succeed())
Expect(me(scope, "")).To(MatchError(connectors.ErrForbidden), "the token is still required")
scope.AgentID = "someone-elses"
Expect(me(scope, botToken)).To(MatchError(connectors.ErrForbidden), "a foreign agent is still refused")
scope.AgentID = r.agentID()
Expect(me(scope, botToken)).To(Succeed())
})
})
Describe("a record that is on without an agent", func() {
BeforeEach(func() { newRig(true); b = stored() })
@@ -143,3 +216,15 @@ var _ = Describe("a credentials-only connector binding", func() {
})
})
})
// asApplication maps a refusal of the gateway to the one of the connectors, as
// the application transport does.
type asApplication struct{ *studiobot.BotTransport }
func (t asApplication) Call(ctx context.Context, scope studio.ConnectorCallScope, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
resp, err := t.BotTransport.Call(ctx, scope, req)
if errors.Is(err, gateway.ErrForbidden) {
return resp, connectors.ErrForbidden
}
return resp, err
}
+3 -1
View File
@@ -64,8 +64,10 @@ func (c *Connectors) AuthorizeConnector(ctx context.Context, s ConnectorCallScop
return deny()
}
}
// A new binding is not stored yet, so its agent is checked here. A
// credentials-only binding has none, and the token check still ran above.
if s.BindingID == "" {
if err := c.checkAgent(ctx, s.Principal, s.AgentID); err != nil {
if err := c.checkAgentIfAny(ctx, s.Principal, s.AgentID); err != nil {
return deny()
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ gets one sentence (for example "I cannot listen to voice messages here: no
speech-to-text model is set up") and no turn starts.
`GET`, `PUT` and `DELETE /api/chats/connectors/{id}` read, replace and delete a
binding. `POST .../enable` and `.../disable` switch it. You can have 10 bindings.
binding. `POST .../enable` and `.../disable` switch it. You can have 50 bindings.
A binding says `active: false` and gives a `reason` while its add-on does not run.
## GitHub
+1 -1
View File
@@ -5756,7 +5756,7 @@ const docTemplate = `{
}
},
"post": {
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.",
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 50 bindings.",
"tags": [
"chats"
],
+1 -1
View File
@@ -5753,7 +5753,7 @@
}
},
"post": {
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.",
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 50 bindings.",
"tags": [
"chats"
],
+1 -1
View File
@@ -17400,7 +17400,7 @@ paths:
The allowlist is empty at first, so nobody is accepted: add chat IDs, user
IDs or @usernames, or set accept_any (anyone who can message the bot then
uses the agent''s tools; the request that turns it on must also carry confirm_accept_any
set to "accept any message", or it is a 400). At most 10 bindings.'
set to "accept any message", or it is a 400). At most 50 bindings.'
parameters:
- description: the binding
in: body