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
8 changes: 8 additions & 0 deletions core/backend/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
31 changes: 31 additions & 0 deletions tests/e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <modelsPath>/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.
Expand Down
55 changes: 54 additions & 1 deletion tests/e2e/mock-backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"

pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/path_resolution_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading