From 78880679146cddb63746569dcdfd0cff86b53757 Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Tue, 23 Jun 2026 12:27:34 +0100 Subject: [PATCH 1/5] fix(settings): merge partial /api/settings updates instead of overwriting (#10463) POST /api/settings rebuilt runtime_settings.json from only the request body, so a focused admin page that submits a single field wiped every other persisted setting. The Middleware proxy tab (mitm_listen) and detector table (pii_default_detectors), plus the MCP SetBranding tool (instance_name/instance_tagline), all POST partial bodies; the no-omitempty api_keys and pii_default_detectors fields even round-tripped as JSON null. Read the persisted settings and overlay only the fields the request set (RuntimeSettings.MergeNonNil) before writing. Every field is a pointer, so the reflection-based merge is total over the struct and any field added later is preserved automatically. Absent or null fields are now kept; clearing a setting is done by sending its explicit empty/zero value (api_keys [], mitm_listen "", etc.), unchanged from before. The full Settings page sends every field, so its Save behaves identically. Assisted-by: Claude:claude-opus-4-8 Claude-Code Signed-off-by: Richard Palethorpe --- core/config/runtime_settings_persist.go | 30 ++++++++++++++ core/config/runtime_settings_persist_test.go | 42 +++++++++++++++++++ core/http/endpoints/localai/settings.go | 43 ++++++++++++-------- core/http/endpoints/localai/settings_test.go | 37 +++++++++++++++++ 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/core/config/runtime_settings_persist.go b/core/config/runtime_settings_persist.go index bb5a4110b336..98a923b40e9f 100644 --- a/core/config/runtime_settings_persist.go +++ b/core/config/runtime_settings_persist.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "reflect" ) // runtimeSettingsFile is the on-disk filename inside DynamicConfigsDir. @@ -33,6 +34,35 @@ func (o *ApplicationConfig) ReadPersistedSettings() (RuntimeSettings, error) { return settings, nil } +// MergeNonNil overlays every set (non-nil) field of overlay onto the +// receiver, leaving the receiver's value untouched wherever overlay left a +// field unset. Every RuntimeSettings field is a pointer precisely so "set" +// can be told apart from "absent" (see the type doc), which makes this a +// faithful partial update: a caller that submits only the field it owns +// changes exactly that field and never clobbers unrelated settings. +// +// This is the read-modify-write contract the persistence helpers exist for. +// UpdateSettingsEndpoint reads the on-disk settings, merges the request body +// on top, and writes the result — so a focused admin page that POSTs only its +// own field (the Middleware page sends only mitm_listen; the detector table +// only pii_default_detectors) no longer nulls every other setting. +// +// Reflection keeps the merge total over the struct: a field added to +// RuntimeSettings later is merged automatically, so the persistence path can +// never silently drop a new setting the way a hand-maintained field list +// would. Non-pointer fields (none today) are skipped — they cannot express +// "absent", so the receiver wins. +func (s *RuntimeSettings) MergeNonNil(overlay RuntimeSettings) { + dst := reflect.ValueOf(s).Elem() + src := reflect.ValueOf(overlay) + for i := 0; i < src.NumField(); i++ { + f := src.Field(i) + if f.Kind() == reflect.Pointer && !f.IsNil() { + dst.Field(i).Set(f) + } + } +} + // WritePersistedSettings serialises the given RuntimeSettings to // runtime_settings.json with restricted permissions (it may carry API // keys and P2P tokens). diff --git a/core/config/runtime_settings_persist_test.go b/core/config/runtime_settings_persist_test.go index a36acb0d26ae..7f5eb07a82e3 100644 --- a/core/config/runtime_settings_persist_test.go +++ b/core/config/runtime_settings_persist_test.go @@ -12,6 +12,7 @@ import ( ) func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } var _ = Describe("RuntimeSettings persistence helpers", func() { var ( @@ -51,6 +52,47 @@ var _ = Describe("RuntimeSettings persistence helpers", func() { }) }) + // MergeNonNil is the partial-update primitive UpdateSettingsEndpoint + // relies on: a focused admin page POSTs only the field it owns, and the + // handler reads the on-disk settings and overlays the request on top. + // Without it, the body would be written verbatim and every field the + // caller omitted would be nulled (the reported regression: changing + // mitm_listen wiped the galleries, api keys, watchdog config, etc.). + Describe("MergeNonNil partial update", func() { + It("overlays set fields and preserves unset ones", func() { + base := config.RuntimeSettings{ + MITMListen: strPtr(":9000"), + Galleries: &[]config.Gallery{{Name: "g1", URL: "http://example/g1"}}, + WatchdogIdleEnabled: boolPtr(true), + ApiKeys: &[]string{"persisted-key"}, + PIIDefaultDetectors: &[]string{"det-a"}, + } + + // Simulate the Middleware proxy tab: only mitm_listen is sent. + overlay := config.RuntimeSettings{MITMListen: strPtr(":8443")} + base.MergeNonNil(overlay) + + Expect(base.MITMListen).ToNot(BeNil()) + Expect(*base.MITMListen).To(Equal(":8443"), "set field should be overlaid") + // Everything the overlay left unset must survive untouched. + Expect(base.Galleries).ToNot(BeNil(), "galleries were clobbered") + Expect(*base.Galleries).To(HaveLen(1)) + Expect(base.WatchdogIdleEnabled).ToNot(BeNil()) + Expect(*base.WatchdogIdleEnabled).To(BeTrue()) + Expect(base.ApiKeys).ToNot(BeNil(), "api_keys were clobbered") + Expect(*base.ApiKeys).To(Equal([]string{"persisted-key"})) + Expect(base.PIIDefaultDetectors).ToNot(BeNil(), "pii_default_detectors were clobbered") + Expect(*base.PIIDefaultDetectors).To(Equal([]string{"det-a"})) + }) + + It("lets an explicit empty slice clear a field", func() { + base := config.RuntimeSettings{PIIDefaultDetectors: &[]string{"det-a"}} + base.MergeNonNil(config.RuntimeSettings{PIIDefaultDetectors: &[]string{}}) + Expect(base.PIIDefaultDetectors).ToNot(BeNil()) + Expect(*base.PIIDefaultDetectors).To(BeEmpty(), "an explicit empty slice should clear, not preserve") + }) + }) + // MITM round trip pins the contract that loadRuntimeSettingsFromFile // MITM listener address must survive a write/read round trip so the // next process restart can bring the listener back up. (Intercept diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go index 7d970f820890..be63589391e4 100644 --- a/core/http/endpoints/localai/settings.go +++ b/core/http/endpoints/localai/settings.go @@ -4,8 +4,6 @@ import ( "encoding/json" "io" "net/http" - "os" - "path/filepath" "time" "github.com/labstack/echo/v4" @@ -110,6 +108,18 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { }) } + // Read whatever is already persisted: it is both the source of truth + // for branding asset filenames (below) and the base we merge this + // request onto before writing. A read failure must not let a Save + // silently discard the existing settings — surface it instead. + persisted, err := appConfig.ReadPersistedSettings() + if err != nil { + return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{ + Success: false, + Error: "Failed to read existing settings: " + err.Error(), + }) + } + // Branding asset filenames are owned exclusively by // /api/branding/asset/{kind} (upload/delete). The Settings page also // round-trips them via GET /api/settings, but its local state is stale @@ -118,11 +128,9 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { // at page open. Replace whatever the body sent for these three fields // with the values currently on disk so /api/settings can never // regress them. - if existing, err := appConfig.ReadPersistedSettings(); err == nil { - settings.LogoFile = existing.LogoFile - settings.LogoHorizontalFile = existing.LogoHorizontalFile - settings.FaviconFile = existing.FaviconFile - } + settings.LogoFile = persisted.LogoFile + settings.LogoHorizontalFile = persisted.LogoHorizontalFile + settings.FaviconFile = persisted.FaviconFile // The UI reads ApiKeys from GET /api/settings, which already returns the // merged env+runtime list. When the user clicks Save, the same merged @@ -145,16 +153,17 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { settings.ApiKeys = &runtimeOnly } - settingsFile := filepath.Join(appConfig.DynamicConfigsDir, "runtime_settings.json") - settingsJSON, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{ - Success: false, - Error: "Failed to marshal settings: " + err.Error(), - }) - } - - if err := os.WriteFile(settingsFile, settingsJSON, 0600); err != nil { + // Persist as a partial update: overlay only the fields this request set + // onto the settings already on disk. Focused admin pages POST just the + // keys they own (the Middleware proxy tab sends only mitm_listen; the + // detector table only pii_default_detectors), so writing the request + // body verbatim would null every unrelated setting (the no-omitempty + // api_keys / pii_default_detectors fields even round-trip as JSON + // null). The full Settings page still round-trips every field, so its + // Save is unchanged. + toPersist := persisted + toPersist.MergeNonNil(settings) + if err := appConfig.WritePersistedSettings(toPersist); err != nil { return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{ Success: false, Error: "Failed to write settings file: " + err.Error(), diff --git a/core/http/endpoints/localai/settings_test.go b/core/http/endpoints/localai/settings_test.go index 25c84e1b77b1..7ba82e1a3f60 100644 --- a/core/http/endpoints/localai/settings_test.go +++ b/core/http/endpoints/localai/settings_test.go @@ -52,6 +52,10 @@ var _ = Describe("Settings endpoints", func() { // Settings are persisted here; set after construction since there's no // dedicated AppOption for it. app.ApplicationConfig().DynamicConfigsDir = tmp + // Contain the MITM CA inside tmp too. The partial-save spec flips + // mitm_listen, which starts the listener and writes a CA; without this + // it defaults to ./mitm-ca and litters the package source tree. + app.ApplicationConfig().MITMCADir = filepath.Join(tmp, "mitm-ca") e = echo.New() e.GET("/api/settings", GetSettingsEndpoint(app)) @@ -109,6 +113,39 @@ var _ = Describe("Settings endpoints", func() { Expect(err).ToNot(HaveOccurred()) }) + // Regression: a focused admin page (the Middleware proxy tab) POSTs only + // the one field it owns — mitm_listen. The old handler wrote the request + // body verbatim, so every other persisted setting was dropped (and + // api_keys / pii_default_detectors, which lack omitempty, were written as + // null). A partial POST must now merge onto what is already on disk. + It("preserves unrelated persisted settings when a partial POST sets only mitm_listen", func() { + // First save establishes a fuller settings file (as the full Settings + // page would): galleries, an API key, and the MITM listener. The + // listener restart binds a real socket, so use 127.0.0.1:0 for an + // ephemeral free port rather than a fixed one that may be in use. + rec := post(`{"mitm_listen":"127.0.0.1:0","galleries":[{"name":"g1","url":"http://example/g1"}],"api_keys":["k1"],"pii_default_detectors":["det-a"]}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + + // The Middleware proxy tab then changes only the listen address — the + // exact partial body that nulled everything else before the fix. + rec = post(`{"mitm_listen":"127.0.0.1:0"}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + + raw, err := os.ReadFile(filepath.Join(tmp, "runtime_settings.json")) + Expect(err).ToNot(HaveOccurred()) + var ondisk config.RuntimeSettings + Expect(json.Unmarshal(raw, &ondisk)).To(Succeed()) + + Expect(ondisk.MITMListen).ToNot(BeNil()) + Expect(*ondisk.MITMListen).To(Equal("127.0.0.1:0"), "the changed field should be saved") + Expect(ondisk.Galleries).ToNot(BeNil(), "galleries were clobbered by the partial save") + Expect(*ondisk.Galleries).To(HaveLen(1)) + Expect(ondisk.ApiKeys).ToNot(BeNil(), "api_keys were nulled by the partial save") + Expect(*ondisk.ApiKeys).To(Equal([]string{"k1"})) + Expect(ondisk.PIIDefaultDetectors).ToNot(BeNil(), "pii_default_detectors were nulled by the partial save") + Expect(*ondisk.PIIDefaultDetectors).To(Equal([]string{"det-a"})) + }) + // Residual #9125: enabling the watchdog from a cold (off) state via the // React master toggle must start the live watchdog immediately, without a // restart. The toggle posts watchdog_idle_enabled/busy_enabled=true while From 2edc4e25b3b395816fb09cbf40cfd208f63aee81 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:27:51 +0200 Subject: [PATCH 2/5] chore: :arrow_up: Update ggml-org/whisper.cpp to `bae6bc02b1940bbfb87b6a0299c565e563b916d1` (#10459) :arrow_up: Update ggml-org/whisper.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/go/whisper/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go/whisper/Makefile b/backend/go/whisper/Makefile index 9858b1d07cd1..65c3d97d1d11 100644 --- a/backend/go/whisper/Makefile +++ b/backend/go/whisper/Makefile @@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1) # whisper.cpp version WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp -WHISPER_CPP_VERSION?=5ed76e9a079962f1c85cfce44edd325c27ef1f97 +WHISPER_CPP_VERSION?=bae6bc02b1940bbfb87b6a0299c565e563b916d1 SO_TARGET?=libgowhisper.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF From 1d49041c85d936fed61529818b023518fd7069cd Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:28:09 +0200 Subject: [PATCH 3/5] chore: :arrow_up: Update ggml-org/llama.cpp to `73618f27a801c0b8614ceaf3547d3c2a99baae14` (#10458) :arrow_up: Update ggml-org/llama.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/cpp/llama-cpp/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cpp/llama-cpp/Makefile b/backend/cpp/llama-cpp/Makefile index f6e89c5eac04..122a30c717d3 100644 --- a/backend/cpp/llama-cpp/Makefile +++ b/backend/cpp/llama-cpp/Makefile @@ -1,5 +1,5 @@ -LLAMA_VERSION?=7c082bc417bbe53210a83df4ba5b49e18ce6193c +LLAMA_VERSION?=73618f27a801c0b8614ceaf3547d3c2a99baae14 LLAMA_REPO?=https://github.com/ggerganov/llama.cpp CMAKE_ARGS?= From 67c88898662e388bbb29e7546121d1a28f0ef77e Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:28:49 +0200 Subject: [PATCH 4/5] chore: :arrow_up: Update CrispStrobe/CrispASR to `63b57289255267edf66e43e33bc3911e04a2e92d` (#10455) :arrow_up: Update CrispStrobe/CrispASR Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/go/crispasr/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go/crispasr/Makefile b/backend/go/crispasr/Makefile index 21f66c2408ff..27e3b30d2fb2 100644 --- a/backend/go/crispasr/Makefile +++ b/backend/go/crispasr/Makefile @@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1) # CrispASR version (release tag) CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR -CRISPASR_VERSION?=7a8cb80907341c0204bd0488c1244764f4163883 +CRISPASR_VERSION?=63b57289255267edf66e43e33bc3911e04a2e92d SO_TARGET?=libgocrispasr.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF From 06a7b6cadb82b1691c4823c5d1abe4ff76b9c17f Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:29:07 +0200 Subject: [PATCH 5/5] chore: :arrow_up: Update leejet/stable-diffusion.cpp to `f440ad9c29dd8bc34e5d1f4b863832b96d6ea05f` (#10457) :arrow_up: Update leejet/stable-diffusion.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/go/stablediffusion-ggml/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go/stablediffusion-ggml/Makefile b/backend/go/stablediffusion-ggml/Makefile index f77baccad617..05b57b254ed8 100644 --- a/backend/go/stablediffusion-ggml/Makefile +++ b/backend/go/stablediffusion-ggml/Makefile @@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1) # stablediffusion.cpp (ggml) STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp -STABLEDIFFUSION_GGML_VERSION?=b12098f5d09fc83da36e65c784f7bdb16a5a5ebf +STABLEDIFFUSION_GGML_VERSION?=f440ad9c29dd8bc34e5d1f4b863832b96d6ea05f CMAKE_ARGS+=-DGGML_MAX_NAME=128