diff --git a/core/http/react-ui/e2e/ui-primitives.spec.js b/core/http/react-ui/e2e/ui-primitives.spec.js index a7bc76bed5..8f2c8580bd 100644 --- a/core/http/react-ui/e2e/ui-primitives.spec.js +++ b/core/http/react-ui/e2e/ui-primitives.spec.js @@ -229,14 +229,14 @@ test.describe('UI primitives', () => { test('side pane: docked on a wide screen, Escape closes it and returns focus', async ({ page }) => { await openCatalog(page) - const trigger = page.getByTestId('open-pane-Tables') + const trigger = page.getByTestId('open-pane-Notes') await trigger.click() const pane = page.getByTestId('cat-pane') await expect(pane).toHaveAttribute('data-state', 'open') await expect(pane).toHaveAttribute('role', 'complementary') await expect(page.locator('.ui-pane-scrim')).toHaveCount(0) expect((await pane.boundingBox()).width).toBeCloseTo(360, 0) - await expect(pane.getByRole('heading', { name: 'Tables' })).toBeVisible() + await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible() await page.keyboard.press('Escape') await expect(pane).toHaveCount(0) await expect(trigger).toBeFocused() @@ -245,7 +245,7 @@ test.describe('UI primitives', () => { test('side pane: an overlay with a scrim below 1280 px', async ({ page }) => { await page.setViewportSize({ width: 1024, height: 800 }) await openCatalog(page) - await page.getByTestId('open-pane-Tables').click() + await page.getByTestId('open-pane-Notes').click() const pane = page.getByTestId('cat-pane') await expect(pane).toHaveAttribute('role', 'dialog') await expect(page.locator('.ui-pane-scrim')).toBeVisible() @@ -302,7 +302,7 @@ test.describe('UI primitives', () => { await page.getByTestId('cat-section-toasts').scrollIntoViewIfNeeded() await page.waitForTimeout(400) await page.screenshot({ path: `${SHOTS}/toasts-${name}-${theme}.png` }) - await page.getByTestId('open-pane-Tables').click() + await page.getByTestId('open-pane-Notes').click() await page.waitForTimeout(500) await page.screenshot({ path: `${SHOTS}/pane-${name}-${theme}.png` }) await page.keyboard.press('Escape') diff --git a/core/http/react-ui/src/pages/UiCatalog.jsx b/core/http/react-ui/src/pages/UiCatalog.jsx index f65975162f..89b8aeda5a 100644 --- a/core/http/react-ui/src/pages/UiCatalog.jsx +++ b/core/http/react-ui/src/pages/UiCatalog.jsx @@ -122,7 +122,7 @@ function Callouts() {
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.
@@ -241,10 +241,10 @@ function Pane() {
- {['Tables', 'Office', 'Voice'].map(name => ( + {['Notes', 'Reports', 'Alerts'].map(name => (
{name} - +
))} @@ -253,9 +253,9 @@ function Pane() { setOpen(false)} - title="Tables" + title="Notes" meta={<>1.0.0 · Main library} - footer={<>} + footer={<>} testId="cat-pane" >
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