diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index 6fbebbbd68..07e22ffd6b 100644
--- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -312,6 +312,15 @@ public class ApplicationProperties {
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
+ /**
+ * Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
+ * to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
+ * self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
+ * false (SaaS does in application-saas.properties) so the engine stays entirely
+ * env-controlled and the processor never overrides its config.
+ */
+ private boolean pushConfigToEngine = true;
+
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template
index a5db905103..125594e659 100644
--- a/app/core/src/main/resources/settings.yml.template
+++ b/app/core/src/main/resources/settings.yml.template
@@ -368,6 +368,7 @@ aiEngine:
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
+ pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
models:
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java
index 30bb088400..de48250150 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java
@@ -22,10 +22,12 @@ import tools.jackson.databind.node.ObjectNode;
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
- * key/baseUrl/model fields mean "keep the engine's own environment credential", so
- * environment-driven deployments (where the engine sets {@code STIRLING_ALLOW_CONFIG_PUSH=false}
- * and rejects the push) stay fully env-controlled. Best-effort and non-blocking: a slow or
- * unreachable engine never delays or fails Stirling startup.
+ * key/baseUrl/model fields mean "keep the engine's own environment credential".
+ *
+ *
Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
+ * pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
+ * env-controlled and the processor never pushes settings-derived config to it. Best-effort and
+ * non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
*/
@Slf4j
@Service
@@ -45,6 +47,12 @@ public class AiEngineConfigSync {
if (!cfg.isEnabled()) {
return;
}
+ if (!cfg.isPushConfigToEngine()) {
+ log.debug(
+ "Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
+ + " (the engine is configured from its own environment)");
+ return;
+ }
// Engine may still be booting; push on a virtual thread with a few retries so we never
// block or crash Stirling startup when the engine is slow or briefly unreachable.
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
@@ -60,9 +68,11 @@ public class AiEngineConfigSync {
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
// startup push covers first-time enablement.
+ AiEngine cfg = applicationProperties.getAiEngine();
if (pendingAiEngine == null
|| pendingAiEngine.isEmpty()
- || !applicationProperties.getAiEngine().isEnabled()) {
+ || !cfg.isPushConfigToEngine()
+ || !cfg.isEnabled()) {
return;
}
boolean engineRelevant =
@@ -70,7 +80,7 @@ public class AiEngineConfigSync {
if (!engineRelevant) {
return;
}
- ObjectNode node = buildConfigNode(applicationProperties.getAiEngine());
+ ObjectNode node = buildConfigNode(cfg);
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
String body = node.toString();
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java
index 92d10bb907..9b214e8724 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -283,6 +286,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
+
+ @Test
+ @DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
+ void dropsMaskedSecretValue() {
+ UpdateSettingsRequest request = new UpdateSettingsRequest();
+ Map settings = new HashMap<>();
+ settings.put("aiEngine.models.apiKey", "********");
+ settings.put("ui.appName", "My App");
+ request.setSettings(settings);
+
+ try (MockedStatic mocked = mockStatic(GeneralUtils.class)) {
+ ResponseEntity