This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-kit.git
commit ad749a3924bdf50101c34ab8e3049cf2f1a3ed54 Author: Jinal Sarvaiya <[email protected]> AuthorDate: Thu Nov 5 14:21:15 2026 +0000 fix: fail-closed collision, global config, ASCII normalization --- .../camel/kit/generator/OpenCodeGenerator.java | 82 +++++++++++++++++----- .../camel/kit/generator/OpenCodeGeneratorTest.java | 72 +++++++++++++++++-- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/generator/OpenCodeGenerator.java b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/generator/OpenCodeGenerator.java index bdc3a948..3f3f2743 100644 --- a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/generator/OpenCodeGenerator.java +++ b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/generator/OpenCodeGenerator.java @@ -24,6 +24,8 @@ public class OpenCodeGenerator extends DefaultGenerator { private static final Set<String> BUILTIN_PERMISSION_PREFIXES = Set.of("external"); + private static final List<String> GLOBAL_CONFIG_FILES = List.of("opencode.json", "opencode.jsonc"); + private final QuteTemplateEngine templateEngine = new QuteTemplateEngine(); @Override @@ -42,57 +44,105 @@ public class OpenCodeGenerator extends DefaultGenerator { List<String> mcpServers = readMcpServerNames(ctx); Set<String> seen = new LinkedHashSet<>(); + boolean collisionDetected = false; StringBuilder denials = new StringBuilder(); for (String server : mcpServers) { String normalized = normalizeServerName(server); if (BUILTIN_PERMISSION_PREFIXES.contains(normalized)) { - continue; - } - if (seen.add(normalized)) { + collisionDetected = true; + } else if (seen.add(normalized)) { denials.append(" ").append(normalized).append("_*: deny\n"); } } data.put("MCP_TOOL_DENIALS", denials.toString()); // OpenCode-specific: generate agent definitions - generateAgents(ctx, data); + generateAgents(ctx, data, !collisionDetected); } static String normalizeServerName(String name) { StringBuilder sb = new StringBuilder(name.length()); for (int i = 0; i < name.length(); i++) { char c = name.charAt(i); - sb.append(Character.isLetterOrDigit(c) || c == '-' ? c : '_'); + sb.append(isAsciiAlphanumericOrHyphen(c) ? c : '_'); } return sb.toString(); } + private static boolean isAsciiAlphanumericOrHyphen(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; + } + private List<String> readMcpServerNames(InitContext ctx) throws IOException { Set<String> names = new LinkedHashSet<>(); for (Path configFile : OpenCodeProjectConfig.existingFiles(ctx.projectDir())) { - JsonNode config = OpenCodeProjectConfig.newJsonMapper().readTree(configFile.toFile()); - if (config == null) { - continue; - } - JsonNode mcp = config.path("mcp"); - if (!mcp.isObject()) { - continue; - } - mcp.fieldNames().forEachRemaining(names::add); + collectMcpServersFromFile(configFile, names); + } + for (Path globalFile : globalConfigFiles()) { + collectMcpServersFromFile(globalFile, names); } return List.copyOf(names); } - private void generateAgents(InitContext ctx, Map<String, Object> data) throws Exception { + private static void collectMcpServersFromFile(Path configFile, Set<String> names) throws IOException { + JsonNode config = OpenCodeProjectConfig.newJsonMapper().readTree(configFile.toFile()); + if (config == null) { + return; + } + JsonNode mcp = config.path("mcp"); + if (!mcp.isObject()) { + return; + } + JsonNode servers = mcp.path("servers"); + boolean hasNativeContainer = servers.isObject() && !servers.has("type"); + if (hasNativeContainer) { + servers.fields().forEachRemaining(entry -> { + if (entry.getValue().isObject()) { + names.add(entry.getKey()); + } + }); + } + mcp.fields().forEachRemaining(entry -> { + if (entry.getValue().isObject() + && !(hasNativeContainer && "servers".equals(entry.getKey()))) { + names.add(entry.getKey()); + } + }); + } + + static List<Path> globalConfigFiles() { + Path globalDir = globalConfigDir(); + return GLOBAL_CONFIG_FILES.stream() + .map(globalDir::resolve) + .filter(Files::exists) + .toList(); + } + + private static Path globalConfigDir() { + String xdgConfigHome = System.getenv("XDG_CONFIG_HOME"); + if (xdgConfigHome != null && !xdgConfigHome.isBlank()) { + return Path.of(xdgConfigHome, "opencode"); + } + return Path.of(System.getProperty("user.home"), ".config", "opencode"); + } + + private void generateAgents(InitContext ctx, Map<String, Object> data, boolean includeShipWorker) + throws Exception { Path agentsDir = ctx.projectDir().resolve(".opencode/agents"); Files.createDirectories(agentsDir); + int count = 0; for (String agentName : AGENTS) { + if ("camel-ship-worker".equals(agentName) && !includeShipWorker) { + Files.deleteIfExists(agentsDir.resolve(agentName + ".md")); + continue; + } String content = templateEngine.render("templates/opencode/agents/" + agentName + ".md", data); Files.writeString(agentsDir.resolve(agentName + ".md"), content); + count++; } - ctx.printer().println(AnsiColors.green("✓") + " Generated " + AGENTS.length + " OpenCode agent definitions"); + ctx.printer().println(AnsiColors.green("✓") + " Generated " + count + " OpenCode agent definitions"); } } diff --git a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/OpenCodeGeneratorTest.java b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/OpenCodeGeneratorTest.java index 7894503f..f5514d1c 100644 --- a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/OpenCodeGeneratorTest.java +++ b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/OpenCodeGeneratorTest.java @@ -1073,7 +1073,7 @@ class OpenCodeGeneratorTest { } @Test - void shipWorkerSkipsExternalServerToAvoidBuiltinCollision() throws Exception { + void shipWorkerNotGeneratedWhenExternalServerCollidesWithBuiltin() throws Exception { InitContext ctx = createContext(); new OpenCodeGenerator().generate(ctx); @@ -1089,13 +1089,71 @@ class OpenCodeGeneratorTest { new OpenCodeGenerator().generate(ctx); + Path workerFile = tempDir.resolve(".opencode/agents/camel-ship-worker.md"); + assertFalse(Files.exists(workerFile), + "Ship worker must not be generated when an MCP server collides with a built-in permission prefix"); + assertTrue(Files.exists(tempDir.resolve(".opencode/agents/brainstormer.md")), + "Other agent definitions must still be generated"); + } + + @Test + void shipWorkerDeletedOnReInitWhenCollisionAppears() throws Exception { + InitContext ctx = createContext(); + new OpenCodeGenerator().generate(ctx); + + Path workerFile = tempDir.resolve(".opencode/agents/camel-ship-worker.md"); + assertTrue(Files.exists(workerFile), "Worker must exist after initial generation"); + + Path configFile = tempDir.resolve("opencode.json"); + com.fasterxml.jackson.databind.node.ObjectNode config + = (com.fasterxml.jackson.databind.node.ObjectNode) new ObjectMapper().readTree(configFile.toFile()); + com.fasterxml.jackson.databind.node.ObjectNode external = new ObjectMapper().createObjectNode(); + external.put("type", "remote"); + external.put("url", "https://example.test/external"); + ((com.fasterxml.jackson.databind.node.ObjectNode) config.path("mcp")).set("external", external); + Files.writeString(configFile, new ObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsString(config)); + + new OpenCodeGenerator().generate(ctx); + + assertFalse(Files.exists(workerFile), + "Ship worker must be deleted on re-init when collision is detected"); + } + + @Test + void shipWorkerDeniesNativeFormatServers() throws Exception { + InitContext ctx = createContext(); + + Files.writeString(tempDir.resolve("opencode.json"), """ + { + "mcp": { + "servers": { + "probe": {"type": "local", "command": ["echo"], "enabled": true}, + "scanner": {"type": "local", "command": ["echo"], "enabled": true} + } + } + } + """); + + new OpenCodeGenerator().generate(ctx); + String worker = Files.readString( tempDir.resolve(".opencode/agents/camel-ship-worker.md")); - assertFalse(worker.contains("external_*: deny"), - "Ship worker must not emit external_* denial to avoid colliding with built-in external_directory"); - assertTrue(worker.contains("mcp: deny"), - "Ship worker must still deny all MCP tools via the mcp permission"); - assertTrue(worker.contains("camel_*: deny"), - "Ship worker must still deny other MCP server tools"); + assertTrue(worker.contains("probe_*: deny"), + "Ship worker must deny servers declared in native mcp.servers format"); + assertTrue(worker.contains("scanner_*: deny"), + "Ship worker must deny all servers from native mcp.servers container"); + } + + @Test + void shipWorkerUsesAsciiOnlyNormalization() throws Exception { + assertEquals("caf_", OpenCodeGenerator.normalizeServerName("café"), + "Non-ASCII letters must be replaced with underscore"); + assertEquals("probe_local", OpenCodeGenerator.normalizeServerName("probe.local"), + "Dots must be replaced with underscore"); + assertEquals("my-server", OpenCodeGenerator.normalizeServerName("my-server"), + "Hyphens must be preserved"); + assertEquals("__", OpenCodeGenerator.normalizeServerName("üñ"), + "All non-ASCII characters must be replaced"); } }
