Files
LocalAI/core/http/endpoints/localai/settings.go
T
mudler-agentandEttore Di Giacinto a9e19746be fix(addons): make owner settings authoritative (#13033)
* feat(addons): add a durable host policy store

Keep bounded host policy in one owner-managed durable store. Use revision
checks and retained receipts to reconcile updates without stale writes.

Require a sole migration source and explicit owner selection for clusters.
Restrict provider pins to tools and skills that existing consumers replace.
Document the internal scope; UI and carrier integration remain separate.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): reject malformed host policy JSON

Reject duplicate keys, noncanonical fields, and incomplete durable
structures before JSON decoding can discard evidence of corruption.
Validate provider keys before deletion so invalid removals cannot commit.

Keep empty provider sets canonical when writing migrated policies.
Regressions cover malformed documents, unchanged storage on rejection,
and replay with case-sensitive operation IDs.

Assisted-by: OpenAI:ChatGPT
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): decouple host policy validation

Move shared egress parsers into a dependency-light package so the gateway
can consume host settings without importing itself through core/config.
Keep config wrappers and a host type alias to preserve existing callers.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): carry host policy actions to owner

Keep host policy reads and updates on the authenticated owner. The owner
checks current administrator authority before updates and preserves CAS
conflicts and durable operation receipts across forwarding.

Provide read-only snapshots for trusted hosts over pinned mTLS. Missing
owner services never fall back to frontend policy. Application startup,
public settings routes and runtime consumers remain separate work.

Verify typed actions with local and real mTLS forwarding tests, including
revoked actors, strict payloads, receipt replay and unavailable stores.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep policy provenance private

Return a bounded policy projection from gateway reads, updates and replay.
Trusted host consumers use the same projection without migration metadata.
The private owner store retains provenance for migration records.

Cover local and pinned-carrier output, durable provenance and typed errors.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): initialize owner host settings

Open durable host settings only after the owner acquires its storage guard.
Keep frontends forwarding without importing their local configuration.

Preserve automatic migration for the sole combined host configuration.
Require explicit owner-local selection for non-default dedicated policy.
Close the store once on cancellation, shutdown or startup failure.

Cover migration, restrictive defaults, restart revisions, corruption,
duplicate owners and frontend isolation in the native bootstrap test.
Document the owner-only migration option and storage constraints.

Assisted-by: OpenAI:ChatGPT
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): close settings before the owner guard

Startup failures released the owner guard before closing host settings.
Use one deferred cleanup to preserve the close order on every failure.

Test real runtime and gateway failures, shutdown, and cancellation.
Check once-only closure and reacquire both locks after cleanup.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): enforce owner egress policy in Studio

Read owner policy for Studio defaults, views, cards and enforcement.
Frontend configuration must not override policy or mask an owner outage.
Propagate context and policy errors instead of reusing a cached allow.
Preserve local settings behavior when add-ons are disabled.

Cover existing consumers through independent pinned mTLS carriers.
Verify live tightening, pending-card outages and restart persistence.
Document the read semantics and the proxy verdict-cache boundary.

Assisted-by: OpenAI:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): read provider pins from owner policy

Studio reads current owner pins for tools and skills instead of frontend
configuration when add-ons are enabled. Policy failures stop selection
rather than silently choosing built-ins. Disabled add-ons retain local
configuration behavior.

Cover owner updates and outages, and document the read contract.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): abort turns on mandatory policy errors

Sessions must not treat owner policy failures as optional tool outages.
Stop priming before harness startup and abort turns before model sends.
Preserve wrapped policy causes and existing optional connector behavior.

Cover sent turns, priming failures, and optional connector failures.
Document the failure contract without changing fallback or cache rules.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): use owner policy for auto-upgrades

Read fresh owner policy before each automatic upgrade pass. Skip the
pass when that policy is unavailable instead of trusting local settings.

Wire a real owner policy store into the transportless manager fixture.
Keep assertions for stale frontend settings and owner policy changes.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): guard legacy settings writes

Reject migrated owner policy fields before applying any local settings.
Omit those fields from legacy reads while add-ons are enabled, and keep
unrelated local settings available without an owner request.

Cover presence, case folding, duplicate keys and atomic rejection.
Document the legacy contract and unchanged disabled behavior.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): use the existing Ginkgo suites

Register settings authority regressions with the existing suites to
comply with the Ginkgo/Gomega test policy. Preserve the conflict,
compatibility, owner outage, and upgrade assertions without adding
another RunSpecs bootstrap.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(egress): bound wildcard host policy grants

Support descendant and public-any patterns through the shared parser.
Keep wildcard grants inside the administrator ceiling and public guard.
Preserve the legacy exact-host exception under the registries ceiling.

Keep policy strings lossless for existing receipts and stored exact hosts.
Test canonical matching, private exclusions, ports, and policy replay.
Proxy connection authorization and cache changes remain separate.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(egress): authorize each new connection

Remove cached allows so policy tightening and owner outages block new
connections immediately. Keep existing DNS, private-address and SNI guards.
Socket tests cover HTTP keep-alive, CONNECT and consumed grants.
Established streams may finish under their original authorization.

Expect the canonical runsc path in the gVisor test, matching production.
This preserves the path contract when the test temp directory is a symlink.
A short alias avoids Unix socket path limits in deep worktrees.

Verify with go test -race -count=1 -timeout=180s ./addons/sandbox/...
using an offline environment and a short alias to the worktree temp folder.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): save network policy through the owner

Use owner revisions for host controls instead of frontend runtime settings.
Keep drafts across conflicts and outages, and replay uncertain writes with
the same operation identity. Read authoritative policy after each save.

Accept bounded host wildcards while keeping extra registries exact.
Keep typed private-access confirmation out of owner policy patches.
Add rendered form and client tests, and document reconciliation behavior.

Assisted-by: OpenAI: GPT-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): fail closed on owner policy errors

Reject partial, mistyped, unknown and oversized owner snapshots before
publishing them to the form. Match the complete Go policy JSON shape.

Keep owner availability errors and uncertain operation identities when
Discard resets the local draft. Only reconciliation can reopen saving.

Tests reproduce both review failures and cover immutable retries,
malformed reloads and the rendered discard-then-edit regression.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): finish owner settings controls

Route skill switches, provider choices and upgrade controls through owner
policy. Preserve per-key changes when another frontend updates the policy.
Keep local tool execution controls on their existing settings path.

Extend the existing state checklist and add-on acceptance guidance so new
controls declare their authority and test both deployment topologies.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): check policy errors in Tables skill tests

Adapt the Tables test added on master to the error-aware skillTurn API.
Assert that normal skill selection succeeds instead of discarding errors.

Verified with make test-distributed-fakebus and scoped golangci-lint.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): document guarded settings IO boundaries

Explain the narrow gosec exceptions for the authenticated administrator
settings response and fixed owner-root files. Keep symlink, mode and type
checks intact; no request-supplied paths reach this store.

Explicitly ignore best-effort temporary-file and directory cleanup errors;
file and directory sync failures still determine durability.

Verified owner-store and handler race tests and scoped golangci-lint.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): run carrier policy cases in Ginkgo

Register projection, forwarding, validation and CAS cases with the existing
gateway suite. Check cleanup errors in test fixtures and explicitly ignore
response-body cleanup after decoding in the internal read client.

Verified gateway/application authority race tests and scoped lint.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): inventory owner policy gateway cases

Require all four owner-policy cases in both the per-report and aggregate
gateway and artifact acceptance inventories. Keep exact matching intact.

Verified real test reports with the existing strict Ginkgo report validator:
215 gateway cases and 252 artifact cases passed, without skipped cases.
The full browser readiness probe remains a separate CI gate.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): scope library settings apply action

Select the exact Apply button in the runtime settings pending bar. The
owner upgrade control has a separate Apply upgrade setting button, so an
unscoped substring locator is ambiguous.

Verified the failing Playwright case against the real UI test server and
Google Chrome, with isolated server and browser temporary paths.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(egress): update route and invalid-host fixtures

Use the context-aware egress callback in route tests. Keep the last-valid
list regression meaningful by using a partial-label wildcard, since a
whole-label descendant pattern is now valid input.

Verified focused application and route race tests and scoped lint.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(settings): reject partial-label wildcard fixture

Keep the invalid-input regression while accepting the newly supported
whole-label descendant wildcard. Use api.*.example.com as invalid input.

Reproduced the full endpoint suite failure, then verified all 616 endpoint
specs pass and scoped lint is clean.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 18:48:39 +01:00

537 lines
22 KiB
Go

package localai
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/studio/hostexec"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/vram"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/xlog"
)
// These keys belong to the add-on owner, not the local runtime settings file.
// Keep GET filtering and presence-aware POST rejection on the same list.
var addonHostSettingsKeys = [...]string{
"chats_egress_ceiling", "chats_egress_default", "chats_egress_allow_private",
"chats_egress_ports", "chats_egress_extra_registries", "chats_egress_allowed_hosts",
"chats_tool_providers", "auto_upgrade_addons",
}
// GetSettingsEndpoint returns current settings with precedence (env > file > defaults)
func GetSettingsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
appConfig := app.ApplicationConfig()
settings := appConfig.ToRuntimeSettings()
if appConfig.Addons.Enabled {
// Do not expose stale local values as policy, even when the owner is
// unavailable. Unrelated settings remain readable without an owner RPC.
// This administrative endpoint already returns RuntimeSettings (including
// API keys) below. Preserve that authorized contract while removing the
// owner-only fields; this projection is not a public policy response.
body, err := json.Marshal(settings) // #nosec G117 -- authenticated administrator settings response; same fields as the non-add-on path.
if err != nil {
return err
}
var projection map[string]json.RawMessage
if err := json.Unmarshal(body, &projection); err != nil {
return err
}
for _, key := range addonHostSettingsKeys {
delete(projection, key)
}
projection["addon_host_settings_authority"] = json.RawMessage(`"owner_required"`)
return c.JSON(http.StatusOK, projection)
}
return c.JSON(http.StatusOK, settings)
}
}
// UpdateSettingsEndpoint updates settings, saves to file, and applies immediately
func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
appConfig := app.ApplicationConfig()
startupConfig := app.StartupConfig()
if startupConfig == nil {
startupConfig = appConfig
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Failed to read request body: " + err.Error(),
})
}
if appConfig.Addons.Enabled {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false, Error: "Failed to parse JSON: " + err.Error(),
})
}
// Check presence before typed decoding: null and even invalid typed
// values must reject the entire patch before any local side effects.
// EqualFold matches encoding/json's case-insensitive tagged names.
for field := range fields {
for _, key := range addonHostSettingsKeys {
if strings.EqualFold(field, key) {
return c.JSON(http.StatusConflict, schema.SettingsResponse{
Success: false, Error: key + " is set on the add-on owner, not through /api/settings",
})
}
}
}
}
var settings config.RuntimeSettings
if err := json.Unmarshal(body, &settings); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Failed to parse JSON: " + err.Error(),
})
}
// The ports and the SearXNG address of the chats' web tools.
if settings.ChatsWebPorts != nil {
if _, err := config.ParsePorts(*settings.ChatsWebPorts); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid chats_web_ports: " + err.Error(),
})
}
}
if settings.ChatsWebSearXNGURL != nil {
if err := config.ValidateSearXNGURL(*settings.ChatsWebSearXNGURL); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid chats_web_searxng_url: " + err.Error(),
})
}
}
if settings.ChatsWebSearchProviders != nil {
if _, err := config.ParseSearchProviders(*settings.ChatsWebSearchProviders, true); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid chats_web_search_providers: " + err.Error(),
})
}
}
// The network of the Sandbox commands.
if msg := validateEgressSettings(settings, body, appConfig.ChatsEgressAllowPrivate); msg != "" {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{Success: false, Error: msg})
}
// Validate timeouts if provided
if settings.WatchdogIdleTimeout != nil {
if _, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid watchdog_idle_timeout format: " + err.Error(),
})
}
}
if settings.WatchdogBusyTimeout != nil {
if _, err := time.ParseDuration(*settings.WatchdogBusyTimeout); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid watchdog_busy_timeout format: " + err.Error(),
})
}
}
if settings.WatchdogInterval != nil {
if _, err := time.ParseDuration(*settings.WatchdogInterval); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid watchdog_interval format: " + err.Error(),
})
}
}
if settings.LRUEvictionRetryInterval != nil {
if _, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid lru_eviction_retry_interval format: " + err.Error(),
})
}
}
if settings.OpenResponsesStoreTTL != nil {
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
if _, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid open_responses_store_ttl format: " + err.Error(),
})
}
}
}
// Reject a malformed VRAM budget up front so a bad value never reaches
// the config (ApplyRuntimeSettings is fail-open and would silently skip
// installing the cap). An empty string is allowed: it clears the cap.
if settings.VRAMBudget != nil && *settings.VRAMBudget != "" {
if _, err := vrambudget.Parse(*settings.VRAMBudget); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid vram_budget format: " + err.Error(),
})
}
}
// The built-in chat tools: a mode the chats understand, and pins only for
// tools and skills an add-on may replace.
if settings.ChatsHostExec != nil && !hostexec.ValidMode(*settings.ChatsHostExec) {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid chats_host_exec: use off, ask or on",
})
}
if settings.ChatsToolProviders != nil {
for tool, addon := range *settings.ChatsToolProviders {
if !(contract.Replaceable(tool) || contract.ReplaceableSkill(tool)) || !validAddonID(addon) {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid chats_tool_providers: map a replaceable tool (bash, web_search, web_fetch) or skill (xlsx, docx, pptx, pdf) to an add-on ID",
})
}
}
}
// The owner's value is the deployment's value. A frontend reads it from
// the owner at every refresh, so a change made here would be undone and
// would not reach the other frontends.
if app.AddonFrontend() && settings.OfficialLibraries != nil && *settings.OfficialLibraries != appConfig.OfficialLibraries {
return c.JSON(http.StatusConflict, schema.SettingsResponse{
Success: false,
Error: "official_libraries is set on the add-on owner, not on a frontend",
})
}
// Generate P2P token before saving so the real token is persisted (not "0")
if settings.P2PToken != nil && *settings.P2PToken == "0" {
token := p2p.GenerateToken(60, 60)
settings.P2PToken = &token
}
// Save to file
if appConfig.DynamicConfigsDir == "" {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "DynamicConfigsDir is not set",
})
}
// 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
// once an asset has been uploaded — clicking Save would otherwise
// clobber the uploaded basename with the empty string the UI loaded
// 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.
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
// list comes back in the POST body. Strip the env-supplied keys from
// the incoming list before we persist or re-merge, otherwise each save
// duplicates the env keys on top of the previous merge (#9071).
if settings.ApiKeys != nil {
envKeys := startupConfig.ApiKeys
envSet := make(map[string]struct{}, len(envKeys))
for _, k := range envKeys {
envSet[k] = struct{}{}
}
runtimeOnly := make([]string, 0, len(*settings.ApiKeys))
for _, k := range *settings.ApiKeys {
if _, fromEnv := envSet[k]; fromEnv {
continue
}
runtimeOnly = append(runtimeOnly, k)
}
settings.ApiKeys = &runtimeOnly
}
// 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(),
})
}
// Apply settings using centralized method
prevGalleries, prevBackendGalleries := appConfig.Galleries, appConfig.BackendGalleries
// The legacy ApplyRuntimeSettings result drives live watchdog
// reconfiguration, not a server restart. Resolver preference changes
// take effect only at startup and must not cycle that unrelated service.
prevDevelopmentPreference := appConfig.PreferDevelopmentBackends
watchdogChanged := appConfig.ApplyRuntimeSettings(&settings)
restartRequired := prevDevelopmentPreference != appConfig.PreferDevelopmentBackends
gallery.ResetGalleryModelCacheIfChanged(prevGalleries, prevBackendGalleries, appConfig)
if settings.OfficialLibraries != nil {
// The shipped default sources of every kind follow the switch now.
if err := app.ApplyLibraries(); err != nil {
xlog.Warn("could not apply the official libraries setting", "error", err)
}
}
if settings.VRAMPersistentCache != nil || settings.AutoloadGalleries != nil {
if appConfig.VRAMPersistentCache && appConfig.AutoloadGalleries {
vram.ConfigurePersistentCache(filepath.Join(appConfig.SystemState.Model.ModelsPath, "..", "cache", "vram"), 24*time.Hour)
} else {
vram.DisablePersistentCache()
}
}
// Handle API keys specially (merge with startup keys)
if settings.ApiKeys != nil {
appConfig.ApiKeys = config.MergeAPIKeys(startupConfig.ApiKeys, *settings.ApiKeys)
}
// Update backend logging dynamically
if settings.EnableBackendLogging != nil {
app.ModelLoader().SetBackendLoggingEnabled(*settings.EnableBackendLogging)
xlog.Info("Updated backend logging setting", "enableBackendLogging", *settings.EnableBackendLogging)
}
// Update watchdog dynamically for settings that don't require restart
if settings.ForceEvictionWhenBusy != nil {
currentWD := app.ModelLoader().GetWatchDog()
if currentWD != nil {
currentWD.SetForceEvictionWhenBusy(*settings.ForceEvictionWhenBusy)
xlog.Info("Updated watchdog force eviction when busy setting", "forceEvictionWhenBusy", *settings.ForceEvictionWhenBusy)
}
}
// Update ModelLoader LRU eviction retry settings dynamically
maxRetries := appConfig.LRUEvictionMaxRetries
retryInterval := appConfig.LRUEvictionRetryInterval
if settings.LRUEvictionMaxRetries != nil {
maxRetries = *settings.LRUEvictionMaxRetries
}
if settings.LRUEvictionRetryInterval != nil {
if dur, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err == nil {
retryInterval = dur
}
}
if settings.LRUEvictionMaxRetries != nil || settings.LRUEvictionRetryInterval != nil {
app.ModelLoader().SetLRUEvictionRetrySettings(maxRetries, retryInterval)
xlog.Info("Updated LRU eviction retry settings", "maxRetries", maxRetries, "retryInterval", retryInterval)
}
// Update Open Responses store TTL dynamically
if settings.OpenResponsesStoreTTL != nil {
ttl := time.Duration(0)
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
ttl = dur
} else {
xlog.Warn("Invalid Open Responses store TTL format", "ttl", *settings.OpenResponsesStoreTTL, "error", err)
}
}
// Import the store package
store := openresponses.GetGlobalStore()
store.SetTTL(ttl)
xlog.Info("Updated Open Responses store TTL", "ttl", ttl)
}
// Restart watchdog if settings changed.
//
// The live start/stop decision derives from the post-apply config
// (WatchdogShouldRun) rather than the raw watchdog_enabled request
// field: the React master toggle only ever writes the idle/busy flags,
// so keying off watchdog_enabled left the live watchdog stopped on a
// cold enable until the next restart (#9125). WatchdogShouldRun mirrors
// the gating in startWatchdog, so a cold enable starts it immediately
// and a full disable (both checks off, no LRU / memory reclaimer) stops
// it.
if watchdogChanged {
if !appConfig.WatchdogShouldRun() {
if err := app.StopWatchdog(); err != nil {
xlog.Error("Failed to stop watchdog", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Settings saved but failed to stop watchdog: " + err.Error(),
})
}
} else {
if err := app.RestartWatchdog(); err != nil {
xlog.Error("Failed to restart watchdog", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Settings saved but failed to restart watchdog: " + err.Error(),
})
}
}
}
// Rebuild the MITM listener when its address OR the instance-wide
// default detectors change. The per-host detector map is resolved once
// at listener start (startMITMLocked → ResolvePIIPolicy), so a
// default-detector change is otherwise invisible to cloud-proxy traffic
// until the next restart — an admin toggling a default detector would
// see no redaction. RestartMITM is a no-op when the listener is
// disabled (empty address).
if settings.MITMListen != nil || settings.PIIDefaultDetectors != nil {
if err := app.RestartMITM(); err != nil {
xlog.Error("Failed to restart MITM proxy", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Settings saved but failed to restart MITM proxy: " + err.Error(),
})
}
}
// Restart P2P if P2P settings changed
p2pChanged := settings.P2PToken != nil || settings.P2PNetworkID != nil || settings.Federated != nil
if p2pChanged {
if settings.P2PToken != nil && *settings.P2PToken == "" {
if err := app.StopP2P(); err != nil {
xlog.Error("Failed to stop P2P", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Settings saved but failed to stop P2P: " + err.Error(),
})
}
} else {
if err := app.RestartP2P(); err != nil {
xlog.Error("Failed to restart P2P", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
Success: false,
Error: "Settings saved but failed to restart P2P: " + err.Error(),
})
}
}
}
return c.JSON(http.StatusOK, schema.SettingsResponse{
Success: true,
Message: "Settings updated successfully",
RestartRequired: restartRequired,
})
}
}
var addonIDShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
func validAddonID(id string) bool { return addonIDShape.MatchString(id) }
// validateEgressSettings checks the chats_egress_* settings of a request and
// returns the error to show, or "". Turning chats_egress_allow_private on
// needs chats_egress_private_confirm with the typed phrase in the same body;
// the phrase is a person's act, it is not stored.
func validateEgressSettings(s config.RuntimeSettings, body []byte, privateOn bool) string {
for name, v := range map[string]*string{"chats_egress_ceiling": s.ChatsEgressCeiling, "chats_egress_default": s.ChatsEgressDefault} {
if v != nil && !config.ValidEgressPreset(*v) {
return "Invalid " + name + ": use off, registries or all"
}
}
if s.ChatsEgressPorts != nil {
if _, err := config.ParseEgressPorts(*s.ChatsEgressPorts); err != nil {
return "Invalid chats_egress_ports: " + err.Error()
}
}
if s.ChatsEgressExtraRegistries != nil {
if _, err := config.ParseEgressHosts(*s.ChatsEgressExtraRegistries); err != nil {
return "Invalid chats_egress_extra_registries: " + err.Error()
}
}
if s.ChatsEgressAllowedHosts != nil {
// An IP literal is judged with the private switch as this request
// leaves it.
priv := privateOn
if s.ChatsEgressAllowPrivate != nil {
priv = *s.ChatsEgressAllowPrivate
}
if _, err := config.ParseEgressAllowedHosts(*s.ChatsEgressAllowedHosts, priv); err != nil {
return "Invalid chats_egress_allowed_hosts: " + err.Error()
}
}
if s.ChatsEgressAuditDays != nil && (*s.ChatsEgressAuditDays < 1 || *s.ChatsEgressAuditDays > 365) {
return "Invalid chats_egress_audit_days: use 1 to 365"
}
if s.ChatsIndexMaxTextBytes != nil && (*s.ChatsIndexMaxTextBytes < config.MinChatsIndexMaxTextBytes || *s.ChatsIndexMaxTextBytes > config.MaxChatsIndexMaxTextBytes) {
return fmt.Sprintf("Invalid chats_index_max_text_bytes: use %d to %d", config.MinChatsIndexMaxTextBytes, config.MaxChatsIndexMaxTextBytes)
}
if s.ChatsMaxIterations != nil && (*s.ChatsMaxIterations < 1 || *s.ChatsMaxIterations > config.MaxChatsMaxIterations) {
return fmt.Sprintf("Invalid chats_max_iterations: use 1 to %d", config.MaxChatsMaxIterations)
}
if s.ChatsGoalMaxTurns != nil && (*s.ChatsGoalMaxTurns < 1 || *s.ChatsGoalMaxTurns > config.MaxChatsGoalMaxTurns) {
return fmt.Sprintf("Invalid chats_goal_max_turns: use 1 to %d", config.MaxChatsGoalMaxTurns)
}
if s.ChatsGoalMaxSteps != nil && (*s.ChatsGoalMaxSteps < 1 || *s.ChatsGoalMaxSteps > config.MaxChatsGoalMaxSteps) {
return fmt.Sprintf("Invalid chats_goal_max_steps: use 1 to %d", config.MaxChatsGoalMaxSteps)
}
if s.ChatsLoopMinInterval != nil {
d, err := time.ParseDuration(strings.TrimSpace(*s.ChatsLoopMinInterval))
if err != nil || d < config.MinChatsLoopInterval || d > config.MaxChatsLoopInterval {
return "Invalid chats_loop_min_interval: use a duration from 30s to 24h, such as 1m"
}
}
for _, c := range []struct {
name string
v *int
hi int
}{
{"chats_loop_max_runs", s.ChatsLoopMaxRuns, config.MaxChatsLoopMaxRuns},
{"chats_loop_max_per_chat", s.ChatsLoopMaxPerChat, config.MaxChatsLoopMaxPerChat},
{"chats_loop_max_per_user", s.ChatsLoopMaxPerUser, config.MaxChatsLoopMaxPerUser},
{"chats_loop_daily_runs", s.ChatsLoopDailyRuns, config.MaxChatsLoopDailyRuns},
} {
if c.v != nil && (*c.v < 1 || *c.v > c.hi) {
return fmt.Sprintf("Invalid %s: use 1 to %d", c.name, c.hi)
}
}
if s.ChatsEgressAllowPrivate != nil && *s.ChatsEgressAllowPrivate && !privateOn {
var confirm struct {
Phrase string `json:"chats_egress_private_confirm"`
}
_ = json.Unmarshal(body, &confirm)
if strings.TrimSpace(confirm.Phrase) != config.EgressPrivatePhrase {
return "Turning chats_egress_allow_private on needs chats_egress_private_confirm: \"" + config.EgressPrivatePhrase + "\""
}
}
return ""
}