A saved agent uses only the ones you pick for it.Start a chat}>It loaded in 2.1 s.
- >}>You have 0.9.1. It asks for one more permission.
+ >}>You have 0.9.1. It asks for one more permission.Open the run}>in “Digest of the week”, 12 minutes ago. The model stopped before it answered.
diff --git a/core/services/studio/providerguard/guard_test.go b/core/services/studio/providerguard/guard_test.go
index 0bf8226bd3..c248003cb1 100644
--- a/core/services/studio/providerguard/guard_test.go
+++ b/core/services/studio/providerguard/guard_test.go
@@ -1,16 +1,35 @@
package providerguard_test
-// The test keeps core free of the names of shipped add-ons that provide a
-// generic role: the text that a person or a model reads must come from the
-// provider's declared title, or be generic.
+// Core must not name a shipped add-on. The rules of the add-on architecture are
+// that core has generic capabilities and no special case for one add-on: no id
+// compared with a string, no title in a sentence that a person or a model
+// reads. This guard enforces that on four surfaces: the string literals of the
+// Go code of core and pkg, the source of the React UI, the locale files of the
+// UI and the documentation pages that describe core.
+//
+// It is driven by addons/*/manifest.json, so a new add-on is covered the day
+// its manifest lands. Two lists excuse a hit, and both are exact:
+//
+// - allowed: the name is not the add-on (a third-party service such as the
+// OAuth login, a release source or a token format; an ordinary word).
+// - knownDebt: the name IS a special case that still has to go. Each entry
+// says what removes it. The list may only shrink: a new hit fails, a hit
+// that is fixed makes its entry fail until the entry is deleted, and
+// maxKnownDebt falls with the list.
+//
+// What is matched (never loosened to hide a hit):
+//
+// - the id of an add-on as a whole string literal ("sandbox"), or as the
+// prefix of a tool name ("tables__list");
+// - a declared title as a whole word, case sensitive ("Sandbox", "Short
+// memory"), in every locale the manifest gives.
+//
+// Only add-ons with a role are matched (see providerguard.Addon.Role). Face,
+// voice, talk, studio, fine-tuning and quantization declare no role: each is a
+// page and a service that core owns, and its name is an ordinary word of the
+// product, so the guard cannot tell the add-on from the feature.
import (
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "path/filepath"
- "regexp"
"strconv"
"strings"
@@ -19,115 +38,245 @@ import (
. "github.com/onsi/gomega"
)
-// matcher finds a name as a whole word, case sensitive: "Sandbox" is the
-// add-on, "sandbox" the ordinary noun for an isolated environment.
-func matcher(names []string) *regexp.Regexp {
- quoted := make([]string, len(names))
- for i, n := range names {
- quoted[i] = regexp.QuoteMeta(n)
- }
- return regexp.MustCompile(`(^|[^\w-])(` + strings.Join(quoted, "|") + `)($|[^\w-])`)
+// entry excuses the findings of one file whose text holds a substring. A file
+// that ends in "/" stands for every file under that folder. For a locale
+// file the text is " = ", so a key excuses it in every
+// language.
+type entry struct{ file, text, why string }
+
+// debt is an entry for a special case that is still in the code.
+type debt struct {
+ entry
+ removedBy string
}
-// allowed lists the string literals that may name a provider, by the file
-// they are in and the text, with the reason. Each one must still be found.
-var allowed = []struct{ file, text, why string }{
- {"core/http/endpoints/localai/api_instructions.go", "Telegram connectors", "the one-time conversion of the legacy Telegram connectors"},
- {"core/schema/chats.go", `example:"Sandbox"`, "a sample value in the API documentation"},
- {"core/services/addons/tools/consumers.go", "such as Telegram", "an example of a connector platform in a note for add-on authors"},
+func (e entry) covers(f finding) bool {
+ if e.file != f.file && !(strings.HasSuffix(e.file, "/") && strings.HasPrefix(f.file, e.file)) {
+ return false
+ }
+ return strings.Contains(f.text, e.text)
+}
+
+const (
+ ui = "core/http/react-ui/src/"
+ locales = "core/http/react-ui/public/locales/"
+)
+
+var allowed = []entry{
+ // Third-party services: the login, the release source, the token issuer.
+ {"core/cli/run.go", "GitHub OAuth App", "help of the GitHub OAuth login: GitHub the service, not the add-on"},
+ {"core/http/auth/models.go", `github`, "the id of the GitHub OAuth login provider"},
+ {ui + "pages/Login.jsx", "github", "the GitHub OAuth login button: the service, not the add-on"},
+ {ui + "pages/Login.jsx", "'GitHub'", "the GitHub OAuth login button: the service, not the add-on"},
+ {ui + "App.jsx", `name="github"`, "the GitHub glyph of the footer link to the project"},
+ {ui + "utils/faIcon.js", "github:", "maps the glyph name of the GitHub brand icon"},
+ {locales, "login.signInWithGitHub", "the GitHub OAuth login button"},
+ {locales, "footer.github", "the link to the project on GitHub"},
+ {locales, "egress.presetHint.registries", "GitHub releases are a package source that commands reach"},
+ {"core/services/routing/piipattern/builtins.go", "GitHub access token", "secret pattern: GitHub issues the token"},
+ {"core/services/routing/piipattern/builtins.go", "GitHub fine-grained personal access token", "secret pattern: GitHub issues the token"},
+ {"core/services/routing/piipattern/builtins.go", "Slack token", "secret pattern: Slack issues the token"},
{"core/services/routing/piipattern/builtins.go", "JSON Web Token", "the name of a token format"},
+ {"pkg/addons/packaging/catalog.go", "GitHub owner/repository", "release signing: GitHub Actions is the build system"},
+ {"pkg/addons/packaging/catalog.go", "GitHub Actions OIDC issuer", "release signing: GitHub Actions is the build system"},
+ {"pkg/addons/packaging/channel.go", "GitHub event name", "release tooling: GitHub Actions is the build system"},
+ {"pkg/addons/packaging/registry.go", "GitHub event name", "release tooling: GitHub Actions is the build system"},
+ {"docs/content/reference/binaries.md", "GitHub", "release downloads: the service"},
+ {"docs/content/reference/cli-reference.md", "GitHub OAuth", "the options of the GitHub OAuth login"},
+ {"docs/content/advanced/private-sources.md", "GitHub", "private gallery sources on the service"},
+ {"docs/content/getting-started/linux.md", "GitHub Releases", "release downloads: the service"},
+ {"docs/content/getting-started/macos.md", "GitHub releases", "release downloads: the service"},
+ {"docs/content/getting-started/models.md", "GitHub", "release downloads: the service"},
+ {"docs/content/getting-started/troubleshooting.md", "GitHub", "the issue tracker of the project: the service"},
+
+ // Chat platforms named as examples of what a connector carries. The
+ // platform is a third-party service; the add-on is not named as one.
+ {"core/cli/run.go", "Telegram, Slack", "help text that gives chat platforms as examples"},
+ {"core/http/auth/features.go", "Telegram bots", "the name of a feature permission; the platform is the content"},
+ {ui + "components/users/UsersPanel.jsx", "Telegram bots", "the name of a feature permission; the platform is the content"},
+ {"core/services/addons/tools/consumers.go", "such as Telegram", "an example of a chat platform in a note for add-on authors"},
+ {locales, "mcpPage.family", "chat platforms as examples of messaging connectors"},
+ {locales, "connectorsPage.noAddonHint", "a chat platform as an example of what a connector add-on carries"},
+ {"docs/content/reference/cli-reference.md", "(Telegram, Slack)", "help text that gives chat platforms as examples"},
+ {"docs/content/getting-started/_index.en.md", "Telegram", "a chat platform as an example of where an agent can answer"},
+ {"docs/content/getting-started/first-agent.md", "Telegram", "a chat platform as an example of where an agent can answer"},
+
+ // Ordinary words that equal an id or a title.
+ {"core/schema/chats.go", `example:"Sandbox"`, "a sample value in the API documentation"},
+ {locales + "de/pages.json", "Sie öffnen sich hier in einer Sandbox", "German for the isolated frame of a page: the ordinary noun"},
+ {locales + "de/workspace.json", "types.sheet = Tabelle", "German for a sheet: the translated title and the noun coincide"},
+ {"core/application/studio_service.go", `memory`, "the in-memory kind of the store"},
+ {"core/application/studio_store.go", `memory`, "the in-memory kind of the store"},
+ {"core/services/studio/nibharness/tools.go", `memory`, "the built-in memory tool of the agent engine"},
+ {"pkg/xsysinfo/gpu.go", `memory`, "a GPU memory counter"},
+ {"core/http/auth/usage.go", `web`, "usage source of a request from the web UI session"},
+ {"core/services/studio/builtins/builtins.go", `web`, "the id of the built-in Web tool group of chats"},
{"core/services/studio/webtools.go", "Web search", "the label of the built-in web search tool"},
{"core/services/studio/webtools.go", "Web:", "the built-in Web tool group, the web as a noun"},
- {"core/services/studio/connectorsvc.go", "Telegram", "one-time migration of the legacy Telegram connector settings"},
- {"core/services/studio/convert/run.go", "Telegram", "one-time migration of the legacy Telegram bindings"},
- {"core/cli/run.go", "Telegram, Slack", "help text that gives the connector platforms as examples"},
- {"core/http/auth/features.go", "Telegram bots", "the name of a feature permission; the platform is the content"},
- {"core/cli/run.go", "GitHub OAuth App", "help text of the OAuth login: GitHub the service, not the add-on"},
- {"core/services/routing/piipattern/builtins.go", "GitHub access token", "a secret pattern: GitHub is the issuer of the token, not a provider"},
- {"core/services/routing/piipattern/builtins.go", "GitHub fine-grained personal access token", "a secret pattern: GitHub is the issuer of the token, not a provider"},
- {"pkg/addons/packaging/catalog.go", "GitHub owner/repository", "release signing: GitHub Actions is the build system, not a provider"},
- {"pkg/addons/packaging/catalog.go", "GitHub Actions OIDC issuer", "release signing: GitHub Actions is the build system, not a provider"},
- {"pkg/addons/packaging/channel.go", "GitHub event name", "release tooling: GitHub Actions is the build system, not a provider"},
- {"pkg/addons/packaging/registry.go", "GitHub event name", "release tooling: GitHub Actions is the build system, not a provider"},
- {"core/services/routing/piipattern/builtins.go", "Slack token", "a secret pattern: Slack is the issuer of the token, not a provider"},
+ {"pkg/web/search/brave.go", `web`, "the kind of search of the Brave API"},
+ {locales, "tools.builtin.web", "the label of the built-in Web tool group"},
+ {locales, "settingsTools.labels.web_search", "the label of the built-in web search tool"},
+ {locales, "usage.sources.", "the usage source of a request from the web UI"},
+ {locales, "sources.title = Web sources", "the web as a kind of source of a collection"},
+ {locales, "Im Web gesucht", "the web as a noun"},
+ {locales, "Durchsucht das Web", "the web as a noun"},
+ {ui + "hooks/useAudioPeaks.js", "Web Audio API", "a browser API"},
+ {ui + "hooks/useToolGroups.js", "'web'", "the id of the built-in Web tool group of chats"},
+ {ui + "utils/toolLabels.js", "'web'", "reads the word web in the name of a tool"},
+ {ui + "utils/traffic.js", "'web'", "usage source of a request from the web UI session"},
+ {ui + "utils/chatApps.js", "'web'", "the platform of a chat made in the web UI"},
+ {ui + "utils/settingsSchema.js", "'memory'", "the settings group of model memory (eviction, watchdog)"},
+ {ui + "utils/settingsSchema.js", "Web ", "the labels of the built-in web tool settings"},
+ {ui + "utils/operateStatus.js", "'memory'", "the key of the host memory pressure check"},
+ {ui + "pages/OperateOverview.jsx", "'memory'", "the key of the host memory pressure check"},
+ {ui + "utils/tools.js", "'memory'", "the id of the memory check of the model tools"},
+ {ui + "pages/Settings.jsx", "'memory'", "the settings group of model memory (eviction, watchdog)"},
+ {ui + "", `name="memory"`, "the memory glyph"},
+ {ui + "", `icon="memory"`, "the memory glyph"},
+ {ui + "components/StarterModels.jsx", "'memory'", "the memory glyph"},
+ {ui + "addons/sdk.js", "'memory'", "the memory glyph in the list of glyphs that an add-on may use"},
+ {ui + "components/home/homeActions.js", "'memory'", "the /memory command opens the collections that agents use as memory"},
+ {ui + "pages/Home.jsx", "case 'memory'", "the /memory command opens the collections that agents use as memory"},
+ {ui + "components/agents/AgentBits.jsx", "'memory'", "the collections that an agent uses as memory"},
+ {ui + "pages/AgentPage.jsx", `"memory"`, "the collections that an agent uses as memory"},
+ {"docs/content/advanced/reverse-proxy-tls.md", "Web UI", "the web UI of LocalAI"},
+ {"docs/content/getting-started/quickstart.md", "Web Interface", "the web UI of LocalAI"},
+ {"docs/content/getting-started/troubleshooting.md", "Web UI", "the web UI of LocalAI"},
+ {"docs/content/getting-started/troubleshooting.md", "Web ", "the web UI of LocalAI"},
}
-var _ = Describe("the strings of core", func() {
- It("reads the providers from the manifests of addons/", func() {
- names, err := providerguard.Names()
+var knownDebt = []debt{
+ // propose_change kinds and the Memory/ root: core still knows the Memory
+ // add-on through the kind "memory" and the capability that guards it.
+ {entry{"core/services/studio/changetools.go", `memory`, "the kind \"memory\" of propose_change names the Memory add-on"},
+ "kinds of change declared by the add-ons that handle them (contract change-kind registry)"},
+ {entry{"pkg/addons/contract/changeproposal.go", `memory`, "the contract lists the fixed kinds of change, memory first"},
+ "kinds of change declared by the add-ons that handle them (contract change-kind registry)"},
+ {entry{"pkg/addons/contract/contributions.go", `memory`, "the contract names the Memory root"},
+ "context files declared by the add-on with their own root (no fixed Memory/ root in core)"},
+ {entry{ui + "utils/changeProposal.js", "'memory'", "the card has wording for the fixed kinds of change"},
+ "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
+
+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() {
+ addons, err := providerguard.Addons()
Expect(err).NotTo(HaveOccurred())
- Expect(len(names)).To(BeNumerically(">=", 4), "expected the provider add-ons of addons/")
+ role := map[string]bool{}
+ for _, a := range addons {
+ role[a.ID] = a.Role
+ }
+ Expect(len(addons)).To(BeNumerically(">=", 10))
+ for _, id := range []string{"sandbox", "office", "web", "slack", "telegram", "github", "tables", "memory"} {
+ Expect(role).To(HaveKeyWithValue(id, true), "%s gives the host something to act on", id)
+ }
+ for _, id := range []string{"face", "voice", "talk", "studio", "fine-tuning", "quantization"} {
+ Expect(role).To(HaveKeyWithValue(id, false), "%s is a page and a service of core", id)
+ }
})
- It("matches a name as a whole word and never the ordinary noun", func() {
- re := matcher([]string{"Sandbox", "Simple Office"})
- Expect(re.MatchString("Install the Sandbox to isolate")).To(BeTrue())
- Expect(re.MatchString("Open Simple Office")).To(BeTrue())
- Expect(re.MatchString("curl works in the sandbox")).To(BeFalse())
- Expect(re.MatchString("a Sandboxed frame")).To(BeFalse())
+ It("matches an id as a whole string and a title as a whole word, case sensitive", func() {
+ n := newNamer([]providerguard.Addon{
+ {ID: "sandbox", Titles: []string{"Sandbox"}, Role: true},
+ {ID: "office", Titles: []string{"Simple Office"}, Role: true},
+ {ID: "tables", Titles: []string{"Tables", "Tabelle"}, Role: true},
+ {ID: "voice", Titles: []string{"Voice"}},
+ })
+ for _, s := range []string{"sandbox", "tables__list", "Install the Sandbox", "Open Simple Office.", "Tabelle", "(Sandbox)"} {
+ Expect(n.find(s)).NotTo(BeEmpty(), s)
+ }
+ for _, s := range []string{"curl works in the sandbox", "a Sandboxed frame", "sandbox-runner", "Sandbox-less", "sandboxes", "Voice", "voice", "tables_list", "stables"} {
+ Expect(n.find(s)).To(BeEmpty(), s)
+ }
+ Expect(n.findQuoted(`const id = 'office'`)).To(Equal("office"))
+ Expect(n.findQuoted(`const office = 1`)).To(BeEmpty())
})
- It("names no provider add-on in a user-facing or model-facing string outside the allow-list", func() {
+ It("keeps the debt small and explicit", func() {
+ Expect(len(knownDebt)).To(BeNumerically("<=", maxKnownDebt), "knownDebt may only shrink: fix the hit instead of adding an entry")
+ for _, d := range knownDebt {
+ Expect(d.why).NotTo(BeEmpty())
+ Expect(d.removedBy).NotTo(BeEmpty(), "%s %s: say what removes this entry", d.file, d.text)
+ }
+ for _, a := range allowed {
+ Expect(a.why).NotTo(BeEmpty())
+ }
+ })
+
+ It("names no add-on in Go strings, UI source, locales and core docs, except the listed ones", func() {
root, err := providerguard.RepoRoot()
Expect(err).NotTo(HaveOccurred())
- names, err := providerguard.Names()
+ addons, err := providerguard.Addons()
Expect(err).NotTo(HaveOccurred())
- re := matcher(names)
- used := map[int]bool{}
- var found []string
- for _, dir := range []string{"core", "pkg"} {
- err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() {
- // The sandbox backend is the add-on's own code that lives
- // in core for now; the other trees are not ours.
- switch d.Name() {
- case "node_modules", "vendor", "testdata", "sandbox", "react-ui":
- return filepath.SkipDir
- }
- return nil
- }
- if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
- return nil
- }
- fset := token.NewFileSet()
- f, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
- if perr != nil {
- return nil
- }
- rel, _ := filepath.Rel(root, path)
- rel = filepath.ToSlash(rel)
- ast.Inspect(f, func(n ast.Node) bool {
- lit, ok := n.(*ast.BasicLit)
- if !ok || lit.Kind != token.STRING {
- return true
- }
- s, uerr := strconv.Unquote(lit.Value)
- if uerr != nil {
- s = lit.Value
- }
- hit := re.FindStringSubmatch(s)
- if hit == nil {
- return true
- }
- for i, a := range allowed {
- if a.file == rel && strings.Contains(s, a.text) {
- used[i] = true
- return true
- }
- }
- found = append(found, rel+":"+strconv.Itoa(fset.Position(lit.Pos()).Line)+": "+hit[2]+": "+strconv.Quote(s))
- return true
- })
- return nil
- })
+ n := newNamer(addons)
+
+ var found []finding
+ add := func(fs []finding, err error) {
Expect(err).NotTo(HaveOccurred())
+ found = append(found, fs...)
}
- Expect(found).To(BeEmpty(), "build the text from the provider's declared title or word it for any provider")
+ // The sandbox backend is the code of the Sandbox add-on that lives in
+ // core for now; it is skipped as a whole (audit item 1.6).
+ add(scanGo(root, n, []string{"core/services/studio/sandbox"}))
+ add(scanUI(root, n))
+ add(scanLocales(root, n))
+ add(scanDocs(root, n, []string{"docs/content/reference", "docs/content/advanced", "docs/content/getting-started"}))
+
+ usedAllowed := make([]bool, len(allowed))
+ usedDebt := make([]bool, len(knownDebt))
+ var unexcused []string
+ for _, f := range found {
+ excused := false
+ for i, a := range allowed {
+ if a.covers(f) {
+ usedAllowed[i], excused = true, true
+ }
+ }
+ for i, d := range knownDebt {
+ if d.covers(f) {
+ usedDebt[i], excused = true, true
+ }
+ }
+ if !excused {
+ loc := f.file
+ if f.line > 0 {
+ loc += ":" + strconv.Itoa(f.line)
+ }
+ unexcused = append(unexcused, loc+": "+f.name+": "+strconv.Quote(f.text))
+ }
+ }
+ Expect(unexcused).To(BeEmpty(), "core names an add-on: declare it in the manifest and read it, or word it for any provider; if the name is not the add-on, add it to allowed with the reason")
for i, a := range allowed {
- Expect(used[i]).To(BeTrue(), "allow-list entry %s %q (%s) no longer matches; remove it", a.file, a.text, a.why)
+ Expect(usedAllowed[i]).To(BeTrue(), "allowed entry %s %q (%s) matches nothing; remove it", a.file, a.text, a.why)
+ }
+ for i, d := range knownDebt {
+ Expect(usedDebt[i]).To(BeTrue(), "knownDebt entry %s %q is fixed; delete it and lower maxKnownDebt", d.file, d.text)
}
})
})
diff --git a/core/services/studio/providerguard/names.go b/core/services/studio/providerguard/names.go
index d69f12f4ed..f47d1e3a7f 100644
--- a/core/services/studio/providerguard/names.go
+++ b/core/services/studio/providerguard/names.go
@@ -1,5 +1,5 @@
// Package providerguard reads the shipped add-ons so tests can check that core
-// names none of the ones that provide a generic role. Only tests import it.
+// names none of them. Only tests import it.
package providerguard
import (
@@ -36,18 +36,35 @@ type manifest struct {
SkillDefinitions []struct{ Shadows string } `json:"skillDefinitions"`
Connector json.RawMessage `json:"connector"`
ExecutionProv json.RawMessage `json:"executionProvider"`
+ AdminTools json.RawMessage `json:"adminTools"`
+ Credentials json.RawMessage `json:"credentials"`
+ ContextFiles json.RawMessage `json:"contextFiles"`
UI struct {
Title json.RawMessage `json:"title"`
} `json:"ui"`
} `json:"capabilities"`
}
-// Names reads addons/*/manifest.json and returns the names of the
-// add-ons that provide a generic role (they shadow a built-in tool or skill,
-// serve as a connector, or declare the execution provider): the capitalised
-// id and every declared title. The other add-ons (voice, face, tables ...)
-// are ordinary words of the model features that core owns.
-func Names() ([]string, error) {
+// Addon is one shipped add-on as its manifest declares it.
+type Addon struct {
+ ID string
+ // Titles are the declared display titles: one string, or one per locale.
+ Titles []string
+ // Role is true when the add-on gives the host something to act on: it is
+ // a connector or the execution provider, it shadows a built-in tool or
+ // skill, or it declares admin tools, credentials or context files. Core
+ // can special-case such an add-on, so the guard matches its id and
+ // titles. The others (face, voice, talk, studio, fine-tuning,
+ // quantization) are a page and a service that core owns; their names are
+ // ordinary words of the product ("Voice", "Quantization") and are not
+ // matched.
+ Role bool
+}
+
+// Addons reads addons/*/manifest.json and returns every shipped add-on with
+// its declared titles, sorted by id. The guard is driven by this list, so a
+// new add-on is covered the day its manifest lands, with no list to update.
+func Addons() ([]Addon, error) {
root, err := RepoRoot()
if err != nil {
return nil, err
@@ -56,7 +73,7 @@ func Names() ([]string, error) {
if err != nil || len(files) == 0 {
return nil, fmt.Errorf("no add-on manifests found: %v", err)
}
- set := map[string]bool{}
+ var out []Addon
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
@@ -66,27 +83,56 @@ func Names() ([]string, error) {
if json.Unmarshal(raw, &m) != nil || m.ID == "" {
continue
}
- role := len(m.Capabilities.Connector) > 0 && string(m.Capabilities.Connector) != "null"
- role = role || (len(m.Capabilities.ExecutionProv) > 0 && string(m.Capabilities.ExecutionProv) != "null")
+ a := Addon{ID: m.ID}
+ present := func(r json.RawMessage) bool { return len(r) > 0 && string(r) != "null" }
+ a.Role = present(m.Capabilities.Connector) || present(m.Capabilities.ExecutionProv) ||
+ present(m.Capabilities.AdminTools) || present(m.Capabilities.Credentials) || present(m.Capabilities.ContextFiles)
for _, d := range m.Capabilities.ToolDefinitions {
- role = role || d.Shadows != ""
+ a.Role = a.Role || d.Shadows != ""
}
for _, d := range m.Capabilities.SkillDefinitions {
- role = role || d.Shadows != ""
+ a.Role = a.Role || d.Shadows != ""
}
- if !role {
- continue
+ seen := map[string]bool{}
+ add := func(t string) {
+ if t != "" && !seen[t] {
+ seen[t] = true
+ a.Titles = append(a.Titles, t)
+ }
}
- set[strings.ToUpper(m.ID[:1])+m.ID[1:]] = true
var title string
var titles map[string]string
- if json.Unmarshal(m.Capabilities.UI.Title, &title) == nil && title != "" {
- set[title] = true
+ if json.Unmarshal(m.Capabilities.UI.Title, &title) == nil {
+ add(title)
} else if json.Unmarshal(m.Capabilities.UI.Title, &titles) == nil {
for _, v := range titles {
- set[v] = true
+ add(v)
}
}
+ sort.Strings(a.Titles)
+ out = append(out, a)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}
+
+// Names returns the capitalised id and the declared titles of the add-ons with
+// a role, sorted. Tests of the prompt builders use it to check that no prompt
+// holds the name of a shipped add-on.
+func Names() ([]string, error) {
+ addons, err := Addons()
+ if err != nil {
+ return nil, err
+ }
+ set := map[string]bool{}
+ for _, a := range addons {
+ if !a.Role {
+ continue
+ }
+ set[strings.ToUpper(a.ID[:1])+a.ID[1:]] = true
+ for _, t := range a.Titles {
+ set[t] = true
+ }
}
var out []string
for n := range set {
diff --git a/core/services/studio/providerguard/scan_test.go b/core/services/studio/providerguard/scan_test.go
new file mode 100644
index 0000000000..61fe4ca2ff
--- /dev/null
+++ b/core/services/studio/providerguard/scan_test.go
@@ -0,0 +1,252 @@
+package providerguard_test
+
+import (
+ "encoding/json"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/studio/providerguard"
+)
+
+// finding is one place where a file names a shipped add-on.
+type finding struct {
+ file string // slash path from the repository root
+ line int
+ name string // the id or title that matched
+ text string // the string literal, line or value that holds it
+}
+
+// namer matches the ids and titles of the shipped add-ons.
+type namer struct {
+ ids map[string]bool
+ titles []string
+ words *regexp.Regexp
+}
+
+func newNamer(addons []providerguard.Addon) *namer {
+ n := &namer{ids: map[string]bool{}}
+ seen := map[string]bool{}
+ var quoted []string
+ for _, a := range addons {
+ if !a.Role {
+ continue
+ }
+ n.ids[a.ID] = true
+ for _, t := range a.Titles {
+ if !seen[t] {
+ seen[t] = true
+ n.titles = append(n.titles, t)
+ quoted = append(quoted, regexp.QuoteMeta(t))
+ }
+ }
+ }
+ // A title is a whole word and matches case sensitively: "Sandbox" is the
+ // add-on, "sandbox" the ordinary noun. A hyphen or an underscore joins
+ // words, so "Web-scale" and "Face_id" are not the titles "Web" and "Face".
+ n.words = regexp.MustCompile(`(^|[^\p{L}\p{N}_-])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_-])`)
+ return n
+}
+
+// find returns the add-on name that s holds, or "". A string equal to an id,
+// or an id followed by the tool-name separator, is a comparison or a tool name.
+// A declared title anywhere in the text is a name that a person would read.
+func (n *namer) find(s string) string {
+ if n.ids[s] {
+ return s
+ }
+ if i := strings.Index(s, "__"); i > 0 && n.ids[s[:i]] {
+ return s[:i]
+ }
+ if m := n.words.FindStringSubmatch(s); m != nil {
+ return m[2]
+ }
+ return ""
+}
+
+// findQuoted is find for source text that is not parsed: it looks at the
+// title anywhere and at an id only when it stands alone between quotes.
+func (n *namer) findQuoted(line string) string {
+ if m := n.words.FindStringSubmatch(line); m != nil {
+ return m[2]
+ }
+ for _, q := range quotedRe.FindAllStringSubmatch(line, -1) {
+ if v := n.find(q[2]); v != "" && (n.ids[q[2]] || strings.Contains(q[2], "__")) {
+ return v
+ }
+ }
+ return ""
+}
+
+var quotedRe = regexp.MustCompile("(['\"`])([^'\"`\\n]*)(['\"`])")
+
+func walk(root, dir string, skip func(rel string, d fs.DirEntry) bool, fn func(rel string, raw []byte)) error {
+ return filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(root, path)
+ rel = filepath.ToSlash(rel)
+ if skip(rel, d) {
+ if d.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if d.IsDir() {
+ return nil
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ fn(rel, raw)
+ return nil
+ })
+}
+
+func scanGo(root string, n *namer, skipDirs []string) ([]finding, error) {
+ var out []finding
+ for _, dir := range []string{"core", "pkg"} {
+ err := walk(root, dir, func(rel string, d fs.DirEntry) bool {
+ if d.IsDir() {
+ if d.Name() == "node_modules" || d.Name() == "vendor" || d.Name() == "testdata" {
+ return true
+ }
+ // A package named like storetest or agentstest holds the
+ // fixtures of the tests of another package, as a _test.go
+ // file does.
+ if strings.HasSuffix(d.Name(), "test") {
+ return true
+ }
+ for _, s := range skipDirs {
+ if rel == s {
+ return true
+ }
+ }
+ return false
+ }
+ return !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go")
+ }, func(rel string, raw []byte) {
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, rel, raw, parser.SkipObjectResolution)
+ if err != nil {
+ return
+ }
+ ast.Inspect(f, func(node ast.Node) bool {
+ lit, ok := node.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
+ }
+ s, uerr := strconv.Unquote(lit.Value)
+ if uerr != nil {
+ s = lit.Value
+ }
+ if name := n.find(s); name != "" {
+ out = append(out, finding{rel, fset.Position(lit.Pos()).Line, name, s})
+ }
+ return true
+ })
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+ return out, nil
+}
+
+func isTestSource(rel string) bool {
+ return strings.Contains(rel, ".test.") || strings.Contains(rel, ".spec.") || strings.Contains(rel, "/__tests__/")
+}
+
+func scanUI(root string, n *namer) ([]finding, error) {
+ var out []finding
+ err := walk(root, "core/http/react-ui/src", func(rel string, d fs.DirEntry) bool {
+ if d.IsDir() {
+ return d.Name() == "node_modules" || d.Name() == "vendor"
+ }
+ return !(strings.HasSuffix(rel, ".js") || strings.HasSuffix(rel, ".jsx")) || isTestSource(rel)
+ }, func(rel string, raw []byte) {
+ inBlock := false
+ for i, line := range strings.Split(string(raw), "\n") {
+ t := strings.TrimSpace(line)
+ if inBlock {
+ if strings.Contains(t, "*/") {
+ inBlock = false
+ }
+ continue
+ }
+ if strings.HasPrefix(t, "/*") {
+ inBlock = !strings.Contains(t, "*/")
+ continue
+ }
+ if strings.HasPrefix(t, "//") || strings.HasPrefix(t, "*") || strings.HasPrefix(t, "{/*") {
+ continue
+ }
+ // A trailing line comment is prose for developers, not a string.
+ if j := strings.Index(line, " // "); j >= 0 {
+ line = line[:j]
+ }
+ if name := n.findQuoted(line); name != "" {
+ out = append(out, finding{rel, i + 1, name, t})
+ }
+ }
+ })
+ return out, err
+}
+
+func scanLocales(root string, n *namer) ([]finding, error) {
+ var out []finding
+ err := walk(root, "core/http/react-ui/public/locales", func(rel string, d fs.DirEntry) bool {
+ return !d.IsDir() && !strings.HasSuffix(rel, ".json")
+ }, func(rel string, raw []byte) {
+ var tree any
+ if json.Unmarshal(raw, &tree) != nil {
+ return
+ }
+ var visit func(path string, v any)
+ visit = func(path string, v any) {
+ switch x := v.(type) {
+ case map[string]any:
+ for k, c := range x {
+ visit(path+"."+k, c)
+ }
+ case []any:
+ for _, c := range x {
+ visit(path, c)
+ }
+ case string:
+ if name := n.find(x); name != "" {
+ out = append(out, finding{rel, 0, name, strings.TrimPrefix(path, ".") + " = " + x})
+ }
+ }
+ }
+ visit("", tree)
+ })
+ return out, err
+}
+
+func scanDocs(root string, n *namer, dirs []string) ([]finding, error) {
+ var out []finding
+ for _, dir := range dirs {
+ err := walk(root, dir, func(rel string, d fs.DirEntry) bool {
+ return !d.IsDir() && !strings.HasSuffix(rel, ".md")
+ }, func(rel string, raw []byte) {
+ for i, line := range strings.Split(string(raw), "\n") {
+ if m := n.words.FindStringSubmatch(line); m != nil {
+ out = append(out, finding{rel, i + 1, m[2], strings.TrimSpace(line)})
+ }
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+ return out, nil
+}
diff --git a/docs/content/getting-started/containers.md b/docs/content/getting-started/containers.md
index 3e748ac00d..06070291f3 100644
--- a/docs/content/getting-started/containers.md
+++ b/docs/content/getting-started/containers.md
@@ -267,11 +267,11 @@ podman run -ti --name local-ai -p 8080:8080 \
### Container user and existing volumes
-The image starts as root only to run `entrypoint.sh`, which gives the data folders (`/models`, `/backends`, `/configuration`, `/data`, `/tmp/generated`) to the user `localai` (uid and gid 10001) and then runs LocalAI as that user. The Sandbox add-on needs this, because it refuses to run as root. See [the Sandbox in the LocalAI container]({{% relref "features/addons#the-sandbox-in-the-localai-container" %}}) for the devices and permissions it needs.
+The image starts as root only to run `entrypoint.sh`, which gives the data folders (`/models`, `/backends`, `/configuration`, `/data`, `/tmp/generated`) to the user `localai` (uid and gid 10001) and then runs LocalAI as that user. An add-on that runs commands in an isolated sandbox needs this, because it refuses to run as root. See [the sandbox in the LocalAI container]({{% relref "features/addons#the-sandbox-in-the-localai-container" %}}) for the devices and permissions it needs.
- **Volumes from an older image** are owned by root. The first start with this image changes their owner to 10001 (it logs this, and it can take a while on a large models folder). No manual step is needed.
- `LOCALAI_UID` and `LOCALAI_GID` change the user. `docker run --user` or a Kubernetes `runAsUser` is respected, and the data folders must then be writable by that user.
-- `LOCALAI_RUN_AS_ROOT=true` keeps the old behaviour. The Sandbox add-on is then unavailable.
+- `LOCALAI_RUN_AS_ROOT=true` keeps the old behaviour. Add-ons that run commands in a sandbox are then unavailable.
- A bind mount whose owner cannot be changed (a read-only or network mount) must be made writable for uid 10001 on the storage side.
## Next Steps