From 021459e967ef00963feacefc142c0d454cceb127 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 5 May 2026 22:48:28 +0000 Subject: [PATCH 1/2] fix(backend): resolve relative draft_model paths against the models dir The main model file and mmproj are joined with the configured models directory before reaching the backend, but draft_model was sent verbatim. With a relative draft_model in the YAML config, llama.cpp opens the path from the backend process's CWD and fails with "No such file or directory", forcing users to hard-code an absolute path. Mirror the existing mmproj resolution: if draft_model is relative, join it with modelPath. Absolute paths are passed through unchanged. Adds an e2e regression test against the mock backend that asserts the main model file, mmproj, and draft_model all arrive at the backend resolved to absolute paths. Closes #9675 Signed-off-by: Ettore Di Giacinto Assisted-by: Claude:claude-opus-4-7-1m [Read] [Edit] [Bash] [Write] --- core/backend/options.go | 7 ++++ tests/e2e/e2e_suite_test.go | 16 +++++++++ tests/e2e/mock-backend/main.go | 55 ++++++++++++++++++++++++++++- tests/e2e/path_resolution_test.go | 57 +++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/path_resolution_test.go diff --git a/core/backend/options.go b/core/backend/options.go index 9054bb39b693..3246caec4795 100644 --- a/core/backend/options.go +++ b/core/backend/options.go @@ -246,6 +246,13 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions { opts.MMProj = filepath.Join(modelPath, c.MMProj) } + // Resolve draft_model relative to the models directory, mirroring the + // resolution that happens for parameters.model and mmproj. Without this, + // llama.cpp opens the path from the backend's CWD and fails. + if c.DraftModel != "" && !filepath.IsAbs(c.DraftModel) { + opts.DraftModel = filepath.Join(modelPath, c.DraftModel) + } + return opts } diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go index e140b7f1bbc5..f17cc77b2be4 100644 --- a/tests/e2e/e2e_suite_test.go +++ b/tests/e2e/e2e_suite_test.go @@ -169,6 +169,22 @@ var _ = BeforeSuite(func() { Expect(os.WriteFile(filepath.Join(modelsPath, name+".yaml"), data, 0644)).To(Succeed()) } + // Path-resolution model — declares relative draft_model / mmproj paths + // so the e2e test can confirm they arrive at the backend resolved + // against the models directory (regression guard for issue #9675). + pathResolutionCfg := map[string]any{ + "name": "mock-model-path-resolution", + "backend": "mock-backend", + "parameters": map[string]any{ + "model": "subdir/mock-main.bin", + }, + "draft_model": "subdir/mock-draft.bin", + "mmproj": "subdir/mock-mmproj.bin", + } + pathResolutionData, err := yaml.Marshal(pathResolutionCfg) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(filepath.Join(modelsPath, "mock-model-path-resolution.yaml"), pathResolutionData, 0644)).To(Succeed()) + // Diarization model — known_usecases bypasses the FLAG_DIARIZATION // backend-name guard so the /v1/audio/diarization route can dispatch // to the mock backend. diff --git a/tests/e2e/mock-backend/main.go b/tests/e2e/mock-backend/main.go index f3523d628a8e..ec0c7735d3aa 100644 --- a/tests/e2e/mock-backend/main.go +++ b/tests/e2e/mock-backend/main.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" pb "github.com/mudler/LocalAI/pkg/grpc/proto" "github.com/mudler/xlog" @@ -31,6 +32,28 @@ type MockBackend struct { pb.UnimplementedBackendServer } +// lastLoadParams records the most recent LoadModel parameters so a Predict +// call can echo them back. Used by the path-resolution e2e test, which needs +// to verify that relative draft_model / mmproj / modelfile paths in the YAML +// config arrive at the backend already resolved against the models directory. +// Each backend binary serves a single model, so a single value is enough. +var ( + lastLoadParamsMu sync.RWMutex + lastLoadParams *pb.ModelOptions +) + +func recordLoadParams(opts *pb.ModelOptions) { + lastLoadParamsMu.Lock() + defer lastLoadParamsMu.Unlock() + lastLoadParams = opts +} + +func snapshotLoadParams() *pb.ModelOptions { + lastLoadParamsMu.RLock() + defer lastLoadParamsMu.RUnlock() + return lastLoadParams +} + // promptHasToolResults checks if the prompt contains evidence of prior tool // execution — specifically the output from the mock MCP server's get_weather tool. func promptHasToolResults(prompt string) bool { @@ -43,7 +66,12 @@ func (m *MockBackend) Health(ctx context.Context, in *pb.HealthMessage) (*pb.Rep } func (m *MockBackend) LoadModel(ctx context.Context, in *pb.ModelOptions) (*pb.Result, error) { - xlog.Debug("LoadModel called", "model", in.Model) + xlog.Debug("LoadModel called", + "model", in.Model, + "modelfile", in.ModelFile, + "draft_model", in.DraftModel, + "mmproj", in.MMProj) + recordLoadParams(in) return &pb.Result{ Message: "Model loaded successfully (mocked)", Success: true, @@ -56,6 +84,31 @@ func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.R return nil, fmt.Errorf("mock backend predict error: simulated failure") } + // ECHO_LOAD_PARAMS lets path-resolution tests inspect what LoadModel + // received without adding a new RPC. The reply carries a JSON snapshot + // of the relevant ModelOptions fields so the test can assert that + // relative paths from the YAML have been resolved before reaching the + // backend. + if strings.Contains(in.Prompt, "ECHO_LOAD_PARAMS") { + opts := snapshotLoadParams() + snapshot := map[string]string{} + if opts != nil { + snapshot["model"] = opts.Model + snapshot["model_file"] = opts.ModelFile + snapshot["draft_model"] = opts.DraftModel + snapshot["mmproj"] = opts.MMProj + } + payload, err := json.Marshal(snapshot) + if err != nil { + return nil, fmt.Errorf("mock backend echo error: %w", err) + } + return &pb.Reply{ + Message: payload, + Tokens: int32(len(snapshot)), + PromptTokens: 1, + }, nil + } + // Simulate C++ autoparser: tool call via ChatDeltas, empty message if strings.Contains(in.Prompt, "AUTOPARSER_TOOL_CALL") { toolName := mockToolNameFromRequest(in) diff --git a/tests/e2e/path_resolution_test.go b/tests/e2e/path_resolution_test.go new file mode 100644 index 000000000000..7ad5a2f20f56 --- /dev/null +++ b/tests/e2e/path_resolution_test.go @@ -0,0 +1,57 @@ +package e2e_test + +import ( + "context" + "encoding/json" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/openai/openai-go/v3" +) + +// Regression test for https://github.com/mudler/LocalAI/issues/9675. +// Relative draft_model paths used to be sent verbatim to the backend, which +// then opened them from its CWD and failed with "No such file or directory". +// The fix in core/backend/options.go resolves draft_model against the +// configured models directory, mirroring the existing handling for the main +// model file and mmproj. +// +// The mock backend stashes the LoadModel ModelOptions and echoes them back +// in response to the ECHO_LOAD_PARAMS prompt, letting the test inspect the +// exact paths that crossed the gRPC boundary. +var _ = Describe("Backend Path Resolution", Label("MockBackend", "PathResolution"), func() { + It("resolves relative draft_model, mmproj, and main model paths against the models dir", func() { + resp, err := client.Chat.Completions.New( + context.TODO(), + openai.ChatCompletionNewParams{ + Model: "mock-model-path-resolution", + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("ECHO_LOAD_PARAMS"), + }, + }, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Choices).To(HaveLen(1)) + + var snapshot map[string]string + Expect(json.Unmarshal([]byte(resp.Choices[0].Message.Content), &snapshot)).To(Succeed(), + "expected ECHO_LOAD_PARAMS reply to be JSON, got: %q", resp.Choices[0].Message.Content) + + // The main model file is resolved by pkg/model/loader.go and has + // always worked; assert it as a baseline so the test fails loudly + // if that ever regresses too. + Expect(snapshot["model_file"]).To(Equal(filepath.Join(modelsPath, "subdir", "mock-main.bin")), + "main model file should be resolved against the models directory") + + // mmproj has had explicit join logic for a while — guard it so the + // next refactor does not silently drop it. + Expect(snapshot["mmproj"]).To(Equal(filepath.Join(modelsPath, "subdir", "mock-mmproj.bin")), + "mmproj should be resolved against the models directory") + + // The actual fix — without it, draft_model would be sent verbatim + // ("subdir/mock-draft.bin") and llama.cpp would fail to open it. + Expect(snapshot["draft_model"]).To(Equal(filepath.Join(modelsPath, "subdir", "mock-draft.bin")), + "draft_model should be resolved against the models directory (regression guard for #9675)") + }) +}) From 200bfcb098e0c3c5adee956a6edbe9dc56d58db8 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 5 May 2026 22:55:57 +0000 Subject: [PATCH 2/2] fix(backend): always join draft_model with models dir (drop IsAbs shortcut) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept absolute draft_model paths intact via an IsAbs check. That left a path-traversal vector open: a user-supplied YAML config could set draft_model to /etc/passwd (or any other host file the backend process can read) and the path would be sent through unchanged. filepath.Join cleans the leading slash from absolute components, so joining unconditionally — the way mmproj already does — keeps the result rooted at the configured models directory regardless of input. Adds a second e2e spec that feeds an absolute draft_model into the mock backend and asserts the path is clamped under modelsPath. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude:claude-opus-4-7-1m [Read] [Edit] [Bash] --- core/backend/options.go | 9 +++++---- tests/e2e/e2e_suite_test.go | 15 +++++++++++++++ tests/e2e/path_resolution_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/core/backend/options.go b/core/backend/options.go index 3246caec4795..ba8cab88b50f 100644 --- a/core/backend/options.go +++ b/core/backend/options.go @@ -246,10 +246,11 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions { opts.MMProj = filepath.Join(modelPath, c.MMProj) } - // Resolve draft_model relative to the models directory, mirroring the - // resolution that happens for parameters.model and mmproj. Without this, - // llama.cpp opens the path from the backend's CWD and fails. - if c.DraftModel != "" && !filepath.IsAbs(c.DraftModel) { + // Resolve draft_model against the models directory, mirroring the + // handling of parameters.model and mmproj. Always joining (without an + // IsAbs shortcut) prevents user-supplied configs from pointing the + // backend at arbitrary host files via an absolute path. + if c.DraftModel != "" { opts.DraftModel = filepath.Join(modelPath, c.DraftModel) } diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go index f17cc77b2be4..94cb05aad1a0 100644 --- a/tests/e2e/e2e_suite_test.go +++ b/tests/e2e/e2e_suite_test.go @@ -185,6 +185,21 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) Expect(os.WriteFile(filepath.Join(modelsPath, "mock-model-path-resolution.yaml"), pathResolutionData, 0644)).To(Succeed()) + // Same but with an absolute draft_model — must not let a user-supplied + // config reach files outside the models directory. filepath.Join + // strips the leading slash, so /etc/passwd becomes /etc/passwd. + pathEscapeCfg := map[string]any{ + "name": "mock-model-path-escape", + "backend": "mock-backend", + "parameters": map[string]any{ + "model": "subdir/mock-main.bin", + }, + "draft_model": "/etc/passwd", + } + pathEscapeData, err := yaml.Marshal(pathEscapeCfg) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(filepath.Join(modelsPath, "mock-model-path-escape.yaml"), pathEscapeData, 0644)).To(Succeed()) + // Diarization model — known_usecases bypasses the FLAG_DIARIZATION // backend-name guard so the /v1/audio/diarization route can dispatch // to the mock backend. diff --git a/tests/e2e/path_resolution_test.go b/tests/e2e/path_resolution_test.go index 7ad5a2f20f56..f2a6f36b1277 100644 --- a/tests/e2e/path_resolution_test.go +++ b/tests/e2e/path_resolution_test.go @@ -54,4 +54,30 @@ var _ = Describe("Backend Path Resolution", Label("MockBackend", "PathResolution Expect(snapshot["draft_model"]).To(Equal(filepath.Join(modelsPath, "subdir", "mock-draft.bin")), "draft_model should be resolved against the models directory (regression guard for #9675)") }) + + // A user-supplied YAML must not be able to escape the models directory + // by setting draft_model to an absolute path like "/etc/passwd". + // filepath.Join strips the leading slash, so the resulting path stays + // rooted at modelsPath even for adversarial input. + It("clamps absolute draft_model paths to the models directory", func() { + resp, err := client.Chat.Completions.New( + context.TODO(), + openai.ChatCompletionNewParams{ + Model: "mock-model-path-escape", + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("ECHO_LOAD_PARAMS"), + }, + }, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Choices).To(HaveLen(1)) + + var snapshot map[string]string + Expect(json.Unmarshal([]byte(resp.Choices[0].Message.Content), &snapshot)).To(Succeed()) + + Expect(snapshot["draft_model"]).To(Equal(filepath.Join(modelsPath, "etc", "passwd")), + "absolute draft_model paths must be clamped under the models directory") + Expect(snapshot["draft_model"]).ToNot(Equal("/etc/passwd"), + "a YAML config must not be able to point the backend at /etc/passwd") + }) })