From e6fe32720392695c065d4d3945b8a1fc64a5ed05 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 09:12:09 +0000 Subject: [PATCH] fix(studio): let a binding read the history of its thread AuthorizeConnector had no case for the op history, so the default branch refused it. A mention in a Slack thread then logged "connectors: forbidden" and the first turn of the new chat got no thread block. History now follows the rules of a send: the binding is the whole authority, the token comes from owner storage and a disabled binding keeps it. Only the poll stops with the binding. The studio specs ran over a transport that skipped the scope check, so none of them saw the refusal. The rig now authorizes every call with the real manager, and a new spec table pins each op of the contract. Assisted-by: Claude:claude-sonnet-5-5 --- .../connector_owner_routing_test.go | 2 +- core/services/studio/connectors_test.go | 1 + core/services/studio/connectorscope.go | 5 +- core/services/studio/connectorscope_test.go | 95 +++++++++++++++++++ tests/studiobot/transport.go | 13 +++ 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 core/services/studio/connectorscope_test.go diff --git a/core/application/connector_owner_routing_test.go b/core/application/connector_owner_routing_test.go index b650365ca2..91fbe5bf11 100644 --- a/core/application/connector_owner_routing_test.go +++ b/core/application/connector_owner_routing_test.go @@ -385,7 +385,7 @@ var _ = Describe("connector owner routing", func() { b, e := bindings.GetBinding(ctx, original.ID) Expect(e).NotTo(HaveOccurred()) scope := studio.BindingConnectorScope(b) - for _, op := range []string{contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft} { + for _, op := range []string{contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpHistory} { req := contract.ConnectorRequest{Op: op, Kind: "slack", Chat: "chat", Thread: "thread", Text: "private text", MessageID: "working", DraftID: 7, Media: "ref"} before = len(source.requests()) _, e = tr.Call(ctx, scope, req) diff --git a/core/services/studio/connectors_test.go b/core/services/studio/connectors_test.go index 20afa64ea3..65f528d371 100644 --- a/core/services/studio/connectors_test.go +++ b/core/services/studio/connectors_test.go @@ -314,6 +314,7 @@ func newConnRigOpts(sh *shared, store connectors.Store, tr *studiobot.BotTranspo c, err := studio.NewConnectors(r.svc, o) Expect(err).NotTo(HaveOccurred()) DeferCleanup(c.Close) + tr.UseAuthority(c) // as the application does: every call is authorized by the stored binding r.svc.SetConnectors(c) // as the application does: the chats see their bindings ctx, cancel := context.WithCancel(context.Background()) DeferCleanup(cancel) diff --git a/core/services/studio/connectorscope.go b/core/services/studio/connectorscope.go index 3ad2091704..4792ea864b 100644 --- a/core/services/studio/connectorscope.go +++ b/core/services/studio/connectorscope.go @@ -76,7 +76,10 @@ func (c *Connectors) AuthorizeConnector(ctx context.Context, s ConnectorCallScop if !b.Enabled { return deny() } - case contract.ConnectorOpMe, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpMedia, contract.ConnectorOpSendFile: + case contract.ConnectorOpMe, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpMedia, contract.ConnectorOpSendFile, contract.ConnectorOpHistory: + // History reads the thread named by the request with the stored token, + // as a send writes to it: the binding is the whole authority, and a + // disabled binding keeps it, like for a send. default: return deny() } diff --git a/core/services/studio/connectorscope_test.go b/core/services/studio/connectorscope_test.go new file mode 100644 index 0000000000..42bfa95198 --- /dev/null +++ b/core/services/studio/connectorscope_test.go @@ -0,0 +1,95 @@ +package studio_test + +import ( + "context" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The authority of the host is the only gate between an add-on call and the +// token of a binding. These specs pin which operations a binding scope may run. +var _ = Describe("the authorization of a connector call", func() { + ctx := context.Background() + var ( + r *connRig + b connectors.Binding + ) + BeforeEach(func() { + r = newConnRig(newShared(), connStore(), newScriptedBot(), connSay("ok"), nil) + b = r.bind(studio.ConnectorInput{Allow: []string{"11"}}) + }) + history := func() contract.ConnectorRequest { + return contract.ConnectorRequest{Op: contract.ConnectorOpHistory, Kind: b.Kind, Chat: "C1", Thread: "T1", Limit: 20, Before: "m5"} + } + + It("runs the history of a thread for the binding, with the stored token and the request untouched", func() { + got, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), history()) + Expect(err).NotTo(HaveOccurred()) + want := history() + want.Token = botToken + Expect(got).To(Equal(want)) + }) + + It("ignores a token that the caller puts in a history request", func() { + req := history() + req.Token = "not-the-token" + got, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), req) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Token).To(Equal(botToken)) + }) + + It("refuses the history for a scope that does not match the stored binding", func() { + for name, mutate := range map[string]func(*studio.ConnectorCallScope){ + "owner": func(s *studio.ConnectorCallScope) { s.Principal = "mallory" }, + "add-on": func(s *studio.ConnectorCallScope) { s.AddonID = "other" }, + "agent": func(s *studio.ConnectorCallScope) { s.AgentID = "other" }, + "binding": func(s *studio.ConnectorCallScope) { s.BindingID = "missing" }, + "none": func(s *studio.ConnectorCallScope) { s.BindingID = "" }, + } { + s := studio.BindingConnectorScope(b) + mutate(&s) + _, err := r.conns.AuthorizeConnector(ctx, s, history()) + Expect(err).To(MatchError(connectors.ErrForbidden), name) + } + req := history() + req.Kind = "other" + _, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), req) + Expect(err).To(MatchError(connectors.ErrForbidden), "a request of another kind") + s := studio.BindingConnectorScope(b) + s.Purpose = studio.ConnectorPurposeVerifyToken + _, err = r.conns.AuthorizeConnector(ctx, s, history()) + Expect(err).To(MatchError(connectors.ErrForbidden), "a token check runs only the op me") + }) + + It("treats a disabled binding as it treats a send: only the poll stops", func() { + _, err := r.conns.SetConnectorEnabled(ctx, alice, b.ID, false) + Expect(err).NotTo(HaveOccurred()) + off, err := r.store.GetBinding(ctx, b.ID) + Expect(err).NotTo(HaveOccurred()) + s := studio.BindingConnectorScope(off) + send := contract.ConnectorRequest{Op: contract.ConnectorOpSend, Kind: off.Kind, Chat: "C1", Text: "hi"} + _, sendErr := r.conns.AuthorizeConnector(ctx, s, send) + _, histErr := r.conns.AuthorizeConnector(ctx, s, history()) + Expect(sendErr).NotTo(HaveOccurred()) + Expect(histErr).NotTo(HaveOccurred()) + _, pollErr := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: contract.ConnectorOpPoll, Kind: off.Kind}) + Expect(pollErr).To(MatchError(connectors.ErrForbidden)) + }) + + It("allows every operation of the contract for an enabled binding and refuses an unknown one", func() { + s := studio.BindingConnectorScope(b) + for _, op := range []string{ + contract.ConnectorOpPoll, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpMe, + contract.ConnectorOpMedia, contract.ConnectorOpSendFile, contract.ConnectorOpDraft, contract.ConnectorOpHistory, + } { + _, err := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: op, Kind: b.Kind}) + Expect(err).NotTo(HaveOccurred(), op) + } + _, err := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: "export", Kind: b.Kind}) + Expect(err).To(MatchError(connectors.ErrForbidden)) + }) +}) diff --git a/tests/studiobot/transport.go b/tests/studiobot/transport.go index c723b73d72..7aa6643274 100644 --- a/tests/studiobot/transport.go +++ b/tests/studiobot/transport.go @@ -148,6 +148,19 @@ func (b *BotTransport) EnableTransfers(t *hostops.Transfers) (stop func()) { } } +// UseAuthority makes every call go through the real gateway with the given +// authority, as in the application. Without it a transport that moves no file +// calls the add-on directly and checks no scope. +func (b *BotTransport) UseAuthority(a gateway.ConnectorAuthority) { + b.mu.Lock() + defer b.mu.Unlock() + if b.gateway == nil { + b.gateway = &gateway.Gateway{Connectors: gatewaySource{b}, ConnectorAuthority: a} + return + } + b.gateway.ConnectorAuthority = a +} + type allowAll struct{} func (allowAll) AuthorizeConnector(_ context.Context, _ studio.ConnectorCallScope, r contract.ConnectorRequest) (contract.ConnectorRequest, error) {