mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
6 files changed
+576
-129
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user