This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 975a82e55 fix(settings): keep scoped writes and fail closed on prompts
(#6279)
975a82e55 is described below
commit 975a82e557aada209d52fa0bb0774232cb336079
Author: pilichoumao <[email protected]>
AuthorDate: Mon Oct 12 16:16:26 2026 +0800
fix(settings): keep scoped writes and fail closed on prompts (#6279)
Two fixes where a component kept acting on state it should not have
trusted: a settings save that wrote over fields belonging to the other settings
surface, and a CLI risk gate that continued after its own prompt failed to
render.
- #6279 saving either half of the settings clobbered the other. In one
direction the console form posts `llmProvider`, `model` and `baseUrl` from its
mount-time snapshot (`GeneralSettingsTab.tsx:44-59`) and
`SettingsController.java:47-48` passed them straight to `saveGeneralSettings`,
which inherits a stored value only when the incoming field is **blank**
(`SettingsService.java:144-163`), so a stale non-blank value was written back
over a newer AI save; in the other direction `LlmConf [...]
- #6323 `rmqctl/cmd/catalog.go:372-373` discarded the results of the two
`fmt.Fprintf(out, …)` calls that print the risk WARNING and the target prompt
inside `defaultConfirm`, then went on to start the stdin reader goroutine — so
when stderr could not be written, an L3 change such as `group reset-offset`
still issued its preview and apply requests and exited 0 on a `yes` already
sitting in the TTY buffer, without the operator ever being shown what was about
to happen → both writes are [...]
Tests: `SettingsWriteScopeTest` (three cases) constructs the real
`SettingsService` and `LlmConfigService` over a mocked repository and asserts
an actual `Thread.State.BLOCKED` for the in-flight general write,
`SettingsWritePersistenceTest` (three cases) proves on H2 that the flag is
never stored and that a legacy full update still replaces the AI fields,
`SettingsControllerTest` pins the routing with `saveGeneralSettings(any(),
eq(true))`, `generalSettings.test.ts` pins the payload a [...]
Folded: #6323 was closed as folded into this commit (same author).
---
docs/ai-agent-architecture.md | 3 +
docs/api-spec.md | 3 +
rmqctl/cmd/catalog.go | 8 +-
rmqctl/cmd/catalog_confirm_output_test.go | 128 +++++++++++++++++++
.../rocketmq/studio/ops/ai/LlmConfigService.java | 71 +++++------
.../studio/settings/GeneralSettingsUpdateDTO.java | 2 +
.../studio/settings/SettingsController.java | 6 +-
.../rocketmq/studio/settings/SettingsService.java | 21 ++++
.../studio/ops/ai/LlmConfigServiceTest.java | 6 +
.../studio/settings/SettingsControllerTest.java | 12 ++
.../settings/SettingsWritePersistenceTest.java | 87 +++++++++++++
.../studio/settings/SettingsWriteScopeTest.java | 136 +++++++++++++++++++++
web/src/api/generalSettings.test.ts | 9 ++
web/src/api/settings.ts | 1 +
web/src/pages/settings/GeneralSettingsTab.tsx | 1 +
.../settings/__tests__/GeneralSettingsTab.test.tsx | 1 +
16 files changed, 453 insertions(+), 42 deletions(-)
diff --git a/docs/ai-agent-architecture.md b/docs/ai-agent-architecture.md
index 25a54861e..c8b8766ed 100644
--- a/docs/ai-agent-architecture.md
+++ b/docs/ai-agent-architecture.md
@@ -182,6 +182,9 @@ drain 写 `SHUTDOWN` 而不是把一次重新部署误标成用户取消。停
`NODE_EXTRA_CA_CERTS`/代理变量都已在白名单里。不加 `RMQCTL_CONFIG`——我们显式传 `--config`。
-
内置工具(`Bash`/`Read`/`Write`/`Edit`/`Glob`/`Grep`/`Task`/`WebFetch`/`WebSearch`/`TodoWrite`/
`NotebookEdit`)**继续全部禁用**。放行的只有 MCP 工具。Agent 不该在服务端容器里跑 shell 或读文件系统。
+- 独立 CLI 的 L2/L3 交互确认必须成功写出警告和目标提示,才能读取确认答案;
+ stderr 写入失败会在发送任何预览或执行请求之前以非零状态退出。显式 `--yes`
+ 和 `--dry-run` 保持原有的免提示行为。
- L2/L3 变更走工具协议自身的两步:dry-run 返回 `confirm_token`,apply 消费它。服务端由
`ToolMutationFilter` + `ToolTokenService` + `studio.ai.allow-l3-tools`(默认
false)强制。
**因此托管 Agent 默认无法执行 L3 工具**,除非部署方显式打开开关;系统提示词已交代它必须先预览、
diff --git a/docs/api-spec.md b/docs/api-spec.md
index b7a8842e8..bbbd2261b 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2275,6 +2275,9 @@ POST /api/settings/general/save
| `clearApiKey` | `boolean` | 否 | 传 `true` 时显式清除现有密钥,优先级高于 `apiKey` |
| `model` | `string` | 是 | 模型名称 |
| `baseUrl` | `string` | 是 | Base URL |
+| `preserveLlmConfig` | `boolean` | 否 | 默认 `false`;通用设置表单传 `true`,保留服务端当前 AI
配置 |
+
+`preserveLlmConfig=true` 时,请求中的 AI 字段(包括 API Key
及其清空标记)不会覆盖当前值;原有必填字段校验不变。省略该标记或传 `false` 时维持原完整更新契约。控制台通用表单使用该标记,AI 表单只修改 AI
配置,两条路径的读、合并和保存在单个服务进程内串行执行。这不提供跨进程的版本冲突检测;旧调用方的完整更新仍可能覆盖其他写入。标记不存入设置
JSON,也不出现在读取响应中。
**Response `data`:** `null`
diff --git a/rmqctl/cmd/catalog.go b/rmqctl/cmd/catalog.go
index 7f1dddb11..735d2dce8 100644
--- a/rmqctl/cmd/catalog.go
+++ b/rmqctl/cmd/catalog.go
@@ -369,8 +369,12 @@ func defaultConfirm(ctx context.Context, in io.Reader, out
io.Writer, commandPat
if (stat.Mode() & os.ModeCharDevice) == 0 {
return riskConfirmationRequired(commandPath, riskLevel)
}
- fmt.Fprintf(out, "WARNING: %q is a %s operation.\n", commandPath,
riskLevel)
- fmt.Fprintf(out, "Arguments will be sent to %s. Type \"yes\" to
continue: ", server)
+ if _, err := fmt.Fprintf(out, "WARNING: %q is a %s operation.\n",
commandPath, riskLevel); err != nil {
+ return fmt.Errorf("write confirmation warning: %w", err)
+ }
+ if _, err := fmt.Fprintf(out, "Arguments will be sent to %s. Type
\"yes\" to continue: ", server); err != nil {
+ return fmt.Errorf("write confirmation prompt: %w", err)
+ }
// The stdin read itself cannot be interrupted, so run it alongside the
context: a canceled
// context always wins over an answer that may already have been typed,
and the buffered
diff --git a/rmqctl/cmd/catalog_confirm_output_test.go
b/rmqctl/cmd/catalog_confirm_output_test.go
new file mode 100644
index 000000000..07763db3d
--- /dev/null
+++ b/rmqctl/cmd/catalog_confirm_output_test.go
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cmd
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+var errConfirmationOutput = errors.New("synthetic confirmation output failure")
+
+type confirmationFailWriter struct{ failAt, writes int }
+
+func (w *confirmationFailWriter) Write(p []byte) (int, error) {
+ w.writes++
+ if w.writes == w.failAt {
+ return 0, errConfirmationOutput
+ }
+ return len(p), nil
+}
+
+type confirmationCountingReader struct {
+ io.Reader
+ reads atomic.Int32
+}
+
+func (r *confirmationCountingReader) Read(p []byte) (int, error) {
+ r.reads.Add(1)
+ return r.Reader.Read(p)
+}
+
+func TestDefaultConfirmOutputFailureDoesNotReadAnswer(t *testing.T) {
+ for _, failAt := range []int{1, 2} {
+ reader := &confirmationCountingReader{Reader:
strings.NewReader("yes\n")}
+ err := defaultConfirm(context.Background(), charDeviceReader{r:
reader}, &confirmationFailWriter{failAt: failAt}, "group reset-offset", "L3",
"http://studio")
+ if !errors.Is(err, errConfirmationOutput) {
+ t.Errorf("write %d: lost error: %v", failAt, err)
+ }
+ if reader.reads.Load() != 0 {
+ t.Errorf("write %d: consumed answer after output
failure", failAt)
+ }
+ }
+}
+
+func TestConfirmationOutputFailureStopsToolExecution(t *testing.T) {
+ cases := []struct {
+ name, answer string
+ failAt int
+ flags []string
+ wantCode, wantCalls int
+ wantRead bool
+ }{
+ {name: "first prompt fails", answer: "yes\n", failAt: 1,
wantCode: 1},
+ {name: "second prompt fails", answer: "yes\n", failAt: 2,
wantCode: 1},
+ {name: "visible prompt accepted", answer: "yes\n", wantCalls:
2, wantRead: true},
+ {name: "visible prompt rejected", answer: "no\n", wantCode: 1,
wantRead: true},
+ {name: "yes bypasses prompt", failAt: 1, flags:
[]string{"--yes"}, wantCalls: 2},
+ {name: "dry run bypasses prompt", failAt: 1, flags:
[]string{"--dry-run"}, wantCalls: 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ var request struct {
+ Name string `json:"name"`
+ Arguments map[string]any
`json:"arguments"`
+ }
+ if err :=
json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Error(err)
+ return
+ }
+ if request.Name != "rmq.group.reset_offset" {
+ t.Errorf("unexpected tool %s",
request.Name)
+ }
+ status := "EXECUTED"
+ if request.Arguments["dry_run"] == true {
+ status = "PLANNED"
+ } else if request.Arguments["confirm_token"] !=
"synthetic-token" {
+ t.Error("apply without preview token")
+ }
+ writeStudioSuccess(t, w,
map[string]any{"status": status, "instanceId": "test-instance",
"confirm_token": "synthetic-token", "plan": map[string]any{}, "result":
map[string]any{}})
+ }))
+ defer server.Close()
+ reader := &confirmationCountingReader{Reader:
strings.NewReader(tc.answer)}
+ writer := &confirmationFailWriter{failAt: tc.failAt}
+ app := NewApp(&bytes.Buffer{}, writer)
+ app.In = charDeviceReader{r: reader}
+ app.HTTP = server.Client()
+ app.Store.Getenv = testEnv
+ path := newTestConfigPath(t)
+ if err := app.Store.Save(path,
newTestConfig(server.URL)); err != nil {
+ t.Fatal(err)
+ }
+ args := []string{"--config", path, "--instance-id",
"test-instance", "group", "reset-offset", "--group-name", "orders",
"--topic-name", "orders", "--timestamp", "1700000000000"}
+ code := app.Execute(append(args, tc.flags...))
+ t.Logf("exit=%d HTTP requests=%d answer reads=%d",
code, calls.Load(), reader.reads.Load())
+ if code != tc.wantCode || int(calls.Load()) !=
tc.wantCalls {
+ t.Errorf("exit/calls=%d/%d, want %d/%d", code,
calls.Load(), tc.wantCode, tc.wantCalls)
+ }
+ if (reader.reads.Load() > 0) != tc.wantRead {
+ t.Errorf("answer read=%v, want %v",
reader.reads.Load() > 0, tc.wantRead)
+ }
+ })
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
index 138087787..feabb0dcd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -107,44 +107,33 @@ public class LlmConfigService {
}
public synchronized void saveConfig(LlmConfigVO config) {
- LlmConfigVO normalized = normalizeWithStoredApiKey(config);
- LlmOperationResultVO validation = validate(normalized);
- if (validation.getStatus() != 0) {
- throw new LlmGatewayException(400, validation.getCode(),
validation.getErrMsg(), validation.getHint());
- }
- GeneralSettingsVO current = settingsService.getGeneralSettings();
- // The env-injected token is authoritative at runtime but must never
be persisted.
- String persistedApiKey = !StringUtils.hasText(envToken())
- ? normalized.getApiKey()
- : defaultString(current.getApiKey(), "");
- GeneralSettingsVO updated = GeneralSettingsVO.builder()
- .theme(current.getTheme())
- .compact(current.isCompact())
- .desktopNotify(current.isDesktopNotify())
- .notifySound(current.isNotifySound())
- .sessionTimeout(current.getSessionTimeout())
- .requireLogin(current.isRequireLogin())
- // The LLM form never edits notification channels; keep the
stored values
- // instead of persisting nulls over them.
- .dingtalkWebhook(current.getDingtalkWebhook())
- .smsWebhook(current.getSmsWebhook())
- .emailRecipients(current.getEmailRecipients())
- .llmProvider(normalized.getProvider())
- .llmEngine(normalized.getEngine())
- .apiKey(persistedApiKey)
- // Carry the clear intent into the persisted settings: without
it a blank key plus
- // clearApiKey=false means "keep the stored key" to
SettingsService's merge, so the
- // secret the operator just removed would be written straight
back.
- .clearApiKey(normalized.isClearApiKey())
- .model(normalized.getModel())
- .baseUrl(normalized.getApiBase())
- .deploymentName(normalized.getDeploymentName())
- .apiVersion(normalized.getApiVersion())
- .awsRegion(normalized.getAwsRegion())
- .maxTokens(normalized.getMaxTokens())
- .temperature(normalized.getTemperature())
- .build();
- settingsService.saveGeneralSettings(updated);
+ settingsService.updateGeneralSettings(current -> {
+ LlmConfigVO normalized = normalizeWithStoredApiKey(config,
current);
+ LlmOperationResultVO validation = validate(normalized);
+ if (validation.getStatus() != 0) {
+ throw new LlmGatewayException(400, validation.getCode(),
validation.getErrMsg(), validation.getHint());
+ }
+ // The env-injected token is authoritative at runtime but must
never be persisted.
+ String persistedApiKey = !StringUtils.hasText(envToken())
+ ? normalized.getApiKey()
+ : defaultString(current.getApiKey(), "");
+ return current.toBuilder()
+ .llmProvider(normalized.getProvider())
+ .llmEngine(normalized.getEngine())
+ .apiKey(persistedApiKey)
+ // Carry the clear intent into the persisted settings:
without it a blank key plus
+ // clearApiKey=false means "keep the stored key" to
SettingsService's merge, so the
+ // secret the operator just removed would be written
straight back.
+ .clearApiKey(normalized.isClearApiKey())
+ .model(normalized.getModel())
+ .baseUrl(normalized.getApiBase())
+ .deploymentName(normalized.getDeploymentName())
+ .apiVersion(normalized.getApiVersion())
+ .awsRegion(normalized.getAwsRegion())
+ .maxTokens(normalized.getMaxTokens())
+ .temperature(normalized.getTemperature())
+ .build();
+ });
}
public LlmOperationResultVO testConfig(LlmConfigVO config) {
@@ -307,6 +296,10 @@ public class LlmConfigService {
}
private LlmConfigVO normalizeWithStoredApiKey(LlmConfigVO config) {
+ return normalizeWithStoredApiKey(config, null);
+ }
+
+ private LlmConfigVO normalizeWithStoredApiKey(LlmConfigVO config,
GeneralSettingsVO current) {
LlmConfigVO normalized = normalize(config);
if (normalized.isClearApiKey()) {
normalized.setApiKey("");
@@ -321,7 +314,7 @@ public class LlmConfigService {
// otherwise fall back to the key stored in settings.
String effectiveKey = envToken();
if (!StringUtils.hasText(effectiveKey)) {
- effectiveKey =
settingsService.getGeneralSettings().getApiKey();
+ effectiveKey = current == null ?
settingsService.getGeneralSettings().getApiKey() : current.getApiKey();
}
normalized.setApiKey(defaultString(effectiveKey, ""));
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
index a467fccb9..65bba18b2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
@@ -58,6 +58,8 @@ public class GeneralSettingsUpdateDTO {
private String deploymentName;
private String apiVersion;
private String awsRegion;
+ /** Preserve all AI fields when saving the console's non-AI settings form.
*/
+ private boolean preserveLlmConfig;
private String dingtalkWebhook;
@ToString.Exclude
private String dingtalkSigningSecret;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
index f8f01c9b3..0034a40a3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
@@ -45,7 +45,11 @@ public class SettingsController {
@PostMapping("/general/save")
public Result<Void> saveGeneralSettings(@Valid @RequestBody
GeneralSettingsUpdateDTO request) {
- settingsService.saveGeneralSettings(request.toSettings());
+ if (request.isPreserveLlmConfig()) {
+ settingsService.saveGeneralSettings(request.toSettings(), true);
+ } else {
+ settingsService.saveGeneralSettings(request.toSettings());
+ }
return Result.ok();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index 1d616969e..2cbe056cd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -51,6 +51,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Set;
+import java.util.function.UnaryOperator;
@Slf4j
@Service
@@ -127,6 +128,26 @@ public class SettingsService {
}
+ /** Keep read, construction, secret inheritance and persistence under the
same write monitor. */
+ public synchronized void
updateGeneralSettings(UnaryOperator<GeneralSettingsVO> update) {
+
saveGeneralSettings(update.apply(settingsRepository.loadGeneralSettings()));
+ }
+
+ /** Explicitly scoped console writes leave AI fields owned by the AI
endpoint unchanged. */
+ public synchronized void saveGeneralSettings(GeneralSettingsVO settings,
boolean preserveLlmConfig) {
+ if (preserveLlmConfig) {
+ GeneralSettingsVO current =
settingsRepository.loadGeneralSettings();
+ settings = settings.toBuilder()
+
.llmProvider(current.getLlmProvider()).llmEngine(current.getLlmEngine())
+ .apiKey(current.getApiKey()).clearApiKey(false)
+ .model(current.getModel()).baseUrl(current.getBaseUrl())
+
.deploymentName(current.getDeploymentName()).apiVersion(current.getApiVersion())
+
.awsRegion(current.getAwsRegion()).maxTokens(current.getMaxTokens())
+ .temperature(current.getTemperature()).build();
+ }
+ saveGeneralSettings(settings);
+ }
+
public synchronized void saveGeneralSettings(GeneralSettingsVO settings) {
log.info("Saving general settings");
validateLlmBaseUrl(settings.getBaseUrl());
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
index 4c5f7e5ef..3b5eb3a9c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -30,6 +30,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -64,6 +65,11 @@ class LlmConfigServiceTest {
.model("gpt-4o")
.baseUrl("https://api.openai.com/v1")
.build());
+ doAnswer(invocation -> {
+ UnaryOperator<GeneralSettingsVO> update =
invocation.getArgument(0);
+
settingsService.saveGeneralSettings(update.apply(settingsService.getGeneralSettings()));
+ return null;
+ }).when(settingsService).updateGeneralSettings(any());
llmClient = mock(OpenAiCompatibleLlmClient.class);
agentProviders = mock(AgentProviderRegistry.class);
llmConfigService = new LlmConfigService(
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
index 74258f82b..60bfffe4b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
@@ -41,6 +41,7 @@ import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
@@ -67,6 +68,17 @@ class SettingsControllerTest extends WebMvcAuthTestSupport {
@MockitoBean
private NotificationOutboxService notificationOutboxService;
+ @Test
+ void scopedGeneralSaveShouldPassPreservationIntentTest() throws Exception {
+
mockMvc.perform(post("/api/settings/general/save").contentType(MediaType.APPLICATION_JSON).content("""
+
{"theme":"dark","compact":false,"desktopNotify":false,"notifySound":false,
+
"sessionTimeout":60,"requireLogin":false,"llmProvider":"openai","model":"old-model",
+
"baseUrl":"https://api.openai.com/v1","preserveLlmConfig":true}
+ """))
+ .andExpect(status().isOk());
+
verify(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class),
eq(true));
+ }
+
@Test
void getGeneralSettingsShouldReturnSettingsTest() throws Exception {
GeneralSettingsVO settings = GeneralSettingsVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWritePersistenceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWritePersistenceTest.java
new file mode 100644
index 000000000..621e2ef16
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWritePersistenceTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.settings;
+
+import org.apache.rocketmq.studio.ops.ai.LlmConfigService;
+import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
+import org.apache.rocketmq.studio.persistence.mapper.RmqSettingsMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = {
+
"spring.datasource.url=jdbc:h2:mem:settings-write-scope;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
+ "studio.llm.token="
+})
+@ActiveProfiles("dev")
+class SettingsWritePersistenceTest {
+ @Autowired
+ private SettingsService settings;
+ @Autowired
+ private LlmConfigService llm;
+ @Autowired
+ private SettingsRepository repository;
+ @Autowired
+ private RmqSettingsMapper mapper;
+
+ @BeforeEach
+ void initializeTest() {
+
settings.saveGeneralSettings(GeneralSettingsVO.builder().theme("light").sessionTimeout(1440)
+
.llmProvider("openai").model("old-model").apiKey("synthetic-key")
+
.baseUrl("https://api.openai.com/v1").emailRecipients("[email protected]").build());
+ }
+
+ @Test
+ void scopedSaveShouldPersistBothFormsWithoutPersistingTheIntentFlagTest() {
+ GeneralSettingsVO pending =
repository.loadGeneralSettings().toBuilder()
+ .theme("dark").emailRecipients("[email protected]").build();
+
llm.saveConfig(LlmConfigVO.builder().provider("openai").model("new-model")
+
.apiBase("https://api.openai.com/v1").maxTokens(8192).temperature(0.3).build());
+ settings.saveGeneralSettings(pending, true);
+ GeneralSettingsVO result = repository.loadGeneralSettings();
+ assertThat(result.getTheme()).isEqualTo("dark");
+ assertThat(result.getEmailRecipients()).isEqualTo("[email protected]");
+ assertThat(result.getModel()).isEqualTo("new-model");
+ assertThat(result.getApiKey()).isEqualTo("synthetic-key");
+ assertThat(result.getMaxTokens()).isEqualTo(8192);
+ assertThat(result.getTemperature()).isEqualTo(0.3);
+ assertThat(mapper.selectList(null)).hasSize(1);
+
assertThat(mapper.selectList(null).getFirst().getJson()).doesNotContain("preserveLlmConfig");
+ }
+
+ @Test
+ void legacyFullSaveShouldStillUpdateAiFieldsTest() {
+
settings.saveGeneralSettings(repository.loadGeneralSettings().toBuilder().model("legacy-model")
+ .apiKey("legacy-key").build());
+ GeneralSettingsVO result = repository.loadGeneralSettings();
+ assertThat(result.getModel()).isEqualTo("legacy-model");
+ assertThat(result.getApiKey()).isEqualTo("legacy-key");
+ }
+
+ @Test
+ void aiKeyClearShouldRetainNotificationFieldsTest() {
+
llm.saveConfig(LlmConfigVO.builder().provider("ollama").model("local-model")
+
.apiBase("http://localhost:11434/v1").clearApiKey(true).build());
+ GeneralSettingsVO result = repository.loadGeneralSettings();
+ assertThat(result.getApiKey()).isEmpty();
+ assertThat(result.getEmailRecipients()).isEqualTo("[email protected]");
+ assertThat(result.getTheme()).isEqualTo("light");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWriteScopeTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWriteScopeTest.java
new file mode 100644
index 000000000..0a36b6ec7
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsWriteScopeTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.settings;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.ops.ai.LlmConfigService;
+import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
+import org.apache.rocketmq.studio.ops.ai.LlmProperties;
+import org.apache.rocketmq.studio.ops.ai.OpenAiCompatibleLlmClient;
+import org.apache.rocketmq.studio.ops.ai.AgentProviderRegistry;
+import org.apache.rocketmq.studio.ops.alert.NotificationOutboxService;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.client.RestClient;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.ArgumentMatchers.any;
+
+class SettingsWriteScopeTest {
+ private final AtomicReference<GeneralSettingsVO> stored = new
AtomicReference<>(GeneralSettingsVO.builder()
+
.theme("light").sessionTimeout(1440).llmProvider("openai").apiKey("synthetic-key")
+ .model("old-model").baseUrl("https://api.openai.com/v1").build());
+ private final SettingsRepository repository =
mock(SettingsRepository.class);
+
+ private SettingsService settings() {
+ when(repository.loadGeneralSettings()).thenAnswer(inv ->
stored.get().toBuilder().build());
+ doAnswer(inv -> {
+ stored.set(((GeneralSettingsVO)
inv.getArgument(0)).toBuilder().build());
+ return null;
+ })
+ .when(repository).saveGeneralSettings(any());
+ return new SettingsService(repository, RestClient.builder(), new
ObjectMapper(), mock(OperationAuditService.class));
+ }
+
+ private LlmConfigService llm(SettingsService settings) {
+ return new LlmConfigService(settings,
mock(OpenAiCompatibleLlmClient.class),
+ mock(AgentProviderRegistry.class), new LlmProperties());
+ }
+
+ private LlmConfigVO config() {
+ return
LlmConfigVO.builder().provider("openai").apiKey("synthetic-key").model("new-model")
+
.apiBase("https://api.openai.com/v1").maxTokens(4096).temperature(0.7).build();
+ }
+
+ @Test
+ void scopedWriteShouldIgnoreStaleAiFieldsAndSecretClearIntentTest() {
+ SettingsService service = settings();
+ GeneralSettingsVO current =
stored.get().toBuilder().llmEngine("http").apiVersion("version")
+
.deploymentName("deployment").awsRegion("region").maxTokens(8192).temperature(0.3).build();
+ stored.set(current);
+
service.saveGeneralSettings(current.toBuilder().theme("dark").apiKey("stale-key").clearApiKey(true)
+
.llmProvider("stale-provider").llmEngine("stale-engine").model("stale-model")
+
.baseUrl("stale-invalid-url").deploymentName("stale-deployment").apiVersion("stale-version")
+
.awsRegion("stale-region").maxTokens(1).temperature(1.9).build(), true);
+
assertThat(stored.get()).usingRecursiveComparison().ignoringFields("theme").isEqualTo(current);
+ assertThat(stored.get().getTheme()).isEqualTo("dark");
+ }
+
+ @Test
+ void scopedGeneralRequestShouldPreserveAnInterveningAiSaveTest() throws
Exception {
+ SettingsService service = settings();
+ ObjectMapper mapper = new
ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
+ GeneralSettingsUpdateDTO request = mapper.readValue("""
+
{"theme":"dark","compact":false,"desktopNotify":false,"notifySound":false,
+
"sessionTimeout":1440,"requireLogin":false,"llmProvider":"openai",
+
"model":"old-model","baseUrl":"https://api.openai.com/v1","preserveLlmConfig":true}
+ """, GeneralSettingsUpdateDTO.class);
+ llm(service).saveConfig(config());
+ new SettingsController(service,
mock(NotificationOutboxService.class)).saveGeneralSettings(request);
+ assertThat(stored.get().getTheme()).isEqualTo("dark");
+ assertThat(stored.get().getModel()).isEqualTo("new-model");
+ }
+
+ @Test
+ void aiSaveShouldReadAfterAnInFlightGeneralWriteTest() throws Exception {
+ SettingsService service = settings();
+ CountDownLatch persisting = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ doAnswer(inv -> {
+ if (Thread.currentThread().getName().equals("general-writer")) {
+ persisting.countDown();
+ assertThat(release.await(5, TimeUnit.SECONDS)).isTrue();
+ }
+ stored.set(((GeneralSettingsVO)
inv.getArgument(0)).toBuilder().build());
+ return null;
+ }).when(repository).saveGeneralSettings(any());
+ AtomicReference<Thread> aiThread = new AtomicReference<>();
+ try (ExecutorService general = Executors.newSingleThreadExecutor(r ->
new Thread(r, "general-writer"));
+ ExecutorService ai = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, "ai-writer");
+ aiThread.set(thread);
+ return thread;
+ })) {
+ Future<?> generalWrite = general.submit(() ->
service.saveGeneralSettings(stored.get().toBuilder()
+
.theme("dark").emailRecipients("[email protected]").build()));
+ try {
+ assertThat(persisting.await(5, TimeUnit.SECONDS)).isTrue();
+ Future<?> aiWrite = ai.submit(() ->
llm(service).saveConfig(config()));
+ long deadline = System.nanoTime() +
TimeUnit.SECONDS.toNanos(5);
+ while (aiThread.get().getState() != Thread.State.BLOCKED &&
System.nanoTime() < deadline) {
+ Thread.yield();
+ }
+
assertThat(aiThread.get().getState()).isEqualTo(Thread.State.BLOCKED);
+ release.countDown();
+ generalWrite.get(5, TimeUnit.SECONDS);
+ aiWrite.get(5, TimeUnit.SECONDS);
+ assertThat(stored.get().getTheme()).isEqualTo("dark");
+
assertThat(stored.get().getEmailRecipients()).isEqualTo("[email protected]");
+ assertThat(stored.get().getModel()).isEqualTo("new-model");
+ } finally { release.countDown(); }
+ }
+ }
+}
diff --git a/web/src/api/generalSettings.test.ts
b/web/src/api/generalSettings.test.ts
index e59711e2a..1ff96ae54 100644
--- a/web/src/api/generalSettings.test.ts
+++ b/web/src/api/generalSettings.test.ts
@@ -95,6 +95,15 @@ describe('general settings API', () => {
await expect(saveGeneralSettings(update)).resolves.toBeUndefined();
});
+ it('forwards the explicit non-AI scope without changing legacy requests',
async () => {
+ const update: GeneralSettingsUpdate = { ...editableSettings,
preserveLlmConfig: true };
+ mock.onPost('/settings/general/save').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual(update);
+ return [200, { code: 200, data: null }];
+ });
+ await expect(saveGeneralSettings(update)).resolves.toBeUndefined();
+ });
+
it('sends the explicit API key clear flag', async () => {
const update: GeneralSettingsUpdate = { ...editableSettings, clearApiKey:
true };
mock.onPost('/settings/general/save').reply((config) => {
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index 45344ecd3..63561c4fe 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -48,6 +48,7 @@ export type GeneralSettingsUpdate = Omit<
> & {
apiKey?: string;
clearApiKey?: boolean;
+ preserveLlmConfig?: boolean;
clearDingtalkSigningSecret?: boolean;
};
diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx
b/web/src/pages/settings/GeneralSettingsTab.tsx
index 7680bcd4e..e63814579 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -43,6 +43,7 @@ const toThemeMode = (value?: string): ThemeMode =>
value === 'light' || value === 'dark' ? value : 'system';
const buildPayload = (settings: GeneralSettings): GeneralSettingsUpdate => ({
+ preserveLlmConfig: true,
theme: settings.theme,
compact: settings.compact,
desktopNotify: settings.desktopNotify,
diff --git a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
index 7eb329042..1ebb42c44 100644
--- a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
+++ b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
@@ -117,6 +117,7 @@ describe('GeneralSettingsTab', () => {
await waitFor(() =>
expect(saveGeneralSettings).toHaveBeenCalledWith(
expect.objectContaining({
+ preserveLlmConfig: true,
theme: 'system',
compact: false,
desktopNotify: false,