diff --git a/core/services/agentpool/agent_pool.go b/core/services/agentpool/agent_pool.go index c139dda41..5a13ad847 100644 --- a/core/services/agentpool/agent_pool.go +++ b/core/services/agentpool/agent_pool.go @@ -358,6 +358,9 @@ func (s *AgentPoolService) startLocalAGI(_ context.Context, cfg config.AgentPool agiServices.FiltersConfigMeta(), ) + // Warn before starting legacy agents, which remain outside authenticated users' storage. + s.warnLegacyAuthData() + // Start all agents if err := pool.StartAll(); err != nil { xlog.Error("Failed to start agent pool", "error", err) diff --git a/core/services/agentpool/legacy_auth.go b/core/services/agentpool/legacy_auth.go new file mode 100644 index 000000000..b1ef41198 --- /dev/null +++ b/core/services/agentpool/legacy_auth.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +package agentpool + +import ( + "strings" + + skillsManager "github.com/mudler/LocalAI/core/services/skills" + skillgit "github.com/mudler/skillserver/pkg/git" + "github.com/mudler/xlog" +) + +func (s *AgentPoolService) warnLegacyAuthData() { + if !s.appConfig.Auth.Enabled { + return + } + + rootAgents := 0 + for _, name := range s.localAGI.pool.List() { + if !strings.Contains(name, ":") { + rootAgents++ + } + } + rootCollections, err := s.collectionsBackend.ListCollections() + if err != nil { + xlog.Warn("Could not check root collections after enabling user authentication", "error", err) + } + + skillCount, repoCount := 0, 0 + if s.localAGI.skillsService != nil { + manager := skillsManager.NewFilesystemManager(s.localAGI.skillsService) + rootSkills, err := manager.List() + if err != nil { + xlog.Warn("Could not check root skills after enabling user authentication", "error", err) + } else { + skillCount = len(rootSkills) + } + // Count disabled and not-yet-cloned repositories too; the skill index + // contains only skills that are already available on disk. + repos, err := skillgit.NewConfigManager(s.localAGI.skillsService.GetSkillsDir()).LoadConfig() + if err != nil { + xlog.Warn("Could not check root skill repositories after enabling user authentication", "error", err) + } else { + repoCount = len(repos) + } + } + + if rootAgents+len(rootCollections)+skillCount+repoCount == 0 { + return + } + xlog.Warn("User authentication is enabled but root agent data has no owner", + "agents", rootAgents, "collections", len(rootCollections), "skills", skillCount, "git_repositories", repoCount, + "stateDir", s.stateDir, + "details", "Existing data is not migrated to a user. Root agents still start and may run scheduled tasks; their API calls require valid credentials.", + "docs", "https://localai.io/features/authentication/#enabling-auth-on-an-existing-instance") +} diff --git a/core/services/agentpool/legacy_auth_test.go b/core/services/agentpool/legacy_auth_test.go new file mode 100644 index 000000000..02060cbc0 --- /dev/null +++ b/core/services/agentpool/legacy_auth_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +package agentpool_test + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/services/agentpool" + "github.com/mudler/xlog" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("legacy auth warning", func() { + const warning = "User authentication is enabled but root agent data has no owner" + var dir string + var logFile *os.File + + BeforeEach(func() { + dir = GinkgoT().TempDir() + var err error + logFile, err = os.CreateTemp(dir, "startup-*.log") + Expect(err).NotTo(HaveOccurred()) + xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logFile, nil), xlog.LogLevelInfo)) + }) + AfterEach(func() { + xlog.SetLogger(xlog.NewLogger(xlog.LogLevelInfo, "text")) + Expect(logFile.Close()).To(Succeed()) + }) + + write := func(path, data string) { + Expect(os.MkdirAll(filepath.Dir(path), 0750)).To(Succeed()) + Expect(os.WriteFile(path, []byte(data), 0600)).To(Succeed()) + } + start := func(enabled bool, collectionPath string) string { + cfg := config.NewApplicationConfig() + cfg.Auth.Enabled = enabled + cfg.AgentPool = config.AgentPoolConfig{ + Enabled: true, StateDir: dir, APIURL: "http://127.0.0.1:1", + DefaultModel: "unused", CollectionDBPath: collectionPath, + } + svc, err := agentpool.NewAgentPoolService(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(svc.Start(context.Background())).To(Succeed()) + svc.Stop() + data, err := os.ReadFile(logFile.Name()) + Expect(err).NotTo(HaveOccurred()) + return string(data) + } + + It("warns about root agents, custom-path collections, skills and git repositories", func() { + write(filepath.Join(dir, "pool.json"), `{"legacy":{"name":"legacy","model":"unused"},"owner:private":{"name":"owner:private","model":"unused"}}`) + collectionsDir := filepath.Join(GinkgoT().TempDir(), "collections") + write(filepath.Join(collectionsDir, "collection-notes.json"), `{}`) + write(filepath.Join(dir, "skills", "legacy-skill", "SKILL.md"), "---\nname: legacy-skill\ndescription: A legacy skill\n---\nInstructions\n") + write(filepath.Join(dir, "skills", ".git-repos.json"), `[{"id":"old","name":"old","url":"https://example.invalid/old.git","enabled":false}]`) + logs := start(true, collectionsDir) + Expect(logs).To(ContainSubstring(warning)) + Expect(logs).To(ContainSubstring("agents=1 collections=1 skills=1 git_repositories=1")) + Expect(logs).To(ContainSubstring("authentication/#enabling-auth-on-an-existing-instance")) + Expect(logs).NotTo(ContainSubstring("agents=2")) + Expect(strings.Index(logs, warning)).To(BeNumerically("<", strings.Index(logs, "Creating agent"))) + }) + + It("does not warn when auth is disabled", func() { + write(filepath.Join(dir, "skills", "legacy-skill", "SKILL.md"), "---\nname: legacy-skill\ndescription: A legacy skill\n---\nInstructions\n") + Expect(start(false, "")).NotTo(ContainSubstring(warning)) + }) + + It("does not count user-owned data as root data", func() { + write(filepath.Join(dir, "pool.json"), `{"owner:private":{"name":"owner:private","model":"unused"}}`) + write(filepath.Join(dir, "users", "owner", "collections", "collection-notes.json"), `{}`) + write(filepath.Join(dir, "users", "owner", "skills", "private", "SKILL.md"), "---\nname: private\ndescription: Private skill\n---\nInstructions\n") + Expect(start(true, "")).NotTo(ContainSubstring(warning)) + }) + + It("warns for configured git repositories even without installed skills", func() { + write(filepath.Join(dir, "skills", ".git-repos.json"), `[{"id":"old","name":"old","url":"https://example.invalid/old.git","enabled":false}]`) + logs := start(true, "") + Expect(logs).To(ContainSubstring(warning)) + Expect(logs).To(ContainSubstring("agents=0 collections=0 skills=0 git_repositories=1")) + }) + + It("reports an unreadable repository config without preventing startup", func() { + write(filepath.Join(dir, "collections", "collection-notes.json"), `{}`) + write(filepath.Join(dir, "skills", ".git-repos.json"), `invalid json`) + logs := start(true, "") + Expect(logs).To(ContainSubstring("Could not check root skill repositories")) + Expect(logs).To(ContainSubstring(warning)) + Expect(logs).To(ContainSubstring("agents=0 collections=1")) + }) +}) diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md index 8e30e0df0..13660dd27 100644 --- a/docs/content/features/authentication.md +++ b/docs/content/features/authentication.md @@ -118,6 +118,53 @@ LOCALAI_AUTH_DATABASE_URL=postgres://user:pass@host/dbname \ localai run ``` +### Enabling auth on an existing instance + +Enabling user authentication does not assign existing agent data to an account. +Data created without user auth belongs to the root storage area (an empty user ID). +Authenticated users get separate storage, including admins. Existing agents, +collections, skills, tasks, jobs, and outputs can therefore disappear from their +API and Web UI views while remaining on disk. + +In standalone (LocalAGI) mode, startup logs a warning when it finds root agents, +collections, skills, or configured skill repositories. The warning includes counts +and a link to this section. It checks the configured root collection path and +excludes user-owned data. It does not inventory tasks, jobs, outputs, or agent state. +An absent warning is not proof that every resource has an owner. + +{{% notice warning %}} +The warning does not migrate, delete, or pause anything. Root agents still start, +including their scheduled runs. Their requests to the local API need valid +credentials after auth is enabled. Admin views do not provide a complete migration +or export path for root data. +{{% /notice %}} + +Before switching an existing instance: + +1. Pause existing agents and scheduled work while auth is still off. +2. Stop LocalAI and back up its data, agent state, and any separately configured collection storage or databases. +3. Test the switch on a copy before changing the production instance. +4. Restrict access during bootstrap and register the intended administrator first. +5. Recreate or import agent configurations under the intended user. Recreate collections and skills there as needed. +6. Verify credentials and required data before resuming agents. + +The first registered user becomes admin and bypasses approval or invite requirements. +Setting `LOCALAI_ADMIN_EMAIL` promotes that email address, but does **not** reserve +the first-admin role for it. Keep registration inaccessible to other users until +bootstrap is complete. + +Recreating an agent configuration does not migrate its character, scheduler state, +or long-term memory. Keep the backup: LocalAI has no automatic migration of the +root storage area, and agent configuration export does not include this state. + +With user auth active, creating, importing, or updating an agent without +`api_key` generates a new key owned by that user. The key inherits the owner's +permissions; an admin-owned key can therefore grant admin access. Repeated updates +that omit `api_key` can create additional keys. Supply an explicit key with the +intended permissions when recreating or updating agents, and review existing keys +in **Settings → Users and keys**. Existing root agents can use an explicit +`api_key` or `LOCALAI_AGENT_POOL_API_KEY`; neither setting transfers ownership. + ### Configuration Reference | Environment Variable | Default | Description |