mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
refactor(addons): remove the shims for older add-on data
No release carried add-ons, so nothing reads older add-on data or manifests. Delete the code that did. - Connectors: the kind and the add-on of the Telegram connectors that core once carried are gone (LegacyAddon, KindTelegram, the stored binding normalization, the platform titles). A binding needs a kind, and its add-on is the one that serves the kind. The conversion of the old agent pool lists every connector as dropped instead of making a Telegram binding, and its notes say so. - The library index reads only the id field of an entry, not addonId. - A host control that has a capability block (the network policy) is attached by that block alone, not by a tool that replaces bash. - The Office create tools no longer answer for the argument names of an earlier schema, and the identity file note about old addresses is gone. - The known debt of the provider-name guard drops the entries of the conversion and of the resource listing shim, and its ceiling goes from 13 to 5. Assisted-by: Claude:claude-sonnet-5-5 go test
This commit is contained in:
38 files changed
+104
-303
No files matched your search
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
// decodeTextCreation is deliberately local to the three string creation tools.
|
||||
// Bound raw JSON before decoding, and never echo decoder errors or user content.
|
||||
func decodeTextCreation(raw json.RawMessage, v any, field, legacy string) error {
|
||||
func decodeTextCreation(raw json.RawMessage, v any, field string) error {
|
||||
if len(raw) > 1<<20 {
|
||||
return refuse("arguments exceed 1 MiB; shorten %s", field)
|
||||
}
|
||||
@@ -27,9 +27,6 @@ func decodeTextCreation(raw json.RawMessage, v any, field, legacy string) error
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return refuse("arguments must be a JSON object with path and %s strings", field)
|
||||
}
|
||||
if _, ok := fields[legacy]; ok {
|
||||
return refuse("%s is no longer supported; use the %s string", legacy, field)
|
||||
}
|
||||
for _, key := range []string{"path", field, "title", "sheet"} {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
@@ -52,7 +49,7 @@ func (w *work) createDocx(raw json.RawMessage) (any, error) {
|
||||
Title string `json:"title"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
if err := decodeTextCreation(raw, &a, "markdown", "blocks"); err != nil {
|
||||
if err := decodeTextCreation(raw, &a, "markdown"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPath(a.Path, ".docx"); err != nil {
|
||||
@@ -85,7 +82,7 @@ func (w *work) createXlsx(raw json.RawMessage) (any, error) {
|
||||
CSV string `json:"csv"`
|
||||
Sheet string `json:"sheet"`
|
||||
}
|
||||
if err := decodeTextCreation(raw, &a, "csv", "sheets"); err != nil {
|
||||
if err := decodeTextCreation(raw, &a, "csv"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPath(a.Path, ".xlsx"); err != nil {
|
||||
@@ -156,7 +153,7 @@ func (w *work) createPdf(raw json.RawMessage) (any, error) {
|
||||
Title string `json:"title"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
if err := decodeTextCreation(raw, &a, "markdown", "blocks"); err != nil {
|
||||
if err := decodeTextCreation(raw, &a, "markdown"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPath(a.Path, ".pdf"); err != nil {
|
||||
|
||||
@@ -253,15 +253,6 @@ func (t studioTargets) DiscardChat(ctx context.Context, user, title string, crea
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t studioTargets) CreateBinding(ctx context.Context, user string, in convert.BindingIn) error {
|
||||
c := t.svc.Connectors()
|
||||
if c == nil {
|
||||
return errors.New("connectors are not available")
|
||||
}
|
||||
_, err := c.CreateConnector(ctx, user, studio.ConnectorInput{Name: in.Name, Token: in.Token, AgentID: in.AgentID, Allow: in.Allow, Enabled: false})
|
||||
return invalid(err)
|
||||
}
|
||||
|
||||
func connectorRefs(ids []string) []string {
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -17,7 +17,7 @@ func totalsOf(r convert.Report) schema.ChatsConversionTotals {
|
||||
|
||||
// Conversion returns what the conversion of the old data did for the caller.
|
||||
// @Summary Report the conversion of your old agents, skills and chats.
|
||||
// @Description At start the server converts the old chat page, agents, tasks, skills and Telegram connectors into chats, saved agents, triggers, skills and connector bindings. Triggers and connectors are made switched off. This lists the items that belong to you, with what was dropped and why; an administrator also gets the totals of all users. It never holds a token or the text of a prompt.
|
||||
// @Description At start the server converts the old chat page, agents, tasks, skills and connectors into chats, saved agents, triggers and skills. Triggers are made switched off; connectors are listed as dropped and are made again in the Connectors page. This lists the items that belong to you, with what was dropped and why; an administrator also gets the totals of all users. It never holds a token or the text of a prompt.
|
||||
// @Tags chats
|
||||
// @Success 200 {object} schema.ChatsConversion "The report"
|
||||
// @Router /api/chats/conversion [get]
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
},
|
||||
"conversionPage": {
|
||||
"title": "Converted from the old chat",
|
||||
"intro": "The old chat, agents, tasks, skills and Telegram connectors were converted when this server started. Triggers and connectors are switched off until you turn them on. A chat keeps what was said, not what the model remembered.",
|
||||
"intro": "The old chat, agents, tasks and skills were converted when this server started. Triggers are switched off until you turn them on. Connectors are not converted: add the bot again in the Connectors page. A chat keeps what was said, not what the model remembered.",
|
||||
"notRun": "No conversion has run on this server.",
|
||||
"nothing": "Nothing of yours needed converting.",
|
||||
"allUsers": "All users:",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"auth:login.signInWithGitHub": "GitHub as the OAuth identity service a person signs in with; the service, not the GitHub add-on",
|
||||
"chat:steps.verb.websearch": "the web (the internet) as a noun: the verb of a search step",
|
||||
"chats:connectorsPage.noAddonHint": "Telegram is named as an example of a platform a connector carries; content, not a provider",
|
||||
"chats:conversionPage.intro": "names the legacy Telegram connectors that the one-time conversion migrated; content",
|
||||
"chats:egress.presetHint.registries": "GitHub releases as a package source the registries preset allows; the service host, not the GitHub add-on",
|
||||
"chats:mcpPage.family": "Telegram and Slack are examples of messaging platforms; content, not a provider",
|
||||
"chats:tools.builtin.web": "the name of the built-in Web tool group of the Tools menu",
|
||||
|
||||
@@ -188,6 +188,7 @@ var _ = Describe("apps list", func() {
|
||||
ui.Controls = []string{contract.ControlNetworkPolicy, contract.ControlToolProviders}
|
||||
s := snapshot(id, true, "running", ui, "bash")
|
||||
s.Receipt.Manifest.Capabilities.ToolDefinitions = []contract.ToolDefinition{bash}
|
||||
s.Receipt.Manifest.Capabilities.Network = &contract.NetworkCapability{Kind: contract.NetworkKindEgressCheck}
|
||||
s.HostOps = grants
|
||||
s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64)
|
||||
return s
|
||||
|
||||
@@ -104,7 +104,7 @@ var _ = Describe("the backend body of a library entry", func() {
|
||||
" license: mit\n", " urls: [https://example.com]\n", " tags: [a]\n", " icon: https://example.com/i.png\n",
|
||||
" mirrors: [oci://hub.example/x:y]\n", " capabilities: {default: x}\n", " schemaVersion: 1\n",
|
||||
} {
|
||||
_, _, err := read(oldShapeIndex + extra)
|
||||
_, _, err := read(addonEntry + extra)
|
||||
Expect(err).To(HaveOccurred(), extra)
|
||||
}
|
||||
})
|
||||
@@ -273,7 +273,7 @@ var _ = Describe("the backend body of a library entry", func() {
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("is accepted on an add-on without a digest, as on a backend", func() {
|
||||
entry := strings.Replace(oldShapeIndex, " digest: "+yamlDigest+"\n", " mutable: true\n", 1)
|
||||
entry := strings.Replace(addonEntry, " digest: "+yamlDigest+"\n", " mutable: true\n", 1)
|
||||
_, s, err := read(entry)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
r, err := Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}.Resolve(ctx, "lib", "tables", "1.0.0")
|
||||
@@ -282,11 +282,11 @@ var _ = Describe("the backend body of a library entry", func() {
|
||||
Expect(r.Digest).To(BeEmpty())
|
||||
})
|
||||
It("is refused on an add-on with a digest, without a digest unless mutable, or on an archive", func() {
|
||||
_, _, err := read(oldShapeIndex + " mutable: true\n")
|
||||
_, _, err := read(addonEntry + " mutable: true\n")
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, _, err = read(strings.Replace(oldShapeIndex, " digest: "+yamlDigest+"\n", "", 1))
|
||||
_, _, err = read(strings.Replace(addonEntry, " digest: "+yamlDigest+"\n", "", 1))
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, _, err = read(strings.Replace(strings.Replace(oldShapeIndex, " digest: "+yamlDigest+"\n", " mutable: true\n", 1), "oci://registry.example/tables", "https://registry.example/tables.tar", 1))
|
||||
_, _, err = read(strings.Replace(strings.Replace(addonEntry, " digest: "+yamlDigest+"\n", " mutable: true\n", 1), "oci://registry.example/tables", "https://registry.example/tables.tar", 1))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,9 +111,7 @@ type Release struct {
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
// ID is the identifier of the entry for every kind, the same field the
|
||||
// shared envelope uses. For a backend it is the backend name, which is also
|
||||
// its directory name. An index written before the envelope names it
|
||||
// addonId; ReadIndex still reads that name (see LegacyIDField) and every
|
||||
// writer emits id.
|
||||
// its directory name.
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
@@ -12,9 +12,7 @@ const SchemaVersion = 1
|
||||
// entries needs one reader for the header and no knowledge of the body.
|
||||
//
|
||||
// The add-on and backend index files use Release, which names the same
|
||||
// identifier id (catalog.Release.ID). An index written before that still holds
|
||||
// addonId; ReadIndex reads it as id for one release (LegacyIDField) and every
|
||||
// writer emits id. Release has no license, urls, tags, icon or schemaVersion
|
||||
// identifier id (catalog.Release.ID). Release has no license, urls, tags, icon or schemaVersion
|
||||
// yet; moving it to this whole header is a later step.
|
||||
type Envelope struct {
|
||||
Kind Kind `json:"kind" yaml:"kind"`
|
||||
|
||||
@@ -89,16 +89,10 @@ func ReadIndex(data []byte, known ...Kind) (IndexResult, error) {
|
||||
}
|
||||
if !slices.Contains(known, Kind(kind)) {
|
||||
id, _ := entry["id"].(string)
|
||||
if id == "" {
|
||||
id, _ = entry[LegacyIDField].(string)
|
||||
}
|
||||
res.Skipped = append(res.Skipped, SkippedEntry{Position: i, Kind: kind, ID: id})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := NormalizeLegacyID(entry); err != nil {
|
||||
return IndexResult{}, err
|
||||
}
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
b, err := json.Marshal(kept)
|
||||
@@ -111,26 +105,6 @@ func ReadIndex(data []byte, known ...Kind) (IndexResult, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LegacyIDField is the name the identifier of an entry had before the shared
|
||||
// envelope. A reader still accepts it for one release; every writer emits id.
|
||||
const LegacyIDField = "addonId"
|
||||
|
||||
// NormalizeLegacyID moves the legacy addonId of a decoded entry to id, so one
|
||||
// strict decoder serves both spellings. An entry that holds both is refused
|
||||
// even when they agree, because it is not the output of any writer.
|
||||
func NormalizeLegacyID(entry map[string]any) error {
|
||||
legacy, ok := entry[LegacyIDField]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, both := entry["id"]; both {
|
||||
return errors.New("index entry has both id and " + LegacyIDField)
|
||||
}
|
||||
entry["id"] = legacy
|
||||
delete(entry, LegacyIDField)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeIndex writes releases as a library index. It is the inverse of
|
||||
// DecodeIndex and exists for fixtures and tools that serve a library.
|
||||
func EncodeIndex(releases []Release) ([]byte, error) {
|
||||
|
||||
@@ -45,33 +45,15 @@ var _ = Describe("library index document (YAML)", func() {
|
||||
Expect(got.Digest).To(Equal(yamlDigest))
|
||||
Expect(got.UI.MainPage).To(Equal("tables"))
|
||||
})
|
||||
It("reads the legacy addonId as id, and refuses an entry that has both", func() {
|
||||
legacy := strings.Replace(yamlEntry, "- id: tables", "- addonId: tables", 1)
|
||||
got, err := DecodeIndex([]byte(legacy))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got).To(HaveLen(1))
|
||||
Expect(got[0].ID).To(Equal("tables"))
|
||||
current, err := DecodeIndex([]byte(yamlEntry))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(current).To(Equal(got))
|
||||
_, err = DecodeIndex([]byte(yamlEntry + " addonId: tables\n"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// A writer emits id, never the legacy name.
|
||||
out, err := EncodeIndex(got)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(out)).To(HavePrefix("- "))
|
||||
Expect(string(out)).NotTo(ContainSubstring("addonId"))
|
||||
Expect(string(out)).To(ContainSubstring("id: tables"))
|
||||
})
|
||||
It("accepts an empty library written as an empty list", func() {
|
||||
r, e, _ := refreshBody("[]\n")
|
||||
Expect(e).NotTo(HaveOccurred())
|
||||
Expect(r.Count).To(BeZero())
|
||||
})
|
||||
It("refuses a JSON index", func() {
|
||||
_, e, _ := refreshBody(`[{"addonId":"tables","name":"Tables","version":"1.0.0","artifact":"oci://registry.example/tables","digest":"` + yamlDigest + `","os":"linux","arch":"amd64"}]`)
|
||||
_, e, _ := refreshBody(`[{"id":"tables","name":"Tables","version":"1.0.0","artifact":"oci://registry.example/tables","digest":"` + yamlDigest + `","os":"linux","arch":"amd64"}]`)
|
||||
Expect(e).To(MatchError(ErrRefresh))
|
||||
_, e, _ = refreshBody("{\n \"addonId\": \"tables\"\n}")
|
||||
_, e, _ = refreshBody("{\n \"id\": \"tables\"\n}")
|
||||
Expect(e).To(MatchError(ErrRefresh))
|
||||
})
|
||||
It("refuses an unknown field, at the top and inside ui", func() {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const kindDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
const oldShapeIndex = `- addonId: tables
|
||||
const addonEntry = `- id: tables
|
||||
name: Tables
|
||||
version: 1.0.0
|
||||
artifact: oci://registry.example/tables
|
||||
@@ -39,39 +39,39 @@ const unknownKindEntry = `- kind: gizmo
|
||||
|
||||
var _ = Describe("entry kinds", func() {
|
||||
It("reads an index written before kinds existed, and the entry is an add-on", func() {
|
||||
res, err := ReadIndex([]byte(oldShapeIndex), KindAddon)
|
||||
res, err := ReadIndex([]byte(addonEntry), KindAddon)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Skipped).To(BeEmpty())
|
||||
Expect(res.Releases).To(HaveLen(1))
|
||||
Expect(res.Releases[0].Kind).To(BeEmpty())
|
||||
Expect(res.Releases[0].EntryKind()).To(Equal(KindAddon))
|
||||
releases, err := DecodeIndex([]byte(oldShapeIndex))
|
||||
releases, err := DecodeIndex([]byte(addonEntry))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(releases).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("reads a backend entry on a reader that knows backends", func() {
|
||||
res, err := ReadIndex([]byte(oldShapeIndex+backendEntry), KindAddon, KindBackend)
|
||||
res, err := ReadIndex([]byte(addonEntry+backendEntry), KindAddon, KindBackend)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Skipped).To(BeEmpty())
|
||||
Expect(res.Releases).To(HaveLen(2))
|
||||
Expect(res.Releases[1].EntryKind()).To(Equal(KindBackend))
|
||||
Expect(res.Releases[1].Alias).To(Equal("local-store"))
|
||||
// The reader of this host knows backends too.
|
||||
releases, err := DecodeIndex([]byte(oldShapeIndex + backendEntry))
|
||||
releases, err := DecodeIndex([]byte(addonEntry + backendEntry))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(releases).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("skips a backend entry on an add-on only reader and says so", func() {
|
||||
res, err := ReadIndex([]byte(oldShapeIndex+backendEntry), KindAddon)
|
||||
res, err := ReadIndex([]byte(addonEntry+backendEntry), KindAddon)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Releases).To(HaveLen(1))
|
||||
Expect(res.Skipped).To(Equal([]SkippedEntry{{Position: 1, Kind: "backend", ID: "cpu-local-store"}}))
|
||||
})
|
||||
|
||||
It("skips an unknown kind without reading its fields, and keeps the rest", func() {
|
||||
res, err := ReadIndex([]byte(oldShapeIndex+unknownKindEntry+backendEntry), KindAddon, KindBackend)
|
||||
res, err := ReadIndex([]byte(addonEntry+unknownKindEntry+backendEntry), KindAddon, KindBackend)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Releases).To(HaveLen(2))
|
||||
Expect(res.Skipped).To(Equal([]SkippedEntry{{Position: 1, Kind: "gizmo", ID: "widget"}}))
|
||||
@@ -82,33 +82,20 @@ var _ = Describe("entry kinds", func() {
|
||||
})
|
||||
|
||||
It("skips the reserved model kind in this phase", func() {
|
||||
res, err := ReadIndex([]byte("- kind: model\n addonId: m\n files: []\n"), ReadableKinds...)
|
||||
res, err := ReadIndex([]byte("- kind: model\n id: m\n files: []\n"), ReadableKinds...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Releases).To(BeEmpty())
|
||||
Expect(res.SkippedKinds()).To(Equal([]string{"model"}))
|
||||
})
|
||||
|
||||
It("reports the id of a skipped entry, written as id or as the legacy addonId", func() {
|
||||
res, err := ReadIndex([]byte("- kind: model\n id: m1\n- kind: model\n addonId: m2\n"), ReadableKinds...)
|
||||
It("reports the id of a skipped entry", func() {
|
||||
res, err := ReadIndex([]byte("- kind: model\n id: m1\n- kind: model\n id: m2\n"), ReadableKinds...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Skipped).To(HaveLen(2))
|
||||
Expect(res.Skipped[0].ID).To(Equal("m1"))
|
||||
Expect(res.Skipped[1].ID).To(Equal("m2"))
|
||||
})
|
||||
|
||||
It("reads a backend entry that still uses the legacy addonId, and refuses one that has both", func() {
|
||||
legacy := strings.Replace(backendEntry, " id: cpu-local-store", " addonId: cpu-local-store", 1)
|
||||
res, err := ReadIndex([]byte(legacy), ReadableKinds...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Releases).To(HaveLen(1))
|
||||
Expect(res.Releases[0].ID).To(Equal("cpu-local-store"))
|
||||
_, err = ReadIndex([]byte(backendEntry+" addonId: cpu-local-store\n"), ReadableKinds...)
|
||||
Expect(err).To(HaveOccurred())
|
||||
out, err := EncodeIndex(res.Releases)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(out)).NotTo(ContainSubstring("addonId"))
|
||||
})
|
||||
|
||||
It("still refuses an unknown field on an entry of a known kind", func() {
|
||||
_, err := ReadIndex([]byte(backendEntry+" surprise: 1\n"), ReadableKinds...)
|
||||
Expect(err).To(HaveOccurred())
|
||||
@@ -116,7 +103,7 @@ var _ = Describe("entry kinds", func() {
|
||||
|
||||
It("refuses a malformed kind", func() {
|
||||
for _, bad := range []string{"kind: 3", "kind: \"\"", "kind: Backend", "kind: [backend]", "kind: " + strings.Repeat("a", 40)} {
|
||||
_, err := ReadIndex([]byte("- "+bad+"\n addonId: x\n"), ReadableKinds...)
|
||||
_, err := ReadIndex([]byte("- "+bad+"\n id: x\n"), ReadableKinds...)
|
||||
Expect(err).To(HaveOccurred(), bad)
|
||||
}
|
||||
})
|
||||
@@ -132,7 +119,7 @@ var _ = Describe("kinds in a library", func() {
|
||||
}
|
||||
|
||||
It("refreshes an index with an unknown kind and reports what it skipped", func() {
|
||||
res, s, err := refresh(oldShapeIndex+unknownKindEntry+backendEntry, VerificationPolicy{})
|
||||
res, s, err := refresh(addonEntry+unknownKindEntry+backendEntry, VerificationPolicy{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Count).To(Equal(2))
|
||||
Expect(res.Skipped).To(Equal(1))
|
||||
@@ -143,7 +130,7 @@ var _ = Describe("kinds in a library", func() {
|
||||
})
|
||||
|
||||
It("lists add-ons by default and backends on request", func() {
|
||||
_, s, err := refresh(oldShapeIndex+backendEntry, VerificationPolicy{})
|
||||
_, s, err := refresh(addonEntry+backendEntry, VerificationPolicy{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
c := Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}
|
||||
page, err := c.Search(ctx, Query{})
|
||||
@@ -178,7 +165,7 @@ var _ = Describe("kinds in a library", func() {
|
||||
"backend with a main page": backendEntry + " ui:\n mainPage: x\n",
|
||||
"backend name with a slash": strings.Replace(backendEntry, "id: cpu-local-store", "id: a/b", 1),
|
||||
"backend name with dots": strings.Replace(backendEntry, "id: cpu-local-store", "id: a..b", 1),
|
||||
"add-on with an alias": oldShapeIndex + " alias: x\n",
|
||||
"add-on with an alias": addonEntry + " alias: x\n",
|
||||
} {
|
||||
_, _, err := refresh(body, VerificationPolicy{})
|
||||
Expect(err).To(HaveOccurred(), name)
|
||||
|
||||
@@ -53,7 +53,7 @@ var _ = Describe("source configuration", func() {
|
||||
It("rejects secret URLs invalid digests duplicate identities and malformed catalogs", func() {
|
||||
s := setup()
|
||||
Expect(s.Save(ctx, admin, Source{ID: "one", Name: "one", URL: "https://user:secret@example.org"})).To(HaveOccurred())
|
||||
for _, body := range []string{`[{"addonId":"tables"}]`, `[] trailing`, `{"secret":"value"}`} {
|
||||
for _, body := range []string{`[{"id":"tables"}]`, `[] trailing`, `{"secret":"value"}`} {
|
||||
server := library(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) })
|
||||
save(s, "one", server.URL)
|
||||
_, e := s.Refresh(ctx, "one")
|
||||
|
||||
@@ -505,12 +505,6 @@ var _ = Describe("owner identity file", func() {
|
||||
Expect(json.Unmarshal(b, &fields)).To(Succeed())
|
||||
Expect(fields).To(Equal(map[string]any{"OwnerID": "owner", "StorageID": "disk-one"}))
|
||||
})
|
||||
It("loads a file written in the old format with an address", func() {
|
||||
cfg, _, _ := fixture()
|
||||
Expect(os.MkdirAll(cfg.Root, 0700)).To(Succeed())
|
||||
Expect(os.WriteFile(identityPath(cfg), []byte(`{"OwnerID":"owner","Address":"https://old.example","StorageID":"disk-one"}`), 0600)).To(Succeed())
|
||||
Expect(acquire(cfg)).To(Succeed())
|
||||
})
|
||||
It("loads when only the address changed", func() {
|
||||
cfg, _, _ := fixture()
|
||||
Expect(acquire(cfg)).To(Succeed())
|
||||
|
||||
@@ -73,8 +73,7 @@ func (g OwnerGuard) Acquire(ctx context.Context) (func() error, error) {
|
||||
return releaseErr
|
||||
}
|
||||
// The address is runtime configuration and a frontend may reach the owner at
|
||||
// a new one, so identity is the owner and the storage only. An older file
|
||||
// that still carries an address is read and the address is ignored.
|
||||
// a new one, so identity is the owner and the storage only.
|
||||
identity := ownerIdentity{OwnerID: c.OwnerID, StorageID: c.StorageID}
|
||||
expected, _ := json.Marshal(identity)
|
||||
p := filepath.Join(c.Root, "identity.json")
|
||||
|
||||
@@ -22,7 +22,7 @@ type NewStore func() connectors.Store
|
||||
var t0 = time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func binding(id, owner, name string) connectors.Binding {
|
||||
return connectors.Binding{ID: id, Owner: owner, Kind: connectors.KindTelegram, Name: name, AgentID: "ag1", Enabled: true,
|
||||
return connectors.Binding{ID: id, Owner: owner, Kind: "chat", Name: name, AgentID: "ag1", Enabled: true,
|
||||
Allow: []string{"123", "@alice"}, NotifyChat: "456", Token: []byte("sealed-bytes-" + id), Created: t0, Updated: t0}
|
||||
}
|
||||
|
||||
@@ -119,26 +119,6 @@ func Run(newStore NewStore) {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect([]int64{got.FileReceiveMax, got.FileSendMax}).To(Equal([]int64{512 << 10, 2 << 20}))
|
||||
})
|
||||
It("gives an old binding the add-on of its kind", func() {
|
||||
// A binding stored before connectors became add-ons has no add-on ID.
|
||||
s := newStore()
|
||||
_ = s.PutBinding(ctx, binding("old", "u1", "Old"))
|
||||
other := binding("new", "u1", "New")
|
||||
other.Kind, other.AddonID = "slack", "slack-addon"
|
||||
_ = s.PutBinding(ctx, other)
|
||||
got, _ := s.GetBinding(ctx, "old")
|
||||
Expect(got.AddonID).To(Equal("telegram"), "old binding add-on")
|
||||
list, _ := s.ListBindings(ctx, "u1")
|
||||
enabled, _ := s.EnabledBindings(ctx)
|
||||
for _, l := range [][]connectors.Binding{list, enabled} {
|
||||
byID := map[string]string{}
|
||||
for _, b := range l {
|
||||
byID[b.ID] = b.AddonID
|
||||
}
|
||||
Expect(byID).To(HaveKeyWithValue("old", "telegram"))
|
||||
Expect(byID).To(HaveKeyWithValue("new", "slack-addon"))
|
||||
}
|
||||
})
|
||||
It("keeps the capabilities, grants and paired senders", func() {
|
||||
s := newStore()
|
||||
b := binding("b1", "u1", "Bot")
|
||||
|
||||
@@ -138,7 +138,7 @@ func (r bindingRow) binding() (Binding, error) {
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
return Normalize(b), nil
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *jsonStore) GetBinding(_ context.Context, id string) (Binding, error) {
|
||||
if !ok {
|
||||
return Binding{}, ErrNotFound
|
||||
}
|
||||
return Normalize(b.clone()), nil
|
||||
return b.clone(), nil
|
||||
}
|
||||
|
||||
func (s *jsonStore) list(keep func(Binding) bool) []Binding {
|
||||
@@ -106,7 +106,7 @@ func (s *jsonStore) list(keep func(Binding) bool) []Binding {
|
||||
var out []Binding
|
||||
for _, b := range s.d.Bindings {
|
||||
if keep(b) {
|
||||
out = append(out, Normalize(b.clone()))
|
||||
out = append(out, b.clone())
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
|
||||
@@ -36,7 +36,7 @@ var _ = Describe("sealing and validating bindings", func() {
|
||||
})
|
||||
|
||||
It("validates a binding", func() {
|
||||
ok := connectors.Binding{Name: "Bot", Kind: connectors.KindTelegram, AgentID: "a", Allow: []string{"12", "-100123", "@alice_b"}, NotifyChat: "55"}
|
||||
ok := connectors.Binding{Name: "Bot", Kind: "telegram", AgentID: "a", Allow: []string{"12", "-100123", "@alice_b"}, NotifyChat: "55"}
|
||||
Expect(connectors.Validate(ok)).To(Succeed())
|
||||
// Slack names people and channels with upper-case IDs, and its names may have dots.
|
||||
slack := connectors.Binding{Name: "App", Kind: "slack", AgentID: "a", Allow: []string{"U012AB3CD", "C0123456", "@ada.l-ovelace"}, NotifyChat: "U012AB3CD"}
|
||||
@@ -46,14 +46,14 @@ var _ = Describe("sealing and validating bindings", func() {
|
||||
many[i] = "1"
|
||||
}
|
||||
for name, b := range map[string]connectors.Binding{
|
||||
"no name": {Kind: connectors.KindTelegram, AgentID: "a"},
|
||||
"long name": {Name: strings.Repeat("n", 81), Kind: connectors.KindTelegram, AgentID: "a"},
|
||||
"no name": {Kind: "telegram", AgentID: "a"},
|
||||
"long name": {Name: strings.Repeat("n", 81), Kind: "telegram", AgentID: "a"},
|
||||
"bad kind": {Name: "b", Kind: "Not A Kind", AgentID: "a"},
|
||||
"no agent": {Name: "b", Kind: connectors.KindTelegram},
|
||||
"bad allow": {Name: "b", Kind: connectors.KindTelegram, AgentID: "a", Allow: []string{"alice"}},
|
||||
"short name": {Name: "b", Kind: connectors.KindTelegram, AgentID: "a", Allow: []string{"@ab"}},
|
||||
"many": {Name: "b", Kind: connectors.KindTelegram, AgentID: "a", Allow: many},
|
||||
"bad notify": {Name: "b", Kind: connectors.KindTelegram, AgentID: "a", NotifyChat: "me"},
|
||||
"no agent": {Name: "b", Kind: "telegram"},
|
||||
"bad allow": {Name: "b", Kind: "telegram", AgentID: "a", Allow: []string{"alice"}},
|
||||
"short name": {Name: "b", Kind: "telegram", AgentID: "a", Allow: []string{"@ab"}},
|
||||
"many": {Name: "b", Kind: "telegram", AgentID: "a", Allow: many},
|
||||
"bad notify": {Name: "b", Kind: "telegram", AgentID: "a", NotifyChat: "me"},
|
||||
"lower id": {Name: "b", Kind: "slack", AgentID: "a", Allow: []string{"u012ab"}},
|
||||
"id notify": {Name: "b", Kind: "slack", AgentID: "a", NotifyChat: "@ada"},
|
||||
"negative receive limit": {Name: "b", Kind: "slack", AgentID: "a", FileReceiveMax: -1},
|
||||
@@ -73,7 +73,7 @@ var _ = Describe("sealing and validating bindings", func() {
|
||||
})
|
||||
|
||||
It("checks the reply and conversation modes", func() {
|
||||
base := connectors.Binding{Name: "Bot", Kind: connectors.KindTelegram, AgentID: "a"}
|
||||
base := connectors.Binding{Name: "Bot", Kind: "telegram", AgentID: "a"}
|
||||
for _, ok := range []connectors.Binding{
|
||||
base,
|
||||
{Name: "Bot", Kind: "telegram", AgentID: "a", Reply: connectors.ReplyOff, Conversation: connectors.ConversationChat},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package connectors binds messaging platforms to saved agents. A person who
|
||||
// messages a bot talks to the agent of the binding, in a conversation of their
|
||||
// own, and the answer goes back to the platform. The platform's API is spoken by
|
||||
// a connector add-on (the Telegram add-on first); this package keeps what is
|
||||
// a connector add-on; this package keeps what is
|
||||
// generic: the bindings, the sealed tokens, the allowlist and the sender log.
|
||||
//
|
||||
// Two stores exist: NewJSON (files under a data directory, or memory) and
|
||||
@@ -23,14 +23,6 @@ import (
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// LegacyAddon is the add-on that serves the kinds core carried before
|
||||
// connectors became add-ons. A binding of such a kind that has no add-on ID
|
||||
// belongs to it.
|
||||
var LegacyAddon = map[string]string{"telegram": "telegram"}
|
||||
|
||||
// KindTelegram is the kind core carried before connectors became add-ons.
|
||||
const KindTelegram = "telegram"
|
||||
|
||||
// Reply modes: what a sender sees while a turn runs.
|
||||
const (
|
||||
// ReplyOff shows typing only, then the answer.
|
||||
@@ -454,16 +446,6 @@ func (b Binding) Allows(senderID, username string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize gives a binding of an old kind the add-on that serves it. Stores
|
||||
// call it on every read, so a binding made before connectors became add-ons
|
||||
// keeps working once its add-on is installed.
|
||||
func Normalize(b Binding) Binding {
|
||||
if b.AddonID == "" {
|
||||
b.AddonID = LegacyAddon[b.Kind]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Binding) clone() Binding {
|
||||
b.Allow = append([]string(nil), b.Allow...)
|
||||
b.Token = append([]byte(nil), b.Token...)
|
||||
|
||||
@@ -19,7 +19,6 @@ const (
|
||||
|
||||
// BindingConnectorScope also covers background calls with fresh contexts.
|
||||
func BindingConnectorScope(b connectors.Binding) ConnectorCallScope {
|
||||
b = connectors.Normalize(b)
|
||||
return ConnectorCallScope{Purpose: ConnectorPurposeBinding, Principal: b.Owner, BindingID: b.ID, AgentID: b.AgentID, AddonID: b.AddonID, Kind: b.Kind}
|
||||
}
|
||||
func verificationScope(b connectors.Binding) ConnectorCallScope {
|
||||
@@ -46,7 +45,6 @@ func (c *Connectors) AuthorizeConnector(ctx context.Context, s ConnectorCallScop
|
||||
if s.BindingID != "" {
|
||||
var err error
|
||||
b, err = c.o.Store.GetBinding(ctx, s.BindingID)
|
||||
b = connectors.Normalize(b)
|
||||
if err != nil || b.Owner != s.Principal || b.AddonID != s.AddonID || b.Kind != s.Kind || b.AgentID != s.AgentID {
|
||||
return deny()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// ConnectorInput is the definition of a connector binding as its owner writes it.
|
||||
type ConnectorInput struct {
|
||||
// Kind is the platform of the binding, such as telegram. It is fixed when the
|
||||
// binding is made. Empty means telegram, for clients older than the add-ons.
|
||||
// Kind is the platform of the binding, as a connector add-on declares it. It
|
||||
// is required and fixed when the binding is made.
|
||||
Kind string
|
||||
Name string
|
||||
// Token is the bot token. It is needed to create a binding; an update
|
||||
@@ -145,10 +145,6 @@ func (c *Connectors) verifyToken(ctx context.Context, b connectors.Binding, toke
|
||||
return c.o.VerifyToken(ctx, b, token)
|
||||
}
|
||||
|
||||
// legacyTitles name the platforms core served before connectors became
|
||||
// add-ons, for a binding whose add-on is not installed.
|
||||
var legacyTitles = map[string]string{"telegram": "Telegram"}
|
||||
|
||||
// ConnectorInfo is a binding without its token.
|
||||
type ConnectorInfo struct {
|
||||
connectors.Binding
|
||||
@@ -178,11 +174,7 @@ func (c *Connectors) withState(ctx context.Context, i ConnectorInfo) ConnectorIn
|
||||
if _, ok := kindFor(kinds, i.Binding); ok {
|
||||
return i
|
||||
}
|
||||
title := legacyTitles[i.Kind]
|
||||
if title == "" {
|
||||
title = i.AddonID
|
||||
}
|
||||
i.Active, i.Reason = false, "install the "+title+" add-on"
|
||||
i.Active, i.Reason = false, "install the "+i.AddonID+" add-on"
|
||||
return i
|
||||
}
|
||||
|
||||
@@ -193,9 +185,6 @@ func (c *Connectors) kindTitle(ctx context.Context, b connectors.Binding) string
|
||||
return k.Title
|
||||
}
|
||||
}
|
||||
if t := legacyTitles[b.Kind]; t != "" {
|
||||
return t
|
||||
}
|
||||
return b.Kind
|
||||
}
|
||||
|
||||
@@ -205,9 +194,7 @@ func (c *Connectors) ConnectorKinds(ctx context.Context) ([]ConnectorKind, error
|
||||
return c.o.Transport.Kinds(ctx)
|
||||
}
|
||||
|
||||
// resolveAddon finds the add-on that serves a kind. A kind core served before
|
||||
// the add-ons keeps its add-on even when it is not installed, so the binding
|
||||
// can be made now and starts when the add-on is installed.
|
||||
// resolveAddon finds the add-on that serves a kind.
|
||||
func (c *Connectors) resolveAddon(ctx context.Context, kind string) (string, error) {
|
||||
kinds, err := c.o.Transport.Kinds(ctx)
|
||||
if err != nil {
|
||||
@@ -218,9 +205,6 @@ func (c *Connectors) resolveAddon(ctx context.Context, kind string) (string, err
|
||||
return k.AddonID, nil
|
||||
}
|
||||
}
|
||||
if a := connectors.LegacyAddon[kind]; a != "" {
|
||||
return a, nil
|
||||
}
|
||||
return "", connectorBad("no installed add-on serves the kind %q", kind)
|
||||
}
|
||||
|
||||
@@ -341,7 +325,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
owner := principal(user)
|
||||
kind := in.Kind
|
||||
if kind == "" {
|
||||
kind = connectors.KindTelegram
|
||||
return ConnectorInfo{}, connectorBad("a binding needs a kind")
|
||||
}
|
||||
addon, err := c.resolveAddon(ctx, kind)
|
||||
if err != nil {
|
||||
|
||||
@@ -173,7 +173,6 @@ var _ = Describe("conversion fixes", func() {
|
||||
Expect(tg.agents[alice]).To(HaveLen(1), "agents %+v", tg.agents[alice])
|
||||
Expect(tg.agents[alice][0].Name).To(Equal("Null"))
|
||||
Expect(tg.triggers[alice]).To(BeEmpty())
|
||||
Expect(tg.bindings[alice]).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("makes a malformed source a failed item and still runs the other steps", func() {
|
||||
|
||||
@@ -35,7 +35,6 @@ type fakeTargets struct {
|
||||
triggers map[string][]convert.TriggerIn
|
||||
skills map[string]convert.SkillRec
|
||||
chats map[string][]convert.ChatIn
|
||||
bindings map[string][]convert.BindingIn
|
||||
failSave map[string]bool // agent names whose save fails
|
||||
colErr error // what Collections returns
|
||||
// invalidSave names the agents whose save is refused for good; trigErr is
|
||||
@@ -56,7 +55,7 @@ type fakeTargets struct {
|
||||
func newTargets() *fakeTargets {
|
||||
return &fakeTargets{users: map[string]bool{alice: true, bob: true, "": true}, cols: map[string][]string{},
|
||||
agents: map[string][]convert.AgentIn{}, triggers: map[string][]convert.TriggerIn{}, skills: map[string]convert.SkillRec{},
|
||||
chats: map[string][]convert.ChatIn{}, bindings: map[string][]convert.BindingIn{}, failSave: map[string]bool{},
|
||||
chats: map[string][]convert.ChatIn{}, failSave: map[string]bool{},
|
||||
invalidSave: map[string]bool{},
|
||||
admins: map[string]bool{}, mcp: map[string][]convert.MCPIn{}, mcpErr: map[string]error{}}
|
||||
}
|
||||
@@ -149,12 +148,6 @@ func (f *fakeTargets) DiscardChat(_ context.Context, u, title string, created ti
|
||||
f.chats[u] = keep
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTargets) CreateBinding(_ context.Context, u string, in convert.BindingIn) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.bindings[u] = append(f.bindings[u], in)
|
||||
return nil
|
||||
}
|
||||
|
||||
func memMarks() convert.Marks {
|
||||
m, err := convert.NewJSONMarks("")
|
||||
@@ -279,17 +272,15 @@ var _ = Describe("converting the old agents", func() {
|
||||
Expect(without.agents[""][0].Name).To(Equal("Legacy"))
|
||||
})
|
||||
|
||||
It("converts only the Telegram connector and keeps its token out of the report", func() {
|
||||
It("drops every connector and keeps its token out of the report", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
poolFixture(dir)
|
||||
tg := newTargets()
|
||||
r := run(convert.Sources{StateDir: dir, AuthEnabled: true}, tg, memMarks())
|
||||
bs := tg.bindings[alice]
|
||||
Expect(bs).To(HaveLen(1), "bindings = %+v", bs)
|
||||
Expect(bs[0].Token).To(Equal(token))
|
||||
Expect(bs[0].AgentID).To(Equal("agent-1"))
|
||||
Expect(bs[0].Allow).To(Equal([]string{"@ada_l", "@bo_b"}), "want the admins that are valid usernames")
|
||||
it, ok := find(r, convert.KindConnector, "Support: slack")
|
||||
it, ok := find(r, convert.KindConnector, "Support: telegram")
|
||||
Expect(ok).To(BeTrue(), "telegram = %+v", it)
|
||||
Expect(it.Status).To(Equal(convert.StatusDropped))
|
||||
it, ok = find(r, convert.KindConnector, "Support: slack")
|
||||
Expect(ok).To(BeTrue(), "slack = %+v", it)
|
||||
Expect(it.Status).To(Equal(convert.StatusDropped))
|
||||
raw, _ := json.Marshal(r)
|
||||
@@ -427,12 +418,11 @@ var _ = Describe("converting the old agents", func() {
|
||||
it, _ := find(first, convert.KindAgent, "Plain")
|
||||
Expect(it.Status).To(Equal(convert.StatusFailed), "failed item = %+v", it)
|
||||
Expect(it.Reason).To(ContainSubstring("disk full"), "failed item = %+v", it)
|
||||
nAgents, nTriggers, nBindings := len(tg.agents[alice]), len(tg.triggers[alice]), len(tg.bindings[alice])
|
||||
nAgents, nTriggers := len(tg.agents[alice]), len(tg.triggers[alice])
|
||||
delete(tg.failSave, "Plain")
|
||||
second := run(src, tg, marks)
|
||||
Expect(tg.agents[alice]).To(HaveLen(nAgents), "a second run converted an item again")
|
||||
Expect(tg.triggers[alice]).To(HaveLen(nTriggers), "a second run converted an item again")
|
||||
Expect(tg.bindings[alice]).To(HaveLen(nBindings), "a second run converted an item again")
|
||||
Expect(tg.agents[bob]).To(HaveLen(1), "the failed agent was not tried again: %+v", tg.agents[bob])
|
||||
it, _ = find(second, convert.KindAgent, "Support")
|
||||
Expect(it.Status).To(Equal(convert.StatusSkipped), "an item already converted is %+v", it)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -266,7 +265,7 @@ func (r *run) userDirs() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// --- agents, periodic triggers and Telegram bindings ---
|
||||
// --- agents, periodic triggers and connectors ---
|
||||
|
||||
type oldAgent struct {
|
||||
user, name string
|
||||
@@ -529,8 +528,9 @@ func (r *run) periodicTrigger(ctx context.Context, owner string, a oldAgent) {
|
||||
r.add(KindTrigger, a.user, name, StatusConverted, "switched off; every "+interval+r.zoneReason())
|
||||
}
|
||||
|
||||
var usernameShape = regexp.MustCompile(`^[A-Za-z0-9_]{3,63}$`)
|
||||
|
||||
// connectors reports every connector of an old agent as dropped. A connector of
|
||||
// a messaging platform is made again on the Connectors page, where the add-on
|
||||
// that serves the platform asks for the token.
|
||||
func (r *run) connectors(ctx context.Context, owner string, a oldAgent) {
|
||||
list, _ := a.cfg["connectors"].([]any)
|
||||
for i, c := range list {
|
||||
@@ -541,34 +541,6 @@ func (r *run) connectors(ctx context.Context, owner string, a oldAgent) {
|
||||
if r.handled(ctx, key, KindConnector, a.user, name) {
|
||||
continue
|
||||
}
|
||||
if typ != "telegram" {
|
||||
r.drop(ctx, key, KindConnector, a.user, name, "the "+typ+" connector is not rebuilt")
|
||||
continue
|
||||
}
|
||||
ref, ok := r.agents[a.user+"|"+a.name]
|
||||
if !ok || ref.model == "" {
|
||||
r.drop(ctx, key, KindConnector, a.user, name, "the agent has no model, so it cannot be bound")
|
||||
continue
|
||||
}
|
||||
conf := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(str(cm, "config")), &conf)
|
||||
token := str(conf, "token")
|
||||
if token == "" {
|
||||
r.drop(ctx, key, KindConnector, a.user, name, "the connector has no token")
|
||||
continue
|
||||
}
|
||||
var allow []string
|
||||
for u := range strings.SplitSeq(str(conf, "admins"), ",") {
|
||||
u = strings.TrimPrefix(strings.TrimSpace(u), "@")
|
||||
if usernameShape.MatchString(u) {
|
||||
allow = append(allow, "@"+u)
|
||||
}
|
||||
}
|
||||
if err := r.tg.CreateBinding(ctx, owner, BindingIn{Name: a.name + " Telegram", Token: token, AgentID: ref.id, Allow: allow}); err != nil {
|
||||
r.refused(ctx, key, KindConnector, a.user, name, err)
|
||||
continue
|
||||
}
|
||||
r.mark(ctx, key, "binding")
|
||||
r.add(KindConnector, a.user, name, StatusConverted, fmt.Sprintf("switched off; %d allowed senders", len(allow)))
|
||||
r.drop(ctx, key, KindConnector, a.user, name, "the "+typ+" connector is not rebuilt: add the bot again in the Connectors page")
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
// Package convert turns what the old chat, agents, tasks, skills and Telegram
|
||||
// Package convert turns what the old chat, agents, tasks, skills and
|
||||
// connectors left on disk and in the database into the definitions of the chats:
|
||||
// saved agents, triggers, personal and shared skills, chats and connector
|
||||
// bindings. It reads the old data with its own structs, so it needs none of the
|
||||
// saved agents, triggers, personal and shared skills and chats. It reads the old data with its own structs, so it needs none of the
|
||||
// code that is removed, and it never deletes or changes what it reads.
|
||||
//
|
||||
// A run is safe to repeat. Each source item that was converted is marked, and
|
||||
// is not converted again; one that failed is tried again at the next run, with
|
||||
// its error in the report. Nothing converted runs, answers or acts by itself:
|
||||
// triggers and connector bindings are made switched off.
|
||||
// triggers are made switched off.
|
||||
package convert
|
||||
|
||||
import (
|
||||
@@ -154,12 +153,6 @@ type ChatIn struct {
|
||||
Entries []EntryIn
|
||||
}
|
||||
|
||||
// BindingIn is a Telegram binding to make, switched off.
|
||||
type BindingIn struct {
|
||||
Name, Token, AgentID string
|
||||
Allow []string
|
||||
}
|
||||
|
||||
// Targets is where converted things go. The application implements it over the
|
||||
// services of the chats; a test implements it with fakes. user is the owner as
|
||||
// the old data named it: "" is the user of a server with no accounts.
|
||||
@@ -177,7 +170,6 @@ type Targets interface {
|
||||
// DiscardChat deletes the chats of the user with this title and creation
|
||||
// time: what an import that a crash cut short left behind.
|
||||
DiscardChat(ctx context.Context, user, title string, created time.Time) error
|
||||
CreateBinding(ctx context.Context, user string, in BindingIn) error
|
||||
// IsAdmin says whether the user may have a local MCP command. A server with
|
||||
// no accounts has one user, who is the administrator.
|
||||
IsAdmin(ctx context.Context, user string) (bool, error)
|
||||
|
||||
@@ -158,31 +158,11 @@ var knownDebt = []debt{
|
||||
"kinds of change declared by the add-ons that handle them (contract change-kind registry)"},
|
||||
{entry{ui + "pages/Account.jsx", "'memory'", "the Account page has a fixed Memory tab"},
|
||||
"the Memory add-on gives the memory page through its own page declaration"},
|
||||
|
||||
// Migration of the Telegram connectors core carried before connectors became add-ons.
|
||||
{entry{"core/services/studio/connectors/types.go", `telegram`, "LegacyAddon and KindTelegram map the old kind to its add-on"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{"core/services/studio/connectorsvc.go", "Telegram", "the conversion names the old kind and its title"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{"core/services/studio/connectorsvc.go", `telegram`, "the conversion names the old kind"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{"core/services/studio/convert/run.go", "Telegram", "the conversion names the old kind and its title"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{"core/services/studio/convert/run.go", `telegram`, "the conversion names the old kind"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{"core/http/endpoints/localai/api_instructions.go", "Telegram connectors", "the note about the one-time conversion names the old kind"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
{entry{locales, "conversionPage.intro", "the conversion page names the old Telegram connectors"},
|
||||
"end of the support window of the Telegram connector conversion: delete the conversion, LegacyAddon and KindTelegram"},
|
||||
|
||||
// Shim for an old manifest of one add-on.
|
||||
{entry{"core/services/addons/apps/legacy_resource_list.go", `tables`, "builds the resource listing for manifests of the Tables add-on that predate the list declaration"},
|
||||
"the minimum supported manifest API reaches contract.MinResourceListAPI: delete the shim"},
|
||||
}
|
||||
|
||||
// maxKnownDebt is the size of knownDebt now. Lower it when an entry goes; a
|
||||
// higher number is a new special case that is excused instead of fixed.
|
||||
const maxKnownDebt = 13
|
||||
const maxKnownDebt = 5
|
||||
|
||||
var _ = Describe("the names of the shipped add-ons in core", func() {
|
||||
It("reads every add-on from its manifest and marks the ones with a role", func() {
|
||||
|
||||
@@ -4426,7 +4426,7 @@ Expected: a `failed` line: `Cannot find module '../build.mjs'`.
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"host": { "min": 1, "max": 1 },
|
||||
"api": {"min": 2, "max": 2},
|
||||
"api": { "min": 1, "max": 1 },
|
||||
"protocol": { "min": 1, "max": 1 },
|
||||
"dataFormat": { "min": 1, "max": 1 },
|
||||
"capabilities": {
|
||||
|
||||
@@ -46,7 +46,7 @@ var _ = Describe("the connector capability", func() {
|
||||
kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] }
|
||||
BeforeEach(func() { m.API = VersionRange{Min: 2, Max: 2} })
|
||||
|
||||
It("accepts a thread history with the manifest api 2", func() {
|
||||
It("accepts a thread history", func() {
|
||||
kind().History = &ConnectorHistory{Thread: true, MaxMessages: 50}
|
||||
Expect(ValidateManifest(m, host())).To(Succeed())
|
||||
Expect(kind().ServesThreadHistory()).To(BeTrue())
|
||||
|
||||
@@ -36,7 +36,7 @@ var _ = Describe("The credential slot of an add-on", func() {
|
||||
return h
|
||||
}
|
||||
|
||||
It("is valid with API 2, a connector kind and read tools", func() {
|
||||
It("is valid with a connector kind and read tools", func() {
|
||||
Expect(ValidateManifest(manifest(nil), host())).To(Succeed())
|
||||
})
|
||||
It("needs a connector kind to hold the binding", func() {
|
||||
|
||||
@@ -84,8 +84,7 @@ func CommandsTool(defs []ToolDefinition) (ToolDefinition, bool) {
|
||||
// IsolatedSkills lists the skills of an add-on that need its own commands
|
||||
// provider to report isolated execution: the document-slot skills of an
|
||||
// add-on with a commands tool, unless its descriptor says it does not provide
|
||||
// the runtime for documents. A commands tool without a descriptor (a manifest
|
||||
// of API 1) counts as providing it, so such an add-on keeps the check.
|
||||
// the runtime for documents. A commands tool without a descriptor counts as providing it, so such an add-on keeps the check.
|
||||
func IsolatedSkills(defs []ToolDefinition, skills []SkillDefinition) []string {
|
||||
tool, ok := CommandsTool(defs)
|
||||
if !ok {
|
||||
|
||||
@@ -39,9 +39,8 @@ type HostControl struct {
|
||||
// block needs no shadowed tool.
|
||||
Capability string
|
||||
// Shadows is a built-in tool the add-on must replace with a valid tool,
|
||||
// AnyShadow for any, or "" for none. A control with a Capability applies
|
||||
// it only to an add-on that does not declare the block: a manifest
|
||||
// written before the block existed keeps its attachment.
|
||||
// AnyShadow for any, or "" for none. A control with a Capability is not
|
||||
// attached by a shadowed tool: the add-on must declare the block.
|
||||
Shadows string
|
||||
}
|
||||
|
||||
@@ -59,7 +58,7 @@ func declares(c Capabilities, name string) bool {
|
||||
|
||||
// HostControls is the closed registry of host controls.
|
||||
var HostControls = []HostControl{
|
||||
{ID: ControlNetworkPolicy, Grant: "egress.check", Capability: CapabilityNetwork, Shadows: "bash"},
|
||||
{ID: ControlNetworkPolicy, Grant: "egress.check", Capability: CapabilityNetwork},
|
||||
{ID: ControlToolProviders, Shadows: AnyShadow},
|
||||
}
|
||||
|
||||
@@ -115,8 +114,9 @@ func grantCovers(grants []string, op string) bool {
|
||||
// EntitledControls returns the controls of the manifest that the host draws
|
||||
// for it: those declared in ui.controls whose grant is among grants (the
|
||||
// operation prefixes the host setting gives this add-on) and that the add-on
|
||||
// attaches: it declares the control's capability block, or it replaces the
|
||||
// built-in tool the control names with a valid tool. The order of the manifest is
|
||||
// attaches: it declares the control's capability block, or, for a control
|
||||
// without one, it replaces the built-in tool the control names with a valid
|
||||
// tool. The order of the manifest is
|
||||
// kept. A control the add-on is not entitled to is dropped, never an error:
|
||||
// an administrator can remove a grant at any time.
|
||||
func EntitledControls(m Manifest, grants []string) []string {
|
||||
@@ -135,7 +135,10 @@ func EntitledControls(m Manifest, grants []string) []string {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c.Capability != "" && declares(m.Capabilities, c.Capability):
|
||||
case c.Capability != "":
|
||||
if !declares(m.Capabilities, c.Capability) {
|
||||
continue
|
||||
}
|
||||
case c.Shadows == "":
|
||||
case c.Shadows == AnyShadow:
|
||||
if len(shadows) == 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ package contract
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,11 +17,16 @@ var _ = Describe("Host controls", func() {
|
||||
// runner is an add-on that runs commands, with the controls it asks for.
|
||||
// Any ID works: the host never looks at it.
|
||||
runner := func(id string, controls ...string) Manifest {
|
||||
return Manifest{SchemaVersion: 1, ID: id, Version: "1.0.0", Executable: "bin/" + id, OS: "linux", Arch: "amd64",
|
||||
m := Manifest{SchemaVersion: 1, ID: id, Version: "1.0.0", Executable: "bin/" + id, OS: "linux", Arch: "amd64",
|
||||
Host: VersionRange{Min: 1, Max: 1}, API: VersionRange{Min: 2, Max: 2}, Protocol: VersionRange{Min: 1, Max: 1}, DataFormat: VersionRange{Min: 1, Max: 1},
|
||||
Capabilities: Capabilities{Actions: true, Tools: []string{"bash"},
|
||||
ToolDefinitions: []ToolDefinition{{Name: "bash", Description: "Run a command.", Kind: ToolKindAction, InputSchema: json.RawMessage(bashSchema), Shadows: "bash"}},
|
||||
UI: &UI{Entry: "ui/main.js", SDK: VersionRange{Min: 2, Max: 2}, Controls: controls}}}
|
||||
if slices.Contains(controls, ControlNetworkPolicy) {
|
||||
m.Capabilities.Network = &NetworkCapability{Kind: NetworkKindEgressCheck}
|
||||
m.Capabilities.HostOperations = &HostOperations{Optional: []string{"egress.check"}}
|
||||
}
|
||||
return m
|
||||
}
|
||||
host := HostCompatibility{Host: 1, API: 2, Protocol: 1, SDK: HostSDKVersion, DataFormat: 1, OS: "linux", Arch: "amd64"}
|
||||
|
||||
@@ -48,15 +54,15 @@ var _ = Describe("Host controls", func() {
|
||||
Expect(m.Capabilities.UI.Controls).To(Equal([]string{ControlToolProviders}))
|
||||
})
|
||||
|
||||
It("draws the network policy only for an add-on granted egress that runs bash", func() {
|
||||
It("draws the network policy only for an add-on granted egress that declares a network", func() {
|
||||
Expect(EntitledControls(runner("sandbox", ControlNetworkPolicy), []string{"workspace.", "egress."})).To(Equal([]string{ControlNetworkPolicy}))
|
||||
// A narrower grant that still covers the check is enough.
|
||||
Expect(EntitledControls(runner("sandbox", ControlNetworkPolicy), []string{"egress.check"})).To(Equal([]string{ControlNetworkPolicy}))
|
||||
Expect(EntitledControls(runner("sandbox", ControlNetworkPolicy), []string{"workspace."})).To(BeEmpty())
|
||||
Expect(EntitledControls(runner("sandbox", ControlNetworkPolicy), nil)).To(BeEmpty())
|
||||
m := runner("sandbox", ControlNetworkPolicy)
|
||||
m.Capabilities.ToolDefinitions[0].Shadows = ""
|
||||
Expect(EntitledControls(m, []string{"egress."})).To(BeEmpty())
|
||||
m.Capabilities.Network = nil
|
||||
Expect(EntitledControls(m, []string{"egress."})).To(BeEmpty(), "a bash tool alone does not attach the control")
|
||||
})
|
||||
|
||||
It("serves a second add-on with the same controls, by its grant and not by its name", func() {
|
||||
@@ -69,10 +75,11 @@ var _ = Describe("Host controls", func() {
|
||||
It("draws the tool providers for an add-on that replaces any built-in tool", func() {
|
||||
web := runner("web", ControlToolProviders, ControlNetworkPolicy)
|
||||
web.Capabilities.Actions = false
|
||||
web.Capabilities.Network, web.Capabilities.HostOperations = nil, nil
|
||||
web.Capabilities.Tools = []string{"fetch"}
|
||||
web.Capabilities.ToolDefinitions = []ToolDefinition{{Name: "fetch", Description: "Read a page.", Kind: ToolKindRead, InputSchema: json.RawMessage(fetchSchema), Shadows: "web_fetch"}}
|
||||
Expect(ValidateManifest(web, host)).To(Succeed())
|
||||
// The network policy needs bash, even with the grant.
|
||||
// The network policy needs a declared network, even with the grant.
|
||||
Expect(EntitledControls(web, []string{"egress."})).To(Equal([]string{ControlToolProviders}))
|
||||
web.Capabilities.ToolDefinitions[0].Shadows = ""
|
||||
Expect(EntitledControls(web, []string{"egress."})).To(BeEmpty())
|
||||
|
||||
@@ -128,8 +128,7 @@ type Capabilities struct {
|
||||
// (see network.go). It attaches the network-policy host control.
|
||||
Network *NetworkCapability `json:"network,omitempty"`
|
||||
// ContextFiles name small index files of the user's home that the host
|
||||
// loads into the prompt of a chat (see contextfiles.go). They need
|
||||
// manifest API 2.
|
||||
// loads into the prompt of a chat (see contextfiles.go).
|
||||
ContextFiles []ContextFile `json:"contextFiles,omitempty"`
|
||||
|
||||
// Credentials declare the secret that the host keeps for the person and
|
||||
@@ -157,7 +156,7 @@ type ResourceTools struct {
|
||||
// backing data. It is reachable only from the UI-only delete endpoint.
|
||||
Delete string `json:"delete,omitempty"`
|
||||
// List asks the host for a read tool that lists the resources the caller
|
||||
// may use (see ResourceList). It needs manifest API 2.
|
||||
// may use (see ResourceList).
|
||||
List *ResourceList `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
var _ = Describe("The network capability", func() {
|
||||
const bashSchema = `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`
|
||||
|
||||
// withNetwork is a manifest of API 2 that asks for the network-policy
|
||||
// withNetwork is a manifest that asks for the network-policy
|
||||
// control. It runs no command tool unless shadowsBash is set, so what is
|
||||
// drawn can only come from the declared capability.
|
||||
withNetwork := func(n *NetworkCapability, shadowsBash bool) Manifest {
|
||||
@@ -42,7 +42,7 @@ var _ = Describe("The network capability", func() {
|
||||
declared := &NetworkCapability{Kind: NetworkKindEgressCheck, Title: "Runbox network",
|
||||
Presets: []NetworkPreset{{ID: "off", Title: "Closed"}, {ID: "all", Title: "Open"}}}
|
||||
|
||||
It("is valid when the manifest accepts API 2 and lists the egress check", func() {
|
||||
It("is valid when the manifest lists the egress check", func() {
|
||||
Expect(ValidateManifest(withNetwork(declared, false), host())).To(Succeed())
|
||||
})
|
||||
|
||||
@@ -85,10 +85,8 @@ var _ = Describe("The network capability", func() {
|
||||
Expect(EntitledControls(withNetwork(declared, false), []string{"workspace."})).To(BeEmpty())
|
||||
Expect(EntitledControls(withNetwork(declared, true), nil)).To(BeEmpty())
|
||||
})
|
||||
It("is drawn for a manifest that declares no network and replaces bash, as before", func() {
|
||||
Expect(EntitledControls(withNetwork(nil, true), grants)).To(Equal([]string{ControlNetworkPolicy}))
|
||||
})
|
||||
It("is not drawn for a manifest that declares no network and does not replace bash", func() {
|
||||
It("is not drawn for a manifest that declares no network, whether or not it replaces bash", func() {
|
||||
Expect(EntitledControls(withNetwork(nil, true), grants)).To(BeEmpty())
|
||||
Expect(EntitledControls(withNetwork(nil, false), grants)).To(BeEmpty())
|
||||
})
|
||||
It("does not read the add-on ID", func() {
|
||||
@@ -120,11 +118,10 @@ var _ = Describe("The network capability", func() {
|
||||
Expect(m.Capabilities.Network).To(Equal(&NetworkCapability{Kind: NetworkKindEgressCheck, Title: "Sandbox"}))
|
||||
Expect(EntitledControls(m, []string{"egress."})).To(ContainElement(ControlNetworkPolicy))
|
||||
})
|
||||
It("keeps the control through its bash tool when an older manifest declares no network", func() {
|
||||
It("loses the control when it declares no network", func() {
|
||||
m := load()
|
||||
m.Capabilities.Network = nil
|
||||
Expect(EntitledControls(m, []string{"egress."})).To(ContainElement(ControlNetworkPolicy))
|
||||
Expect(EntitledControls(m, nil)).NotTo(ContainElement(ControlNetworkPolicy))
|
||||
Expect(EntitledControls(m, []string{"egress."})).NotTo(ContainElement(ControlNetworkPolicy))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,8 +32,7 @@ type SkillDefinition struct {
|
||||
// add-on skill.
|
||||
Shadows string `json:"shadows,omitempty"`
|
||||
// Slot describes the slot this skill serves: the files it covers and the
|
||||
// words to say about them (see SlotDefinition). It needs Shadows and
|
||||
// manifest API 2. A skill that shadows a default slot may leave it out.
|
||||
// words to say about them (see SlotDefinition). It needs Shadows. A skill that shadows a default slot may leave it out.
|
||||
Slot *SlotDefinition `json:"slot,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ var defaultSlots = sync.OnceValue(func() []Slot {
|
||||
|
||||
// DefaultSlots are the slots the host knows without any declaration, read
|
||||
// from data. A skill that shadows one of them and declares no slot of its own
|
||||
// (a manifest of API 1) is served by these.
|
||||
// is served by these.
|
||||
func DefaultSlots() []Slot { return slices.Clone(defaultSlots()) }
|
||||
|
||||
// DefaultSlot returns the default slot called name.
|
||||
|
||||
Reference in new issue
Block a user