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();