diff --git a/cmd/launcher/internal/launcher.go b/cmd/launcher/internal/launcher.go index ba4427a1f..aa5f7d6f4 100644 --- a/cmd/launcher/internal/launcher.go +++ b/cmd/launcher/internal/launcher.go @@ -207,21 +207,7 @@ func (l *Launcher) StartLocalAI() error { } // Build command arguments - dataPath := l.GetDataPath() - args := []string{ - "run", - "--models-path", l.config.ModelsPath, - "--backends-path", l.config.BackendsPath, - "--address", l.config.Address, - "--log-level", l.config.LogLevel, - // Keep persistent data and dynamic config under the launcher's data - // directory (~/.localai) rather than letting the server resolve them - // to ${basepath}/{data,configuration}. ${basepath} expands to the - // launcher process's CWD (often the user's home root), which puts - // ~/data and ~/configuration outside ~/.localai. See #10610. - "--data-path", filepath.Join(dataPath, "data"), - "--localai-config-dir", filepath.Join(dataPath, "configuration"), - } + args := l.BuildRunArgs() l.localaiCmd = exec.CommandContext(l.ctx, binaryPath, args...) @@ -406,6 +392,32 @@ func (l *Launcher) GetWebUIURL() string { return address } +// BuildRunArgs assembles the argument list passed to `local-ai run`. +// +// Storage paths are anchored to the launcher's data directory instead of the +// server's own defaults. The server resolves data/config to ${basepath} +// (the launcher process CWD, often the user's home root) and generated-content +// /uploads to shared /tmp paths. On a shared /tmp (macOS routes /tmp to +// /private/tmp for every user) the first account to run LocalAI creates +// /tmp/generated with 0750 perms, so any other account then fails startup with +// "mkdir /tmp/generated/content: permission denied". Keeping every writable +// path under the per-user data directory avoids both the misplacement (#10610) +// and the cross-user /tmp collision. +func (l *Launcher) BuildRunArgs() []string { + dataPath := l.GetDataPath() + return []string{ + "run", + "--models-path", l.config.ModelsPath, + "--backends-path", l.config.BackendsPath, + "--address", l.config.Address, + "--log-level", l.config.LogLevel, + "--data-path", filepath.Join(dataPath, "data"), + "--localai-config-dir", filepath.Join(dataPath, "configuration"), + "--generated-content-path", filepath.Join(dataPath, "generated"), + "--upload-path", filepath.Join(dataPath, "uploads"), + } +} + // GetDataPath returns the path where LocalAI data and logs are stored func (l *Launcher) GetDataPath() string { // LocalAI typically stores data in the current working directory or a models directory diff --git a/cmd/launcher/internal/launcher_test.go b/cmd/launcher/internal/launcher_test.go index 15a2a24ee..4786193ff 100644 --- a/cmd/launcher/internal/launcher_test.go +++ b/cmd/launcher/internal/launcher_test.go @@ -149,6 +149,41 @@ var _ = Describe("Launcher", func() { }) }) + Describe("BuildRunArgs", func() { + // Regression for the macOS "mkdir /tmp/generated/content: permission denied" + // startup failure: the launcher must redirect generated-content and upload + // paths under its own data directory instead of letting the server fall back + // to the shared /tmp defaults, which collide across users on a shared /tmp. + It("should keep generated-content and upload paths under the data directory", func() { + config := launcherInstance.GetConfig() + config.ModelsPath = filepath.Join(tempDir, "models") + launcherInstance.SetConfig(config) + + dataPath := launcherInstance.GetDataPath() + args := launcherInstance.BuildRunArgs() + + assertFlagValue := func(flag, expected string) { + idx := -1 + for i, a := range args { + if a == flag { + idx = i + break + } + } + Expect(idx).To(BeNumerically(">=", 0), "expected %s to be present in run args", flag) + Expect(idx+1).To(BeNumerically("<", len(args)), "expected a value after %s", flag) + Expect(args[idx+1]).To(Equal(expected)) + } + + assertFlagValue("--generated-content-path", filepath.Join(dataPath, "generated")) + assertFlagValue("--upload-path", filepath.Join(dataPath, "uploads")) + // The bug was the server resolving these to shared /tmp paths. + for _, a := range args { + Expect(a).ToNot(HavePrefix("/tmp/"), "run args must not reference shared /tmp paths, got %s", a) + } + }) + }) + Describe("Logs", func() { It("should return empty logs initially", func() { logs := launcherInstance.GetLogs() diff --git a/cmd/local-ai/main.go b/cmd/local-ai/main.go index 00d019f8a..d733cdf8a 100644 --- a/cmd/local-ai/main.go +++ b/cmd/local-ai/main.go @@ -57,10 +57,17 @@ For documentation and support: ), kong.UsageOnError(), kong.Vars{ - "basepath": kong.ExpandPath("."), - "galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`, - "backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`, - "version": internal.PrintableVersion(), + "basepath": kong.ExpandPath("."), + // Per-user temp locations for ephemeral writable content. A fixed + // shared name under /tmp collides across accounts on multi-user hosts + // (notably macOS, where /tmp is the shared /private/tmp for everyone), + // failing startup with "mkdir /tmp/generated/content: permission + // denied". See cli.DefaultGeneratedContentPath. + "generatedcontentpath": cli.DefaultGeneratedContentPath(), + "uploadpath": cli.DefaultUploadPath(), + "galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`, + "backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`, + "version": internal.PrintableVersion(), }, ) ctx, err := k.Parse(os.Args[1:]) diff --git a/core/cli/run.go b/core/cli/run.go index c3f7e51c8..85f82e5d5 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -35,8 +35,8 @@ type RunCMD struct { BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"` BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"` ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"` - GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"/tmp/generated/content" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"` - UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"/tmp/localai/upload" help:"Path to store uploads from files api" group:"storage"` + GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"${generatedcontentpath}" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"` + UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"` DataPath string `env:"LOCALAI_DATA_PATH" type:"path" default:"${basepath}/data" help:"Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration" group:"storage"` LocalaiConfigDir string `env:"LOCALAI_CONFIG_DIR" type:"path" default:"${basepath}/configuration" help:"Directory for dynamic loading of certain configuration files (currently api_keys.json and external_backends.json)" group:"storage"` LocalaiConfigDirPollInterval time.Duration `env:"LOCALAI_CONFIG_DIR_POLL_INTERVAL" help:"Typically the config path picks up changes automatically, but if your system has broken fsnotify events, set this to an interval to poll the LocalAI Config Dir (example: 1m)" group:"storage"` @@ -186,6 +186,31 @@ type RunCMD struct { PIIDefaultDetectors []string `env:"LOCALAI_PII_DEFAULT_DETECTORS" help:"Instance-wide default PII/secret detector model names applied to any PII-enabled model (chiefly cloud-proxy / MITM models) that names no pii.detectors of its own. Comma-separated, e.g. privacy-filter-nemotron,secret-filter. Takes precedence over the value persisted via the Middleware UI." group:"middleware"` } +// userScopedTempDir returns a temp directory namespaced to the current user. +// +// The generated-content and upload directories are ephemeral, so they live +// under the OS temp dir - but a fixed shared name like /tmp/generated is a trap +// on any multi-user host. macOS routes /tmp to the shared /private/tmp for every +// account, so whichever user starts LocalAI first creates the parent with 0750 +// perms and every other account then fails startup with +// "mkdir /tmp/generated/content: permission denied" (the same happens on Linux +// once a stale root-owned /tmp/generated is left behind). Scoping to the current +// UID gives each account its own tree so they never collide. +func userScopedTempDir() string { + return filepath.Join(os.TempDir(), fmt.Sprintf("localai-%d", os.Getuid())) +} + +// DefaultGeneratedContentPath returns the default location for backend-generated +// content (images, audio, videos). +func DefaultGeneratedContentPath() string { + return filepath.Join(userScopedTempDir(), "generated", "content") +} + +// DefaultUploadPath returns the default location for uploads from the files API. +func DefaultUploadPath() string { + return filepath.Join(userScopedTempDir(), "upload") +} + func (r *RunCMD) Run(ctx *cliContext.Context) error { warnDeprecatedFlags() diff --git a/core/cli/run_paths_test.go b/core/cli/run_paths_test.go new file mode 100644 index 000000000..1878a4405 --- /dev/null +++ b/core/cli/run_paths_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Regression for the startup failure observed when a second OS account (or a +// leftover root-owned directory) already created the shared /tmp locations: +// +// unable to create ImageDir: "mkdir /tmp/generated/content: permission denied" +// +// The historical defaults (/tmp/generated/content and /tmp/localai/upload) are +// shared across every user of a machine. On macOS /tmp is routed to the shared +// /private/tmp for all accounts, so the first account to run LocalAI creates the +// parent with 0750 perms and locks everyone else out. The defaults must instead +// be scoped to the current user so unrelated accounts never collide. +var _ = Describe("default writable paths", func() { + userScope := fmt.Sprintf("localai-%d", os.Getuid()) + + Describe("DefaultGeneratedContentPath", func() { + It("is scoped to the current user under the OS temp dir", func() { + p := DefaultGeneratedContentPath() + Expect(p).To(HavePrefix(os.TempDir())) + Expect(p).To(ContainSubstring(userScope)) + Expect(p).To(HaveSuffix(filepath.Join("generated", "content"))) + }) + + It("is not the historical shared path", func() { + Expect(DefaultGeneratedContentPath()).ToNot(Equal("/tmp/generated/content")) + }) + }) + + Describe("DefaultUploadPath", func() { + It("is scoped to the current user under the OS temp dir", func() { + p := DefaultUploadPath() + Expect(p).To(HavePrefix(os.TempDir())) + Expect(p).To(ContainSubstring(userScope)) + Expect(p).To(HaveSuffix("upload")) + }) + + It("is not the historical shared path", func() { + Expect(DefaultUploadPath()).ToNot(Equal("/tmp/localai/upload")) + }) + }) +}) diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 685b87377..3e919efb8 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -23,8 +23,8 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e |-----------|---------|-------------|----------------------| | `--models-path` | `BASEPATH/models` | Path containing models used for inferencing | `$LOCALAI_MODELS_PATH`, `$MODELS_PATH` | | `--data-path` | `BASEPATH/data` | Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration | `$LOCALAI_DATA_PATH` | -| `--generated-content-path` | `/tmp/generated/content` | Location for assets generated by backends (e.g. stablediffusion, images, audio, videos) | `$LOCALAI_GENERATED_CONTENT_PATH`, `$GENERATED_CONTENT_PATH` | -| `--upload-path` | `/tmp/localai/upload` | Path to store uploads from files API | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` | +| `--generated-content-path` | `TMPDIR/localai-UID/generated/content` | Location for assets generated by backends (e.g. stablediffusion, images, audio, videos). Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID so accounts sharing a host never collide. | `$LOCALAI_GENERATED_CONTENT_PATH`, `$GENERATED_CONTENT_PATH` | +| `--upload-path` | `TMPDIR/localai-UID/upload` | Path to store uploads from files API. Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID. | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` | | `--localai-config-dir` | `BASEPATH/configuration` | Directory for dynamic loading of certain configuration files (currently runtime_settings.json, api_keys.json, and external_backends.json). See [Runtime Settings]({{%relref "features/runtime-settings" %}}) for web-based configuration. | `$LOCALAI_CONFIG_DIR` | | `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` | | `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |