From d4c268b64454c6621a20da8a7cd9e6fc12c87abd Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 5 Nov 2026 09:42:53 +0000 Subject: [PATCH] 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 --- core/http/endpoints/studio/connectors.go | 5 +- core/http/endpoints/studio/errmap_test.go | 4 + core/services/studio/connectors/types.go | 2 +- .../studio/connectors_agentless_test.go | 85 +++++++++++++++++++ core/services/studio/connectorscope.go | 4 +- docs/content/features/chats-connectors.md | 2 +- swagger/docs.go | 2 +- swagger/swagger.json | 2 +- swagger/swagger.yaml | 2 +- 9 files changed, 101 insertions(+), 7 deletions(-) diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index c7604f0017..a99532da31 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -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" diff --git a/core/http/endpoints/studio/errmap_test.go b/core/http/endpoints/studio/errmap_test.go index 41b1e9deeb..cadf63866d 100644 --- a/core/http/endpoints/studio/errmap_test.go +++ b/core/http/endpoints/studio/errmap_test.go @@ -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) } }) diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index 965c4f069a..d98a9532bf 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -85,7 +85,7 @@ const ( MaxNameRunes = 80 MaxAllow = 200 MaxAllowRunes = 64 - MaxPerUser = 10 + MaxPerUser = 50 MaxSendersKept = 200 MaxOptions = 8 MaxOptionRunes = 32 diff --git a/core/services/studio/connectors_agentless_test.go b/core/services/studio/connectors_agentless_test.go index 9f3cbc1896..88c3e0490c 100644 --- a/core/services/studio/connectors_agentless_test.go +++ b/core/services/studio/connectors_agentless_test.go @@ -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 +} diff --git a/core/services/studio/connectorscope.go b/core/services/studio/connectorscope.go index 4792ea864b..0e716324dc 100644 --- a/core/services/studio/connectorscope.go +++ b/core/services/studio/connectorscope.go @@ -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() } } diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 8cbccf4daa..acdac63c84 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -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 diff --git a/swagger/docs.go b/swagger/docs.go index 429ffda99a..4c0dcc338a 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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" ], diff --git a/swagger/swagger.json b/swagger/swagger.json index 2dd3baffc7..19ba9a4bfd 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" ], diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 2f2c7bea31..7122a9a811 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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