From 461ae84732130d6a40f9e7683ec133182792ebe7 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:53:25 +0200 Subject: [PATCH] fix(startup): scope generated-content and upload dirs to the current user (#10698) The `--generated-content-path` and `--upload-path` defaults were the fixed shared locations `/tmp/generated/content` and `/tmp/localai/upload`. On any multi-user host these collide across accounts: macOS routes `/tmp` to the shared `/private/tmp` for every user, so whichever account starts LocalAI first creates the parent with 0750 perms and every other account then fails startup with: unable to create ImageDir: "mkdir /tmp/generated/content: permission denied" unable to create UploadDir: "mkdir /tmp/localai/upload: permission denied" The same happens on Linux once a stale root-owned `/tmp/generated` (e.g. from a prior `sudo` run) is left behind. This bites the desktop launcher and any app embedding the raw binary (Wingman, nib-desktop), which start `local-ai run` with no path flags. Default both paths under the OS temp dir (`os.TempDir()`, honoring `$TMPDIR`; already per-user on macOS) namespaced by the current UID (`TMPDIR/localai-/...`), so accounts never collide while the paths stay ephemeral. Wired via new kong vars in main.go so every consumer of the raw binary inherits the fix. All content subdirs (audio, images) derive from `GeneratedContentDir`, so they are fixed transitively. As defense in depth, the launcher also anchors these two paths under its own per-user data directory (mirroring the #10610 fix for data/config), extracted into a testable `BuildRunArgs`. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- cmd/launcher/internal/launcher.go | 42 +++++++++++++-------- cmd/launcher/internal/launcher_test.go | 35 +++++++++++++++++ cmd/local-ai/main.go | 15 ++++++-- core/cli/run.go | 29 +++++++++++++- core/cli/run_paths_test.go | 50 +++++++++++++++++++++++++ docs/content/reference/cli-reference.md | 4 +- 6 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 core/cli/run_paths_test.go 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` |