Files
LocalAI/pkg/addons/contract/connector_test.go
T
Ettore Di Giacinto 2b588a7350 fix(connectors): hold add-on write tools to tools.write in connector chats
A write tool of an add-on that holds a credential was protected only by
the write mode of the connection and the confirmation of the proposal.
In a connector chat it now also needs the capability tools.write for the
sender of the turn, through the same Authorize path as the other
capabilities: the tool is not offered without it, a call is checked again
and every decision is audited. The default is no.

The rule is by shape, not by name. The manifest says an add-on holds a
credential, and contract.ToolPermission maps its non-read tools to
tools.write. The chat reads the permission from the tool definition, so
the core names no add-on.

Also close a hole in local-owner mode, where a chat turn and the browser
share one credential: a proposal that waits for a person could be
confirmed from a chat turn. The call of a chat tool now carries a mark,
and the owner refuses to confirm such a proposal under it. A remote owner
does not see the mark; local-owner mode has no remote owner.

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 15:41:45 +00:00

173 lines
7.6 KiB
Go

// SPDX-License-Identifier: MIT
package contract
import (
"os"
"strings"
"unicode/utf8"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the connector capability", func() {
var m Manifest
BeforeEach(func() {
b, err := os.ReadFile("../../../tests/addons/fixtures/contracts/page.json")
Expect(err).NotTo(HaveOccurred())
m, err = ParseManifest(b)
Expect(err).NotTo(HaveOccurred())
m.Capabilities.UI.SDK = VersionRange{Min: 1, Max: 2}
m.Capabilities.UI.Pages = []string{"bot"}
m.Capabilities.UI.Navigation = nil
m.Capabilities.UI.MainPage = ""
m.Capabilities.Connector = &ConnectorCapability{Kinds: []ConnectorKind{{ID: "telegram", Title: "Telegram", Delivery: DeliveryPull, Page: "bot"}}}
})
host := func() HostCompatibility {
h := compatible()
h.SDK = HostSDKVersion
return h
}
It("accepts a pull kind with a declared page", func() {
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("accepts drafts and a preview interval within the bounds", func() {
m.Capabilities.Connector.Kinds[0].Drafts = true
m.Capabilities.Connector.Kinds[0].PreviewMs = MinPreviewMs
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("lets an add-on declare nothing but the connector", func() {
m.Capabilities.UI = nil
m.Capabilities.Connector.Kinds[0].Page = ""
Expect(ValidateManifest(m, host())).To(Succeed())
})
Describe("the history ability", func() {
kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] }
BeforeEach(func() { m.API = VersionRange{Min: 1, Max: 2} })
It("accepts a thread history with the manifest api 2", func() {
kind().History = &ConnectorHistory{Thread: true, MaxMessages: 50}
Expect(ValidateManifest(m, host())).To(Succeed())
Expect(kind().ServesThreadHistory()).To(BeTrue())
})
It("refuses it under the manifest api 1", func() {
m.API = VersionRange{Min: 1, Max: 1}
kind().History = &ConnectorHistory{Thread: true}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("refuses an ability that says nothing and a limit out of bounds", func() {
kind().History = &ConnectorHistory{}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().History = &ConnectorHistory{Thread: true, MaxMessages: MaxHistoryMessages + 1}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().History = &ConnectorHistory{Thread: true, MaxMessages: -1}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("serves nothing for a kind that declares nothing", func() {
Expect(kind().ServesThreadHistory()).To(BeFalse())
})
It("cuts the limit to what the kind declares", func() {
k := ConnectorKind{History: &ConnectorHistory{Thread: true, MaxMessages: 10}}
Expect(k.HistoryLimit(0)).To(Equal(10))
Expect(k.HistoryLimit(5)).To(Equal(5))
Expect(k.HistoryLimit(500)).To(Equal(10))
Expect(ConnectorKind{History: &ConnectorHistory{Thread: true}}.HistoryLimit(0)).To(Equal(DefaultHistoryMessages))
})
})
Describe("the options of a kind", func() {
kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] }
follow := func() ConnectorOption {
return ConnectorOption{ID: "followThreads", Title: "Follow threads", Default: "own",
Values: []ConnectorOptionValue{{ID: "own", Title: "Own"}, {ID: "any", Title: "Any"}, {ID: "never", Title: "Never"}}}
}
BeforeEach(func() { m.API = VersionRange{Min: 1, Max: 2} })
It("accepts an option with a default among its values", func() {
kind().Options = []ConnectorOption{follow()}
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("refuses options under api 1, a bad default, one value, a repeated id and too many", func() {
kind().Options = []ConnectorOption{follow()}
m.API = VersionRange{Min: 1, Max: 1}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
m.API = VersionRange{Min: 1, Max: 2}
for _, edit := range []func(*ConnectorOption){
func(o *ConnectorOption) { o.Default = "sometimes" },
func(o *ConnectorOption) { o.Values = o.Values[:1] },
func(o *ConnectorOption) { o.ID = "Follow-Threads" },
func(o *ConnectorOption) { o.Values[1].ID = "own" },
func(o *ConnectorOption) { o.Title = "<b>x</b>" },
} {
o := follow()
edit(&o)
kind().Options = []ConnectorOption{o}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
}
kind().Options = []ConnectorOption{follow(), follow()}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("resolves the choices of a binding against the declaration", func() {
k := ConnectorKind{Options: []ConnectorOption{follow()}}
Expect(k.ResolveOptions(nil)).To(Equal(map[string]string{"followThreads": "own"}))
Expect(k.ResolveOptions(map[string]string{"followThreads": "never"})).To(Equal(map[string]string{"followThreads": "never"}))
_, err := k.ResolveOptions(map[string]string{"followThreads": "sometimes"})
Expect(err).To(HaveOccurred())
_, err = k.ResolveOptions(map[string]string{"other": "x"})
Expect(err).To(HaveOccurred())
Expect(ConnectorKind{}.ResolveOptions(nil)).To(BeNil())
})
})
DescribeTable("refuses",
func(edit func(*ConnectorCapability)) {
edit(m.Capabilities.Connector)
Expect(ValidateManifest(m, host())).NotTo(Succeed())
},
Entry("no kind", func(c *ConnectorCapability) { c.Kinds = nil }),
Entry("a bad kind id", func(c *ConnectorCapability) { c.Kinds[0].ID = "Tele gram" }),
Entry("a repeated kind", func(c *ConnectorCapability) { c.Kinds = append(c.Kinds, c.Kinds[0]) }),
Entry("markup in the title", func(c *ConnectorCapability) { c.Kinds[0].Title = "<b>x</b>" }),
Entry("an unknown delivery", func(c *ConnectorCapability) { c.Kinds[0].Delivery = "webhook" }),
Entry("a page the UI does not declare", func(c *ConnectorCapability) { c.Kinds[0].Page = "other" }),
Entry("a preview interval too short", func(c *ConnectorCapability) { c.Kinds[0].PreviewMs = MinPreviewMs - 1 }),
Entry("a preview interval too long", func(c *ConnectorCapability) { c.Kinds[0].PreviewMs = MaxPreviewMs + 1 }),
)
})
var _ = DescribeTable("SafeFileName",
func(in, want string) { Expect(SafeFileName(in)).To(Equal(want)) },
Entry("a plain name", "report.pdf", "report.pdf"),
Entry("a path keeps its last element", "/home/u/out/chart.png", "chart.png"),
Entry("a Windows path", `C:\tmp\a.txt`, "a.txt"),
Entry("control characters and quotes", "a\x00b\"c\n.txt", "a_b_c_.txt"),
Entry("dots only", "..", "file"),
Entry("empty", "", "file"),
Entry("a hidden name loses its leading dot", ".env", "env"),
Entry("a long name keeps its extension", strings.Repeat("é", 100)+".csv", strings.Repeat("é", 62)+".csv"),
)
var _ = It("cuts a long name on a character boundary", func() {
got := SafeFileName("a" + strings.Repeat("é", 100) + ".csv")
Expect(len(got)).To(BeNumerically("<=", MaxConnectorFileName))
Expect(utf8.ValidString(got)).To(BeTrue())
Expect(got).To(HaveSuffix(".csv"))
})
var _ = Describe("the permission of a tool", func() {
It("is tools.write for a tool that is not a read, in an add-on that holds a credential", func() {
for _, kind := range []string{ToolKindWrite, ToolKindSchema, ToolKindCreate, ToolKindAction} {
Expect(ToolPermission(true, ToolDefinition{Kind: kind})).To(Equal(CapToolsWrite), kind)
}
})
It("is empty for a read, and for any tool of an add-on without a credential", func() {
Expect(ToolPermission(true, ToolDefinition{Kind: ToolKindRead})).To(BeEmpty())
for _, kind := range []string{ToolKindRead, ToolKindWrite, ToolKindSchema, ToolKindCreate, ToolKindAction} {
Expect(ToolPermission(false, ToolDefinition{Kind: kind})).To(BeEmpty(), kind)
}
})
It("names a capability in the closed list", func() {
Expect(KnownCapability(CapToolsWrite)).To(BeTrue())
Expect(SensitiveCapability(CapToolsWrite)).To(BeFalse())
})
})