Skip to content

Commit 461ae84

Browse files
localai-botmudler
andauthored
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-<uid>/...`), 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 <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 2a4426c commit 461ae84

6 files changed

Lines changed: 152 additions & 23 deletions

File tree

cmd/launcher/internal/launcher.go

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -207,21 +207,7 @@ func (l *Launcher) StartLocalAI() error {
207207
}
208208

209209
// Build command arguments
210-
dataPath := l.GetDataPath()
211-
args := []string{
212-
"run",
213-
"--models-path", l.config.ModelsPath,
214-
"--backends-path", l.config.BackendsPath,
215-
"--address", l.config.Address,
216-
"--log-level", l.config.LogLevel,
217-
// Keep persistent data and dynamic config under the launcher's data
218-
// directory (~/.localai) rather than letting the server resolve them
219-
// to ${basepath}/{data,configuration}. ${basepath} expands to the
220-
// launcher process's CWD (often the user's home root), which puts
221-
// ~/data and ~/configuration outside ~/.localai. See #10610.
222-
"--data-path", filepath.Join(dataPath, "data"),
223-
"--localai-config-dir", filepath.Join(dataPath, "configuration"),
224-
}
210+
args := l.BuildRunArgs()
225211

226212
l.localaiCmd = exec.CommandContext(l.ctx, binaryPath, args...)
227213

@@ -406,6 +392,32 @@ func (l *Launcher) GetWebUIURL() string {
406392
return address
407393
}
408394

395+
// BuildRunArgs assembles the argument list passed to `local-ai run`.
396+
//
397+
// Storage paths are anchored to the launcher's data directory instead of the
398+
// server's own defaults. The server resolves data/config to ${basepath}
399+
// (the launcher process CWD, often the user's home root) and generated-content
400+
// /uploads to shared /tmp paths. On a shared /tmp (macOS routes /tmp to
401+
// /private/tmp for every user) the first account to run LocalAI creates
402+
// /tmp/generated with 0750 perms, so any other account then fails startup with
403+
// "mkdir /tmp/generated/content: permission denied". Keeping every writable
404+
// path under the per-user data directory avoids both the misplacement (#10610)
405+
// and the cross-user /tmp collision.
406+
func (l *Launcher) BuildRunArgs() []string {
407+
dataPath := l.GetDataPath()
408+
return []string{
409+
"run",
410+
"--models-path", l.config.ModelsPath,
411+
"--backends-path", l.config.BackendsPath,
412+
"--address", l.config.Address,
413+
"--log-level", l.config.LogLevel,
414+
"--data-path", filepath.Join(dataPath, "data"),
415+
"--localai-config-dir", filepath.Join(dataPath, "configuration"),
416+
"--generated-content-path", filepath.Join(dataPath, "generated"),
417+
"--upload-path", filepath.Join(dataPath, "uploads"),
418+
}
419+
}
420+
409421
// GetDataPath returns the path where LocalAI data and logs are stored
410422
func (l *Launcher) GetDataPath() string {
411423
// LocalAI typically stores data in the current working directory or a models directory

cmd/launcher/internal/launcher_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,41 @@ var _ = Describe("Launcher", func() {
149149
})
150150
})
151151

152+
Describe("BuildRunArgs", func() {
153+
// Regression for the macOS "mkdir /tmp/generated/content: permission denied"
154+
// startup failure: the launcher must redirect generated-content and upload
155+
// paths under its own data directory instead of letting the server fall back
156+
// to the shared /tmp defaults, which collide across users on a shared /tmp.
157+
It("should keep generated-content and upload paths under the data directory", func() {
158+
config := launcherInstance.GetConfig()
159+
config.ModelsPath = filepath.Join(tempDir, "models")
160+
launcherInstance.SetConfig(config)
161+
162+
dataPath := launcherInstance.GetDataPath()
163+
args := launcherInstance.BuildRunArgs()
164+
165+
assertFlagValue := func(flag, expected string) {
166+
idx := -1
167+
for i, a := range args {
168+
if a == flag {
169+
idx = i
170+
break
171+
}
172+
}
173+
Expect(idx).To(BeNumerically(">=", 0), "expected %s to be present in run args", flag)
174+
Expect(idx+1).To(BeNumerically("<", len(args)), "expected a value after %s", flag)
175+
Expect(args[idx+1]).To(Equal(expected))
176+
}
177+
178+
assertFlagValue("--generated-content-path", filepath.Join(dataPath, "generated"))
179+
assertFlagValue("--upload-path", filepath.Join(dataPath, "uploads"))
180+
// The bug was the server resolving these to shared /tmp paths.
181+
for _, a := range args {
182+
Expect(a).ToNot(HavePrefix("/tmp/"), "run args must not reference shared /tmp paths, got %s", a)
183+
}
184+
})
185+
})
186+
152187
Describe("Logs", func() {
153188
It("should return empty logs initially", func() {
154189
logs := launcherInstance.GetLogs()

cmd/local-ai/main.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,17 @@ For documentation and support:
5757
),
5858
kong.UsageOnError(),
5959
kong.Vars{
60-
"basepath": kong.ExpandPath("."),
61-
"galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`,
62-
"backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`,
63-
"version": internal.PrintableVersion(),
60+
"basepath": kong.ExpandPath("."),
61+
// Per-user temp locations for ephemeral writable content. A fixed
62+
// shared name under /tmp collides across accounts on multi-user hosts
63+
// (notably macOS, where /tmp is the shared /private/tmp for everyone),
64+
// failing startup with "mkdir /tmp/generated/content: permission
65+
// denied". See cli.DefaultGeneratedContentPath.
66+
"generatedcontentpath": cli.DefaultGeneratedContentPath(),
67+
"uploadpath": cli.DefaultUploadPath(),
68+
"galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`,
69+
"backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`,
70+
"version": internal.PrintableVersion(),
6471
},
6572
)
6673
ctx, err := k.Parse(os.Args[1:])

core/cli/run.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type RunCMD struct {
3535
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
3636
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"`
3737
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
38-
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"`
39-
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"/tmp/localai/upload" help:"Path to store uploads from files api" group:"storage"`
38+
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"`
39+
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
4040
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"`
4141
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"`
4242
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 {
186186
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"`
187187
}
188188

189+
// userScopedTempDir returns a temp directory namespaced to the current user.
190+
//
191+
// The generated-content and upload directories are ephemeral, so they live
192+
// under the OS temp dir - but a fixed shared name like /tmp/generated is a trap
193+
// on any multi-user host. macOS routes /tmp to the shared /private/tmp for every
194+
// account, so whichever user starts LocalAI first creates the parent with 0750
195+
// perms and every other account then fails startup with
196+
// "mkdir /tmp/generated/content: permission denied" (the same happens on Linux
197+
// once a stale root-owned /tmp/generated is left behind). Scoping to the current
198+
// UID gives each account its own tree so they never collide.
199+
func userScopedTempDir() string {
200+
return filepath.Join(os.TempDir(), fmt.Sprintf("localai-%d", os.Getuid()))
201+
}
202+
203+
// DefaultGeneratedContentPath returns the default location for backend-generated
204+
// content (images, audio, videos).
205+
func DefaultGeneratedContentPath() string {
206+
return filepath.Join(userScopedTempDir(), "generated", "content")
207+
}
208+
209+
// DefaultUploadPath returns the default location for uploads from the files API.
210+
func DefaultUploadPath() string {
211+
return filepath.Join(userScopedTempDir(), "upload")
212+
}
213+
189214
func (r *RunCMD) Run(ctx *cliContext.Context) error {
190215
warnDeprecatedFlags()
191216

core/cli/run_paths_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
// Regression for the startup failure observed when a second OS account (or a
13+
// leftover root-owned directory) already created the shared /tmp locations:
14+
//
15+
// unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
16+
//
17+
// The historical defaults (/tmp/generated/content and /tmp/localai/upload) are
18+
// shared across every user of a machine. On macOS /tmp is routed to the shared
19+
// /private/tmp for all accounts, so the first account to run LocalAI creates the
20+
// parent with 0750 perms and locks everyone else out. The defaults must instead
21+
// be scoped to the current user so unrelated accounts never collide.
22+
var _ = Describe("default writable paths", func() {
23+
userScope := fmt.Sprintf("localai-%d", os.Getuid())
24+
25+
Describe("DefaultGeneratedContentPath", func() {
26+
It("is scoped to the current user under the OS temp dir", func() {
27+
p := DefaultGeneratedContentPath()
28+
Expect(p).To(HavePrefix(os.TempDir()))
29+
Expect(p).To(ContainSubstring(userScope))
30+
Expect(p).To(HaveSuffix(filepath.Join("generated", "content")))
31+
})
32+
33+
It("is not the historical shared path", func() {
34+
Expect(DefaultGeneratedContentPath()).ToNot(Equal("/tmp/generated/content"))
35+
})
36+
})
37+
38+
Describe("DefaultUploadPath", func() {
39+
It("is scoped to the current user under the OS temp dir", func() {
40+
p := DefaultUploadPath()
41+
Expect(p).To(HavePrefix(os.TempDir()))
42+
Expect(p).To(ContainSubstring(userScope))
43+
Expect(p).To(HaveSuffix("upload"))
44+
})
45+
46+
It("is not the historical shared path", func() {
47+
Expect(DefaultUploadPath()).ToNot(Equal("/tmp/localai/upload"))
48+
})
49+
})
50+
})

docs/content/reference/cli-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e
2323
|-----------|---------|-------------|----------------------|
2424
| `--models-path` | `BASEPATH/models` | Path containing models used for inferencing | `$LOCALAI_MODELS_PATH`, `$MODELS_PATH` |
2525
| `--data-path` | `BASEPATH/data` | Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration | `$LOCALAI_DATA_PATH` |
26-
| `--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` |
27-
| `--upload-path` | `/tmp/localai/upload` | Path to store uploads from files API | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` |
26+
| `--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` |
27+
| `--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` |
2828
| `--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` |
2929
| `--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` |
3030
| `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |

0 commit comments

Comments
 (0)