diff --git a/core/backend/options.go b/core/backend/options.go index 9054bb39b693..ba8cab88b50f 100644 --- a/core/backend/options.go +++ b/core/backend/options.go @@ -246,6 +246,14 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions { opts.MMProj = filepath.Join(modelPath, c.MMProj) } + // 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) + } + return opts } diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go index e140b7f1bbc5..94cb05aad1a0 100644 --- a/tests/e2e/e2e_suite_test.go +++ b/tests/e2e/e2e_suite_test.go @@ -169,6 +169,37 @@ 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()) + + // 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/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..f2a6f36b1277 --- /dev/null +++ b/tests/e2e/path_resolution_test.go @@ -0,0 +1,83 @@ +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") + }) +})