test(addons): guard core against naming a shipped add-on

One manifest-driven guard replaces the Go-only provider guard. It reads
addons/*/manifest.json and fails when the Go strings of core and pkg,
the React UI source, the locale files or the core docs hold the id of an
add-on with a role (as a whole string or a tool-name prefix) or one of
its declared titles as a whole word.

Third-party mentions (OAuth login, release sources, token formats) and
ordinary words sit in a commented allow-list. The special cases that
remain (propose_change memory kind, the Account memory tab, the Telegram
conversion, the Tables listing shim) sit in knownDebt, each with the item
that removes it, and a ceiling that only goes down.

Reword the sandbox sentences in the container docs and use neutral
sample names in the UI catalog page.

Assisted-by: Claude:claude-sonnet-5-5 go test
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 15:38:07 +00:00
1 parent 15e2321d50
commit 0921098539
6 files changed
+576 -129

No files matched your search

+4 -4
View File
@@ -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')
+5 -5
View File
@@ -122,7 +122,7 @@ function Callouts() {
<div className="ui-cat__stack">
<Callout tone="info" title="Chats use the connectors that are on.">A saved agent uses only the ones you pick for it.</Callout>
<Callout tone="ok" title="qwen3-8b is ready." actions={<Button size="sm" variant="primary">Start a chat</Button>}>It loaded in 2.1 s.</Callout>
<Callout tone="warn" title="Tables 1.0.0 is available." actions={<><Button size="sm" variant="ghost">What changed</Button><Button size="sm">Update</Button></>}>You have 0.9.1. It asks for one more permission.</Callout>
<Callout tone="warn" title="Notes 1.0.0 is available." actions={<><Button size="sm" variant="ghost">What changed</Button><Button size="sm">Update</Button></>}>You have 0.9.1. It asks for one more permission.</Callout>
<Callout tone="error" title="research-assistant failed" actions={<Button size="sm">Open the run</Button>}>in “Digest of the week”, 12 minutes ago. The model stopped before it answered.</Callout>
</div>
</Section>
@@ -241,10 +241,10 @@ function Pane() {
<PaneLayout>
<div className="ui-cat__stack">
<div className="ui-cat__list">
{['Tables', 'Office', 'Voice'].map(name => (
{['Notes', 'Reports', 'Alerts'].map(name => (
<div className="ui-cat__item" key={name}>
<strong>{name}</strong>
<StatusChip status={name === 'Office' ? 'failed' : 'running'} />
<StatusChip status={name === 'Reports' ? 'failed' : 'running'} />
<Button size="sm" onClick={() => setOpen(true)} data-testid={`open-pane-${name}`}>Details</Button>
</div>
))}
@@ -253,9 +253,9 @@ function Pane() {
<SidePane
open={open}
onClose={() => setOpen(false)}
title="Tables"
title="Notes"
meta={<><StatusChip status="running" /><span>1.0.0 · Main library</span></>}
footer={<><Button variant="danger-ghost">Remove Tables…</Button><Button variant="primary">Open Tables</Button></>}
footer={<><Button variant="danger-ghost">Remove Notes…</Button><Button variant="primary">Open Notes</Button></>}
testId="cat-pane"
>
<div className="ui-cat__stack">
+249 -100
View File
@@ -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 "<key path> = <value>", 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)
}
})
})
+64 -18
View File
@@ -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 {
@@ -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
}
+2 -2
View File
@@ -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