gnodet-bot commented on code in PR #27971:
URL: https://github.com/apache/camel/pull/27971#discussion_r4298591585
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java:
##########
@@ -193,6 +194,9 @@ String call(Harden harden, String endpoint, String
sysPrompt, String userPrompt,
defaultValue = "true", arity = "0..1", fallbackValue = "true")
boolean stream = true;
+ // whether the answer was printed as it came
+ private boolean streamed;
Review Comment:
⚠️ **`streamed` is never reset between file iterations**
`doCall()` iterates over multiple files in a `for` loop, but `streamed` is
an instance field that starts `false` and is set to `true` inside
`sendStreamingRequest()` — it is never cleared back to `false`. If the first
file triggers the Ollama streaming path (setting `streamed = true`), every
subsequent file's result will be silently dropped by the `if (!streamed)` guard
in `handleHardeningResult()` — even when those files go through the
non-streaming OpenAI-compatible path.
Fix: reset the flag at the start of each iteration, or make it a local
variable threaded through the call chain rather than instance state.
```suggestion
// whether the answer for the current file was printed as it came (reset
per file)
private boolean streamed;
```
Or (cleaner): reset it at the top of the per-file block in `doCall()`.
##########
dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.camel.dsl.jbang.core.commands;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * camel harden: security suggestions for a route by an LLM (a local server
plays Ollama, or an OpenAI-compatible
+ * service). Only the suggestions are on stdout.
+ */
+class HardenTest extends CamelCommandBaseTestSupport {
+
+ private static final String ROUTE = """
+ - route:
+ id: upload
+ from:
+ uri: platform-http:/upload
+ steps:
+ - to: ftp:files.example.com/in?username=bob&password=secret
+ """;
+
+ private static final String SUGGESTIONS = "Use SFTP instead of FTP, and
keep the password in a vault.";
+
+ @TempDir
+ Path dir;
+
+ private Path route;
+ private FakeLlmServer llm;
+
+ @BeforeEach
+ void start() throws Exception {
+ route = Files.writeString(dir.resolve("upload.camel.yaml"), ROUTE);
+ llm = new FakeLlmServer(SUGGESTIONS);
+ }
+
+ @AfterEach
+ void stop() {
+ llm.close();
+ }
+
+ @Test
+ void suggestionsFromOllama() throws Exception {
+ assertThat(command(route.toString()).doCall()).isZero();
+
+ assertThat(printer.getStdout()).contains("Security Hardening Analysis:
" + route, SUGGESTIONS)
+ .doesNotContain("LLM Configuration");
+ assertThat(printer.getStderr()).contains("LLM Configuration:",
"Performing security analysis with test (Ollama)");
+
assertThat(llm.lastRequest().getString("prompt")).contains("ftp:files.example.com");
+ }
+
+ @Test
+ void suggestionsFromAnOpenAiCompatibleService() throws Exception {
+ assertThat(command(route.toString(), "--api-type=openai",
"--api-key=sk-1234567890abcd").doCall()).isZero();
+
+ assertThat(printer.getStdout()).contains(SUGGESTIONS);
+ assertThat(printer.getStderr()).contains("(OpenAI-compatible)", " API
Key: ****abcd");
+ JsonObject request = llm.lastRequest();
+ assertThat(request.getString("model")).isEqualTo("test");
+ assertThat(request.toJson()).contains("ftp:files.example.com");
+ }
+
+ @Test
+ void thePromptsAreOnStderr() throws Exception {
+ assertThat(command(route.toString(), "--show-prompt",
"--stream=false").doCall()).isZero();
+
+ assertThat(printer.getStderr()).contains("--- SYSTEM PROMPT ---", "---
USER PROMPT ---");
+
assertThat(printer.getStdout()).contains(SUGGESTIONS).doesNotContain("---
SYSTEM PROMPT ---");
+ }
+
+ @Test
+ void anUnreachableUrlIsNotReplacedByAnotherLlm() throws Exception {
+ Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command, route.toString(),
"--url=http://127.0.0.1:1", "--model=test");
+
+ // the route is not sent to the Ollama of the host
+ assertThat(command.doCall()).isEqualTo(CliExitCode.FAILURE);
+ assertThat(printer.getStderr()).contains("Cannot connect to LLM
service at: http://127.0.0.1:1");
+ }
+
+ @Test
+ void unknownFormatIsAUsageError() {
+ Harden command = command(route.toString(), "--format=json");
+
+ assertThatThrownBy(command::doCall)
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.USAGE))
+ .hasMessage("--format must be text or markdown, was: json");
+ }
+
+ private Harden command(String... args) {
+ Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command,
+ Stream.concat(Arrays.stream(args), Stream.of("--url=" +
llm.url(), "--model=test"))
+ .toArray(String[]::new));
+ return command;
+ }
Review Comment:
💡 **Consider adding a multi-file test to cover the `streamed` reset bug**
The private `command()` helper always wires a single route file. There is no
test that passes two or more files to `Harden`, so the state-accumulation bug
described above (a stale `streamed = true` silently suppressing the second
file's output) would not be caught by this suite.
A minimal regression test:
```java
@Test
void suggestionsForMultipleFiles() throws Exception {
Path route2 = Files.writeString(dir.resolve("other.camel.yaml"), ROUTE);
assertThat(command(route.toString(),
route2.toString()).doCall()).isZero();
// both files' suggestions must appear in stdout
assertThat(printer.getStdout())
.contains("Security Hardening Analysis: " + route)
.contains("Security Hardening Analysis: " + route2)
.contains(SUGGESTIONS);
}
```
##########
dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java:
##########
@@ -0,0 +1,106 @@
+/*
+ * 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.camel.dsl.jbang.core.commands;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * A local server that plays an LLM for the tests of the AI commands: Ollama
(its models, and the answer of the model,
+ * whole or streamed in two parts) and an OpenAI-compatible chat completion.
It keeps the last question it answered.
+ */
+final class FakeLlmServer implements AutoCloseable {
+
+ private final HttpServer server;
+ private volatile String answer;
+ private volatile JsonObject lastRequest;
+
+ FakeLlmServer(String answer) throws IOException {
+ this.answer = answer;
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/api/tags", ex -> reply(ex,
"{\"models\":[{\"name\":\"test\",\"size\":1000}]}"));
+ server.createContext("/api/ps", ex -> reply(ex, "{\"models\":[]}"));
+ server.createContext("/api/show", ex -> reply(ex,
"{\"model_info\":{}}"));
+ server.createContext("/api/generate", this::generate);
+ server.createContext("/v1/models", ex -> reply(ex,
"{\"data\":[{\"id\":\"test\"}]}"));
+ server.createContext("/v1/chat/completions", this::chat);
+ server.start();
+ }
+
+ String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ /**
+ * The last question the server answered, as the client sent it.
+ */
+ JsonObject lastRequest() {
+ return lastRequest;
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private void generate(HttpExchange exchange) throws IOException {
+ JsonObject request = read(exchange);
+ if (request.getBooleanOrDefault("stream", false)) {
+ // the answer in two parts, a JSON document per line
+ int half = answer.length() / 2;
+ String first = new JsonObject(Map.of("response",
answer.substring(0, half), "done", false)).toJson();
+ String last = new JsonObject(Map.of("response",
answer.substring(half), "done", true)).toJson();
+ reply(exchange, first + "\n" + last + "\n");
+ } else {
+ reply(exchange, new JsonObject(Map.of("response", answer, "done",
true)).toJson());
+ }
+ }
+
+ private void chat(HttpExchange exchange) throws IOException {
+ read(exchange);
+ JsonObject message = new JsonObject(Map.of("role", "assistant",
"content", answer));
+ JsonObject choice = new JsonObject(Map.of("index", 0, "message",
message));
+ reply(exchange, new JsonObject(Map.of("choices", new
JsonArray(List.of(choice)))).toJson());
+ }
+
+ private JsonObject read(HttpExchange exchange) throws IOException {
+ String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
+ JsonObject request = Jsoner.deserialize(body, new JsonObject());
+ lastRequest = request;
+ return request;
+ }
+
+ private static void reply(HttpExchange exchange, String body) throws
IOException {
+ exchange.getRequestBody().readAllBytes();
Review Comment:
🔧 **Redundant `readAllBytes()` in `reply()` — body already consumed by
`read()`**
`generate()` and `chat()` both call `read(exchange)` first (which drains the
request body via `readAllBytes()`), and then call `reply(exchange, ...)`. The
`reply()` method calls `exchange.getRequestBody().readAllBytes()` again on an
already-exhausted stream, which always returns an empty byte array and does
nothing useful.
The direct `reply()` calls in the constructor lambdas (`/api/tags`,
`/api/ps`, etc.) never send a body, so those are harmless but misleading.
```suggestion
private static void reply(HttpExchange exchange, String body) throws
IOException {
```
Remove the `exchange.getRequestBody().readAllBytes();` line from `reply()` —
the callers that need to read the body already do so via `read()` before
invoking `reply()`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]