diff --git a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java index f5a1b6a..e75dd42 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java @@ -38,7 +38,7 @@ import java.util.function.Supplier; *
  • File-based MCP mount + instructions. opencode has no inline * {@code --mcp-config}/{@code --append-system-prompt}. Instead the bridge writes an ephemeral * {@code opencode.json} that declares the bridge as a {@code remote} MCP server and lists a - * reply-charter file under {@code instructions}, then points the worker at it with + * member-charter file under {@code instructions}, then points the worker at it with * {@code OPENCODE_CONFIG}. This is the one place the launcher touches disk — Claude never did.
  • *
  • Model as a flag. the {@code provider/model} selector is passed as * {@code -m}, not an env var.
  • @@ -163,17 +163,17 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * {@inheritDoc} * *

    Builds the opencode launch: no {@code ANTHROPIC_*} and no guard (opencode reads its own - * provider credentials); when the profile mounts the bridge MCP, generate an ephemeral - * {@code opencode.json} (remote MCP server + reply-charter instructions) and point the worker at + * provider credentials); when the profile mounts the bridge MCP or has a member charter, generate an ephemeral + * {@code opencode.json} (remote MCP server + member-charter instructions) and point the worker at * it via {@code OPENCODE_CONFIG}; carry the parity-neutral git-forge grant; and select the model * with {@code -m}. */ @Override protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { Map workerEnv = baseEnv(cfg); - // A config file is needed for the bridge MCP mount, for a pinned endpoint (CB-508), or both. - if (cfg.hasMcp() || hasCustomProvider(cfg)) { - workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString()); + // A config file is needed for the bridge MCP mount, a member charter, or a pinned endpoint (CB-508). + if (cfg.hasMcp() || spec.charter() != null || hasCustomProvider(cfg)) { + workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString()); } applyGitToken(workerEnv, cfg); return new Launch(workerEnv, @@ -236,12 +236,12 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { } /** - * Write an ephemeral {@code opencode.json} (and the reply-charter file it references) into a + * Write an ephemeral {@code opencode.json} (and the member-charter file it references) into a * fresh per-spawn directory under {@link #configRoot}, and return the config file's path for * {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it; * it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn). */ - private Path writeConfig(BridgedConfig.Profile cfg) { + private Path writeConfig(BridgedConfig.Profile cfg, String charterText) { try { Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-"); dir.toFile().deleteOnExit(); @@ -264,16 +264,19 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { // if per-profile control is ever wanted, add a profile knob rather than dropping this. root.putObject("compaction").put("auto", true); - if (cfg.hasMcp()) { - Path charter = dir.resolve("reply-charter.md"); - Files.writeString(charter, REPLY_CHARTER); + if (charterText != null) { + Path charter = dir.resolve("member-charter.md"); + Files.writeString(charter, charterText); charter.toFile().deleteOnExit(); + root.putArray("instructions").add(charter.toAbsolutePath().toString()); + } + + if (cfg.hasMcp()) { ObjectNode bridge = root.putObject("mcp").putObject("bridge"); bridge.put("type", "remote"); bridge.put("url", cfg.mcpUrl()); bridge.put("enabled", true); - root.putArray("instructions").add(charter.toAbsolutePath().toString()); } if (hasCustomProvider(cfg)) { addCustomProvider(root, cfg); diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java index ba370ec..b4dee40 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java @@ -17,6 +17,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.*; @@ -34,12 +38,19 @@ class OpenCodeLauncherTest { } /** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */ - private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg) { + private static OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg) { return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null, 0, System::currentTimeMillis, () -> { }, configRoot, configRoot); } + private static OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg, + Supplier fleet) { + return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null, + 0, System::currentTimeMillis, () -> { }, configRoot, configRoot, fleet); + } + @SuppressWarnings("unchecked") private static Map lastStart(FakeHerdr herdr) { return (Map) herdr.lastCall("agent.start").params(); @@ -62,8 +73,10 @@ class OpenCodeLauncherTest { @Test void writesRemoteMcpConfigAndCharterInstructionsWhenMcpUrlSet(@TempDir Path root) throws Exception { FakeHerdr herdr = new FakeHerdr(); - service(herdr, root, opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null)) - .spawn(); + BridgedConfig.Fleet fleet = new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), + Map.of("dev", "role rule"), null); + service(herdr, root, opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null), + () -> fleet).spawn(); Map env = startEnv(herdr); assertNull(env.get("ANTHROPIC_BASE_URL"), "opencode carries no ANTHROPIC_* / subscription boundary"); @@ -82,13 +95,15 @@ class OpenCodeLauncherTest { "the profile's bridge MCP url is present"); assertTrue(bridge.path("enabled").asBoolean(), "the bridge server is enabled"); assertTrue(json.path("instructions").isArray() && !json.path("instructions").isEmpty(), - "the reply charter is mounted via instructions"); + "the member charter is mounted via instructions"); - // The instructions entry is a real file path holding the reply charter. - Path charter = Path.of(cfgPath).resolveSibling("reply-charter.md"); + // The instructions entry is a real file path holding the composed member charter. + Path charter = Path.of(cfgPath).resolveSibling("member-charter.md"); assertTrue(Files.exists(charter), "the charter file the config references was written"); - assertTrue(Files.readString(charter).contains("bridge_reply"), - "the charter instructs the worker to answer via bridge_reply"); + assertEquals("role rule\n\n" + HerdrPeerLauncher.REPLY_CHARTER, Files.readString(charter), + "the composed charter keeps the role rule first and the reply rule last"); + assertEquals(charter.toAbsolutePath().toString(), json.path("instructions").get(0).asText(), + "instructions names the charter file by its absolute path"); } @Test @@ -100,6 +115,69 @@ class OpenCodeLauncherTest { "no bridge MCP url → no config file and no OPENCODE_CONFIG"); } + @Test + void roleCharterWithoutMcpOrCustomProviderStillWritesAConfig(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Fleet fleet = new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), + Map.of("dev", "role rule"), null); + Path configRoot = Files.createDirectory(root.resolve("configs")); + Path checkout = Files.createDirectory(root.resolve("checkout")); + service(herdr, configRoot, opencodeCfg("google/gemini-2.5-pro", null, null), () -> fleet).spawn(); + + String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG"); + assertNotNull(cfgPath, "a role charter needs a config even without MCP or custom provider"); + JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile()); + Path charter = Path.of(json.path("instructions").get(0).asText()); + assertEquals("role rule", Files.readString(charter), "the base-composed role charter is unchanged"); + assertTrue(json.path("mcp").isMissingNode(), "a charter does not add an MCP mount"); + assertTrue(charter.startsWith(configRoot), "the charter is written under the temp config root"); + try (var files = Files.walk(checkout)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().equals("member-charter.md")), + "the worker checkout receives no charter file"); + } + } + + @Test + void nullCharterWritesNoCharterFileOrInstructions(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, pinnedCfg("local-vllm/model", "http://127.0.0.1:8000", null), + () -> new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), null)).spawn(); + + String config = startEnv(herdr).get("OPENCODE_CONFIG"); + assertNotNull(config, "the custom provider still needs a config"); + JsonNode json = new ObjectMapper().readTree(Path.of(config).toFile()); + assertTrue(json.path("instructions").isMissingNode(), "a null charter adds no instructions entry"); + try (var files = Files.walk(root)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().equals("member-charter.md")), + "a null charter creates no charter file"); + } + } + + @Test + void concurrentSpawnsWriteSeparateCharterDirectories(@TempDir Path root) throws Exception { + BridgedConfig.Profile cfg = opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> spawnConfigPath(root, cfg)); + Future second = executor.submit(() -> spawnConfigPath(root, cfg)); + + Path firstCharter = Path.of(first.get()).resolveSibling("member-charter.md"); + Path secondCharter = Path.of(second.get()).resolveSibling("member-charter.md"); + assertNotEquals(firstCharter.getParent(), secondCharter.getParent(), + "each concurrent spawn owns a separate config directory"); + assertTrue(Files.exists(firstCharter)); + assertTrue(Files.exists(secondCharter)); + } finally { + executor.shutdownNow(); + } + } + + private static String spawnConfigPath(Path root, BridgedConfig.Profile cfg) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, cfg).spawn(); + return startEnv(herdr).get("OPENCODE_CONFIG"); + } + @Test void passesTheModelAsDashMFlagAlongsideAutoApprove(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr();