Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions cmd/launcher/internal/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)

Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions cmd/launcher/internal/launcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 11 additions & 4 deletions cmd/local-ai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:])
Expand Down
29 changes: 27 additions & 2 deletions core/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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()

Expand Down
50 changes: 50 additions & 0 deletions core/cli/run_paths_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
})
4 changes: 2 additions & 2 deletions docs/content/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading