mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 13:10:23 -04:00
* 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 <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7-1m [Read] [Edit] [Bash] [Write] * fix(backend): always join draft_model with models dir (drop IsAbs shortcut) 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 <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7-1m [Read] [Edit] [Bash] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
84 lines
3.5 KiB
Go
84 lines
3.5 KiB
Go
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)")
|
|
})
|
|
|
|
// 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")
|
|
})
|
|
})
|