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 d6ccb4080 fix(ai): isolate CLI agent subprocess environments (#2207)
d6ccb4080 is described below
commit d6ccb408026de81c068e56f7996e9adeecab1bb5
Author: xdz997 <[email protected]>
AuthorDate: Wed Aug 19 14:21:16 2026 +0800
fix(ai): isolate CLI agent subprocess environments (#2207)
---
deploy/.env.example | 6 +
deploy/docker-compose.yml | 1 +
.../studio/ops/ai/ClaudeCodeAgentProvider.java | 5 +-
.../rocketmq/studio/ops/ai/CliAgentProvider.java | 20 ++-
.../studio/ops/ai/CliProcessEnvironment.java | 135 +++++++++++++++++++++
.../rocketmq/studio/ops/ai/LlmProperties.java | 9 +-
.../rocketmq/studio/ops/ai/QoderAgentProvider.java | 4 +
server/src/main/resources/application.yml | 1 +
.../studio/ops/ai/ClaudeCodeAgentProviderTest.java | 45 ++++++-
.../studio/ops/ai/CliAgentProviderTest.java | 49 +++++++-
.../studio/ops/ai/CliProcessEnvironmentTest.java | 132 ++++++++++++++++++++
11 files changed, 394 insertions(+), 13 deletions(-)
diff --git a/deploy/.env.example b/deploy/.env.example
index 71d38ad0e..3d7f82d1d 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -26,3 +26,9 @@ STUDIO_METRICS_PROMETHEUS_BASE_URL=
STUDIO_METRICS_PROMETHEUS_USERNAME=
STUDIO_METRICS_PROMETHEUS_PASSWORD=
STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN=
+
+# Optional comma-separated environment variable names that CLI-backed AI
+# providers may inherit in addition to Studio's minimal built-in allowlist.
+# Values are read from the rocketmq-server environment; do not list unrelated
+# server secrets here.
+RMQ_LLM_CLI_ALLOWED_ENVIRONMENT=
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 3c98eca2e..d68278bcf 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -51,6 +51,7 @@ services:
STUDIO_ROCKETMQ_NAMESRV_ADDR:
${STUDIO_ROCKETMQ_NAMESRV_ADDR:-nameserver:9876}
RMQ_LLM_TOKEN: ${RMQ_LLM_TOKEN:-}
RMQ_ANTHROPIC_BASE_URL: ${RMQ_ANTHROPIC_BASE_URL:-}
+ RMQ_LLM_CLI_ALLOWED_ENVIRONMENT: ${RMQ_LLM_CLI_ALLOWED_ENVIRONMENT:-}
expose:
- "8888"
networks:
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
index 8e83df376..033eb6ff3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
@@ -56,7 +56,8 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider
{
private final LlmProperties llmProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
- public ClaudeCodeAgentProvider(LlmProperties llmProperties) {
+ public ClaudeCodeAgentProvider(LlmProperties llmProperties,
CliProcessEnvironment processEnvironment) {
+ super(processEnvironment);
this.llmProperties = llmProperties;
}
@@ -95,7 +96,7 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider
{
command.add("--include-partial-messages");
ProcessBuilder builder = new ProcessBuilder(command);
- builder.environment().putAll(childEnv(config));
+ processEnvironment().apply(builder, childEnv(config));
builder.redirectErrorStream(false);
try {
Process process = builder.start();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index 22411ffeb..f038c0ff4 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -25,6 +25,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
@@ -42,18 +43,28 @@ public abstract class CliAgentProvider implements
AgentProvider {
private static final long TIMEOUT_SECONDS = 180;
private static final int MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
+ private final CliProcessEnvironment processEnvironment;
+
+ protected CliAgentProvider(CliProcessEnvironment processEnvironment) {
+ this.processEnvironment = Objects.requireNonNull(processEnvironment,
"processEnvironment");
+ }
+
protected abstract List<String> buildCommand(LlmConfigVO config, String
prompt, String modelOverride);
protected abstract Map<String, String> childEnv(LlmConfigVO config);
protected abstract String binaryName();
+ protected final CliProcessEnvironment processEnvironment() {
+ return processEnvironment;
+ }
+
@Override
public boolean available() {
try {
- Process process = new ProcessBuilder("sh", "-c", "command -v " +
binaryName())
- .redirectErrorStream(true)
- .start();
+ ProcessBuilder builder = new ProcessBuilder("sh", "-c", "command
-v " + binaryName());
+ processEnvironment.apply(builder, Map.of());
+ Process process = builder.redirectErrorStream(true).start();
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
return finished && process.exitValue() == 0;
} catch (IOException | InterruptedException exception) {
@@ -73,8 +84,7 @@ public abstract class CliAgentProvider implements
AgentProvider {
}
List<String> command = buildCommand(config, prompt, modelOverride);
ProcessBuilder builder = new ProcessBuilder(command);
- Map<String, String> env = builder.environment();
- env.putAll(childEnv(config));
+ processEnvironment.apply(builder, childEnv(config));
// Merge stderr into stdout and drain the stream on a background
thread. Reading stdout
// then stderr sequentially on the caller thread deadlocks once the
child fills a pipe
// buffer (64 KiB), and a timeout that runs only after both reads can
never fire while the
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
new file mode 100644
index 000000000..cce3d5f49
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
@@ -0,0 +1,135 @@
+/*
+ * 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.ops.ai;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Builds the environment for CLI-backed AI providers without exposing every
+ * variable from the Studio server process.
+ */
+@Component
+public class CliProcessEnvironment {
+
+ private static final Pattern ENVIRONMENT_NAME =
Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
+
+ /**
+ * Runtime variables used to find executables and user-scoped CLI state,
+ * create temporary files, select a locale, and reach the provider through
+ * explicitly configured proxies or certificate stores.
+ */
+ private static final List<String> DEFAULT_ALLOWED_NAMES = List.of(
+ "PATH",
+ "HOME",
+ "USERPROFILE",
+ "XDG_CONFIG_HOME",
+ "XDG_CACHE_HOME",
+ "XDG_DATA_HOME",
+ "TMPDIR",
+ "TMP",
+ "TEMP",
+ "LANG",
+ "LANGUAGE",
+ "LC_ALL",
+ "LC_CTYPE",
+ "TERM",
+ "SSL_CERT_FILE",
+ "SSL_CERT_DIR",
+ "NODE_EXTRA_CA_CERTS",
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "no_proxy",
+ "SystemRoot",
+ "ComSpec",
+ "PATHEXT");
+
+ private final Set<String> allowedNames;
+
+ @Autowired
+ public CliProcessEnvironment(LlmProperties properties) {
+ this(properties == null ? List.of() :
properties.getCliAllowedEnvironment());
+ }
+
+ CliProcessEnvironment(Collection<String> additionalAllowedNames) {
+ LinkedHashSet<String> names = new
LinkedHashSet<>(DEFAULT_ALLOWED_NAMES);
+ if (additionalAllowedNames != null) {
+ additionalAllowedNames.stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .filter(CliProcessEnvironment::isValidName)
+ .forEach(names::add);
+ }
+ this.allowedNames = Collections.unmodifiableSet(names);
+ }
+
+ /**
+ * Replaces the builder's inherited environment with the isolated child
+ * environment. Provider-specific values are applied last so the selected
+ * request configuration wins over any allowed parent value.
+ */
+ void apply(ProcessBuilder builder, Map<String, String>
providerEnvironment) {
+ Map<String, String> target = builder.environment();
+ Map<String, String> isolated = build(target, providerEnvironment);
+ target.clear();
+ target.putAll(isolated);
+ }
+
+ Map<String, String> build(Map<String, String> parentEnvironment,
+ Map<String, String> providerEnvironment) {
+ Map<String, String> result = new LinkedHashMap<>();
+ if (parentEnvironment != null) {
+ for (String name : allowedNames) {
+ if (parentEnvironment.containsKey(name)) {
+ String value = parentEnvironment.get(name);
+ if (value != null) {
+ result.put(name, value);
+ }
+ }
+ }
+ }
+ if (providerEnvironment != null) {
+ providerEnvironment.forEach((name, value) -> {
+ if (isValidName(name) && value != null) {
+ result.put(name, value);
+ }
+ });
+ }
+ return result;
+ }
+
+ Set<String> allowedNames() {
+ return allowedNames;
+ }
+
+ private static boolean isValidName(String name) {
+ return name != null && ENVIRONMENT_NAME.matcher(name).matches();
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
index 62dde47e8..734c02219 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
@@ -19,14 +19,17 @@ package org.apache.rocketmq.studio.ops.ai;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import java.util.ArrayList;
+import java.util.List;
+
/**
- * LLM credentials supplied through the environment. The token is a secret and
- * must never be persisted or logged; it is injected as RMQ_LLM_TOKEN into the
- * container and bound here at startup.
+ * LLM runtime settings supplied through the environment. Tokens and inherited
+ * CLI environment values must never be persisted or logged.
*/
@Data
@ConfigurationProperties(prefix = "studio.llm")
public class LlmProperties {
private String token;
private String anthropicBaseUrl;
+ private List<String> cliAllowedEnvironment = new ArrayList<>();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
index a0b954458..0b01dbeb5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
@@ -33,6 +33,10 @@ public class QoderAgentProvider extends CliAgentProvider {
public static final String ENGINE = "qoder";
+ public QoderAgentProvider(CliProcessEnvironment processEnvironment) {
+ super(processEnvironment);
+ }
+
@Override
public String engine() {
return ENGINE;
diff --git a/server/src/main/resources/application.yml
b/server/src/main/resources/application.yml
index a4104aa45..db9260acf 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -71,3 +71,4 @@ studio:
llm:
token: ${RMQ_LLM_TOKEN:}
anthropic-base-url: ${RMQ_ANTHROPIC_BASE_URL:}
+ cli-allowed-environment: ${RMQ_LLM_CLI_ALLOWED_ENVIRONMENT:}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
index 8cedc2ddd..d9767c31c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
@@ -51,15 +51,56 @@ class ClaudeCodeAgentProviderTest {
.isEqualTo(504));
}
+ @Test
+ void streamUsesTheIsolatedEnvironment() {
+ RecordingEnvironment processEnvironment = new RecordingEnvironment();
+ TestClaudeCodeAgentProvider provider = new TestClaudeCodeAgentProvider(
+ List.of("sh", "-c",
+ "printf eyJ0eXBlIjoicmVzdWx0IiwicmVzdWx0IjoiZG9uZSJ9 |
base64 -d"),
+ 5,
+ processEnvironment,
+ Map.of("ANTHROPIC_AUTH_TOKEN", "request-token"));
+
+ provider.stream(LlmConfigVO.builder().build(), "prompt", null, ignored
-> { });
+
+
assertThat(processEnvironment.childEnvironments).singleElement().satisfies(environment
->
+ assertThat(environment)
+ .containsEntry("ANTHROPIC_AUTH_TOKEN", "request-token")
+ .doesNotContainKey("SERVER_SECRET"));
+ }
+
+ private static final class RecordingEnvironment extends
CliProcessEnvironment {
+ private final List<Map<String, String>> childEnvironments = new
ArrayList<>();
+
+ RecordingEnvironment() {
+ super(List.of());
+ }
+
+ @Override
+ void apply(ProcessBuilder builder, Map<String, String>
providerEnvironment) {
+ builder.environment().put("SERVER_SECRET",
"must-not-cross-boundary");
+ super.apply(builder, providerEnvironment);
+ childEnvironments.add(Map.copyOf(builder.environment()));
+ }
+ }
+
private static class TestClaudeCodeAgentProvider extends
ClaudeCodeAgentProvider {
private final List<String> command;
private final long timeoutSeconds;
+ private final Map<String, String> environment;
TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds)
{
- super(null);
+ this(command, timeoutSeconds, new
CliProcessEnvironment(List.of()), Map.of());
+ }
+
+ TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds,
+ CliProcessEnvironment processEnvironment,
+ Map<String, String> environment) {
+ super(null, processEnvironment);
this.command = command;
this.timeoutSeconds = timeoutSeconds;
+ this.environment = environment;
}
@Override
@@ -74,7 +115,7 @@ class ClaudeCodeAgentProviderTest {
@Override
protected Map<String, String> childEnv(LlmConfigVO config) {
- return Map.of();
+ return environment;
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
index 54a2226f7..19fc6aac7 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -12,6 +12,7 @@ package org.apache.rocketmq.studio.ops.ai;
import org.junit.jupiter.api.Test;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -23,14 +24,26 @@ class CliAgentProviderTest {
private static final class FakeCli extends CliAgentProvider {
private final String script;
private final int outputLimitBytes;
+ private final Map<String, String> environment;
FakeCli(String script) {
this(script, Integer.MAX_VALUE);
}
FakeCli(String script, int outputLimitBytes) {
+ this(script, outputLimitBytes, new
CliProcessEnvironment(List.of()), Map.of());
+ }
+
+ FakeCli(String script, CliProcessEnvironment processEnvironment,
Map<String, String> environment) {
+ this(script, Integer.MAX_VALUE, processEnvironment, environment);
+ }
+
+ FakeCli(String script, int outputLimitBytes, CliProcessEnvironment
processEnvironment,
+ Map<String, String> environment) {
+ super(processEnvironment);
this.script = script;
this.outputLimitBytes = outputLimitBytes;
+ this.environment = environment;
}
@Override
@@ -45,7 +58,7 @@ class CliAgentProviderTest {
@Override
protected Map<String, String> childEnv(LlmConfigVO config) {
- return Map.of();
+ return environment;
}
@Override
@@ -59,6 +72,23 @@ class CliAgentProviderTest {
}
}
+ private static final class RecordingEnvironment extends
CliProcessEnvironment {
+ private final List<Map<String, String>> providerEnvironments = new
ArrayList<>();
+ private final List<Map<String, String>> childEnvironments = new
ArrayList<>();
+
+ RecordingEnvironment() {
+ super(List.of());
+ }
+
+ @Override
+ void apply(ProcessBuilder builder, Map<String, String>
providerEnvironment) {
+ builder.environment().put("SERVER_SECRET",
"must-not-cross-boundary");
+ super.apply(builder, providerEnvironment);
+ providerEnvironments.add(Map.copyOf(providerEnvironment));
+ childEnvironments.add(Map.copyOf(builder.environment()));
+ }
+ }
+
@Test
void completeSurvivesLargeStderrOutput() {
// Write well over the 64 KiB pipe buffer to stderr, then print the
completion on stdout.
@@ -94,4 +124,21 @@ class CliAgentProviderTest {
assertThat(cli.complete(null, "prompt", null)).isNotEmpty();
}
+
+ @Test
+ void availabilityAndCompletionUseTheIsolatedEnvironment() {
+ RecordingEnvironment processEnvironment = new RecordingEnvironment();
+ FakeCli cli = new FakeCli(
+ "printf '%s' \"$PROVIDER_TOKEN\"",
+ processEnvironment,
+ Map.of("PROVIDER_TOKEN", "request-token"));
+
+ assertThat(cli.complete(null, "prompt",
null)).isEqualTo("request-token");
+ assertThat(processEnvironment.providerEnvironments)
+ .containsExactly(Map.of(), Map.of("PROVIDER_TOKEN",
"request-token"));
+
assertThat(processEnvironment.childEnvironments).allSatisfy(environment ->
+ assertThat(environment).doesNotContainKey("SERVER_SECRET"));
+ assertThat(processEnvironment.childEnvironments.get(1))
+ .containsEntry("PROVIDER_TOKEN", "request-token");
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
new file mode 100644
index 000000000..abb5bbae7
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.ops.ai;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import
org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CliProcessEnvironmentTest {
+
+ @Test
+ void bindsCommaSeparatedAdditionalNames() {
+ Binder binder = new Binder(new MapConfigurationPropertySource(Map.of(
+ "studio.llm.cli-allowed-environment",
"CUSTOM_CONFIG,CUSTOM_HOME")));
+
+ LlmProperties properties = binder.bind("studio.llm",
Bindable.of(LlmProperties.class))
+ .orElseThrow(() -> new AssertionError("LLM properties were not
bound"));
+
+
assertThat(properties.getCliAllowedEnvironment()).containsExactly("CUSTOM_CONFIG",
"CUSTOM_HOME");
+ }
+
+ @Test
+ void keepsOnlyRequiredRuntimeEnvironment() {
+ CliProcessEnvironment policy = new CliProcessEnvironment(List.of());
+ Map<String, String> parent = new LinkedHashMap<>();
+ parent.put("PATH", "/usr/local/bin:/usr/bin");
+ parent.put("HOME", "/home/studio");
+ parent.put("XDG_CONFIG_HOME", "/home/studio/.config");
+ parent.put("TMPDIR", "/tmp/studio");
+ parent.put("LANG", "en_US.UTF-8");
+ parent.put("HTTPS_PROXY", "http://proxy.example:8080");
+ parent.put("SSL_CERT_FILE", "/etc/ssl/custom.pem");
+ parent.put("SPRING_DATASOURCE_PASSWORD", "database-secret");
+ parent.put("STUDIO_AUTH_ADMIN_PASSWORD", "admin-secret");
+ parent.put("CLOUD_ACCESS_KEY_SECRET", "cloud-secret");
+ parent.put("NODE_OPTIONS", "--require=/tmp/hook.js");
+
+ Map<String, String> child = policy.build(parent, Map.of());
+
+ assertThat(child).containsEntry("PATH", "/usr/local/bin:/usr/bin")
+ .containsEntry("HOME", "/home/studio")
+ .containsEntry("XDG_CONFIG_HOME", "/home/studio/.config")
+ .containsEntry("TMPDIR", "/tmp/studio")
+ .containsEntry("LANG", "en_US.UTF-8")
+ .containsEntry("HTTPS_PROXY", "http://proxy.example:8080")
+ .containsEntry("SSL_CERT_FILE", "/etc/ssl/custom.pem")
+ .doesNotContainKeys("SPRING_DATASOURCE_PASSWORD",
"STUDIO_AUTH_ADMIN_PASSWORD",
+ "CLOUD_ACCESS_KEY_SECRET", "NODE_OPTIONS");
+ }
+
+ @Test
+ void retainsOnlyConfiguredAdditionalNames() {
+ CliProcessEnvironment policy = new CliProcessEnvironment(
+ List.of("CUSTOM_CLI_CONFIG", " CUSTOM_RUNTIME_HOME "));
+ Map<String, String> parent = Map.of(
+ "CUSTOM_CLI_CONFIG", "profile-a",
+ "CUSTOM_RUNTIME_HOME", "/opt/provider",
+ "UNRELATED_SECRET", "secret");
+
+ assertThat(policy.build(parent, Map.of()))
+ .containsEntry("CUSTOM_CLI_CONFIG", "profile-a")
+ .containsEntry("CUSTOM_RUNTIME_HOME", "/opt/provider")
+ .doesNotContainKey("UNRELATED_SECRET");
+ }
+
+ @Test
+ void providerEnvironmentOverridesAllowedParentValue() {
+ CliProcessEnvironment policy = new
CliProcessEnvironment(List.of("ANTHROPIC_AUTH_TOKEN"));
+ Map<String, String> child = policy.build(
+ Map.of("ANTHROPIC_AUTH_TOKEN", "stale-parent-token", "PATH",
"/usr/bin"),
+ Map.of("ANTHROPIC_AUTH_TOKEN", "request-token",
"ANTHROPIC_BASE_URL", "https://llm.example"));
+
+ assertThat(child).containsEntry("PATH", "/usr/bin")
+ .containsEntry("ANTHROPIC_AUTH_TOKEN", "request-token")
+ .containsEntry("ANTHROPIC_BASE_URL", "https://llm.example");
+ }
+
+ @Test
+ void ignoresInvalidConfiguredAndProviderNames() {
+ CliProcessEnvironment policy = new CliProcessEnvironment(
+ List.of("", " ", "INVALID-NAME", "HAS=VALUE",
"VALID_EXTRA"));
+ Map<String, String> provider = new LinkedHashMap<>();
+ provider.put("BAD-NAME", "bad");
+ provider.put("GOOD_NAME", "good");
+ provider.put("NULL_VALUE", null);
+
+ Map<String, String> child = policy.build(
+ Map.of("INVALID-NAME", "bad", "HAS=VALUE", "bad",
"VALID_EXTRA", "kept"), provider);
+
+ assertThat(policy.allowedNames()).contains("VALID_EXTRA")
+ .doesNotContain("INVALID-NAME", "HAS=VALUE");
+ assertThat(child).containsEntry("VALID_EXTRA", "kept")
+ .containsEntry("GOOD_NAME", "good")
+ .doesNotContainKeys("INVALID-NAME", "HAS=VALUE", "BAD-NAME",
"NULL_VALUE");
+ }
+
+ @Test
+ void applyReplacesTheBuilderEnvironment() {
+ CliProcessEnvironment policy = new CliProcessEnvironment(List.of());
+ ProcessBuilder builder = new ProcessBuilder("sh", "-c", "true");
+ builder.environment().clear();
+ builder.environment().put("PATH", "/usr/bin");
+ builder.environment().put("SERVER_SECRET", "must-not-cross-boundary");
+
+ policy.apply(builder, Map.of("PROVIDER_TOKEN", "provider-secret"));
+
+ assertThat(builder.environment()).containsOnly(
+ Map.entry("PATH", "/usr/bin"),
+ Map.entry("PROVIDER_TOKEN", "provider-secret"));
+ }
+}