env,
+ long spawnReadyTimeoutMs,
+ LongSupplier nowMillis, Runnable sleeper, Path configRoot) {
+ super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
+ spawnReadyTimeoutMs, nowMillis, sleeper);
+ this.configRoot = configRoot;
+ }
+
+ private static Path defaultConfigRoot() {
+ return Path.of(System.getProperty("java.io.tmpdir"));
+ }
+
+ /**
+ * {@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
+ * it via {@code OPENCODE_CONFIG}; carry the parity-neutral git-forge grant; and select the model
+ * with {@code -m}.
+ */
+ @Override
+ protected Launch buildLaunch(BridgedConfig.Worker cfg) {
+ Map workerEnv = newEnv();
+ if (cfg.hasMcp()) {
+ workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString());
+ }
+ applyGitToken(workerEnv, cfg);
+ return new Launch(workerEnv, argvWithModel(cfg));
+ }
+
+ /** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */
+ private List argvWithModel(BridgedConfig.Worker cfg) {
+ List argv = mutableArgv(cfg.argv());
+ if (cfg.model() != null && !cfg.model().isBlank()) {
+ argv.add("-m");
+ argv.add(cfg.model());
+ }
+ return argv;
+ }
+
+ /**
+ * Write an ephemeral {@code opencode.json} (and the reply-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.Worker cfg) {
+ try {
+ Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-");
+ dir.toFile().deleteOnExit();
+
+ Path charter = dir.resolve("reply-charter.md");
+ Files.writeString(charter, REPLY_CHARTER);
+ charter.toFile().deleteOnExit();
+
+ String json = "{\n"
+ + " \"$schema\": \"https://opencode.ai/config.json\",\n"
+ + " \"mcp\": { \"bridge\": { \"type\": \"remote\", \"url\": \""
+ + jsonEscape(cfg.mcpUrl()) + "\", \"enabled\": true } },\n"
+ + " \"instructions\": [\"" + jsonEscape(charter.toAbsolutePath().toString()) + "\"]\n"
+ + "}\n";
+ Path cfgFile = dir.resolve("opencode.json");
+ Files.writeString(cfgFile, json);
+ cfgFile.toFile().deleteOnExit();
+ return cfgFile;
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "cannot write opencode config for profile " + cfg.profile(), e);
+ }
+ }
+
+ /** Minimal JSON string escaping for the two interpolated values (a URL and an absolute path). */
+ private static String jsonEscape(String s) {
+ return s.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
+
+ /** Spawn a worker for the default profile in the resolved default cwd. */
+ public Agent spawn() {
+ return spawnInternal(null, null, null);
+ }
+
+ /** Spawn a worker for a named profile (null → default) in the resolved default cwd. */
+ public Agent spawn(String profileName) {
+ return spawnInternal(profileName, null, null);
+ }
+
+ /** Spawn a worker for a named profile with an explicit requested/caller cwd (CB-112). */
+ public Agent spawn(String profileName, String requestedCwd, String callerCwd) {
+ return spawnInternal(profileName, requestedCwd, callerCwd);
+ }
+
+ // --- capabilities --------------------------------------------------------------------------
+
+ @Override
+ public Set capabilities() {
+ Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP);
+ if (hasGitTokenProfile()) {
+ caps.add(Capability.SELF_PR);
+ }
+ return Set.copyOf(caps);
+ }
+
+ /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
+ private boolean hasGitTokenProfile() {
+ return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
+ }
+
+ // --- CB-117 reap predicate (opencode prefix), kept for direct unit testing -----------------
+
+ /**
+ * Whether {@code name} is an opencode bridge worker started by a different process than
+ * {@code currentNonce}. A thin {@code opencode}-prefix binding of
+ * {@link HerdrPeerLauncher#isForeignWorker(String, String, String)}.
+ */
+ static boolean isForeignWorker(String name, String currentNonce) {
+ return HerdrPeerLauncher.isForeignWorker(NAME_PREFIX, name, currentNonce);
+ }
+}
diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java
new file mode 100644
index 0000000..d6768b9
--- /dev/null
+++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java
@@ -0,0 +1,179 @@
+package dev.ltms.bridged.worker;
+
+import dev.ltms.bridged.config.BridgedConfig;
+import dev.ltms.bridged.herdr.AgentControl;
+import dev.ltms.bridged.herdr.FakeHerdr;
+import dev.ltms.bridged.herdr.WorkspaceControl;
+import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.PeerHandle;
+import dev.ltms.bridged.peer.PeerUnreachableException;
+import dev.ltms.bridged.peer.SpawnRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * The opencode adapter's launch build: a file-based MCP mount + reply-charter instructions (no
+ * inline flags, no {@code ANTHROPIC_*}, no guard), the {@code -m} model flag, and the shared base
+ * transport (naming, reap, readiness gate) proving the {@link HerdrPeerLauncher} SPI is neutral.
+ */
+class OpenCodeLauncherTest {
+
+ private static BridgedConfig.Worker opencodeCfg(String model, String mcpUrl, String gitTokenEnv) {
+ return new BridgedConfig.Worker("gemini", null, model, null, "BRIDGED_WORKER_TOKEN",
+ List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
+ null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_OPENCODE);
+ }
+
+ /** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */
+ private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker 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);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map lastStart(FakeHerdr herdr) {
+ return (Map) herdr.lastCall("agent.start").params();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map startEnv(FakeHerdr herdr) {
+ return (Map) lastStart(herdr).get("env");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List startArgv(FakeHerdr herdr) {
+ return (List) lastStart(herdr).get("argv");
+ }
+
+ @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();
+
+ Map env = startEnv(herdr);
+ assertNull(env.get("ANTHROPIC_BASE_URL"), "opencode carries no ANTHROPIC_* / subscription boundary");
+ String cfgPath = env.get("OPENCODE_CONFIG");
+ assertNotNull(cfgPath, "OPENCODE_CONFIG points the worker at the generated config file");
+ assertTrue(Path.of(cfgPath).startsWith(root), "config file is generated under the injected root");
+
+ String json = Files.readString(Path.of(cfgPath));
+ assertTrue(json.contains("\"type\": \"remote\""), "bridge is mounted as a remote MCP server");
+ assertTrue(json.contains("http://127.0.0.1:8765/mcp"), "the profile's bridge MCP url is present");
+ assertTrue(json.contains("\"instructions\""), "the reply 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");
+ 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");
+ }
+
+ @Test
+ void noConfigFileWhenMcpUrlAbsent(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)).spawn();
+
+ assertNull(startEnv(herdr).get("OPENCODE_CONFIG"),
+ "no bridge MCP url → no config file and no OPENCODE_CONFIG");
+ }
+
+ @Test
+ void passesTheModelAsDashMFlag(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)).spawn();
+
+ List argv = startArgv(herdr);
+ assertEquals("opencode", argv.getFirst(), "base opencode command preserved first");
+ int m = argv.indexOf("-m");
+ assertTrue(m >= 0, "model is selected with -m");
+ assertEquals("google/gemini-2.5-pro", argv.get(m + 1), "the provider/model selector follows -m");
+ }
+
+ @Test
+ void noModelFlagWhenModelBlank(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, root, opencodeCfg(null, null, null)).spawn();
+ assertEquals(List.of("opencode"), startArgv(herdr), "no model → argv is the bare opencode command");
+ }
+
+ @Test
+ void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, root, opencodeCfg(null, null, "GITEA_ACCESS_TOKEN")).spawn();
+ assertEquals("tok", startEnv(herdr).get("GITEA_TOKEN"),
+ "a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude");
+ }
+
+ @Test
+ void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ assertEquals(java.util.Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP),
+ service(herdr, root, opencodeCfg(null, null, null)).capabilities(),
+ "no git token → no SELF_PR");
+ assertTrue(service(herdr, root, opencodeCfg(null, null, "GITEA_ACCESS_TOKEN"))
+ .capabilities().contains(Capability.SELF_PR),
+ "a git-token profile adds SELF_PR");
+ }
+
+ @Test
+ void foreignWorkerMatchesOpencodePrefixButNotClaude() {
+ String nonce = "abc123";
+ assertTrue(OpenCodeLauncher.isForeignWorker("opencode-gemini-def456-1", nonce),
+ "an opencode pane from another process is foreign");
+ assertFalse(OpenCodeLauncher.isForeignWorker("opencode-gemini-" + nonce + "-1", nonce),
+ "our own opencode pane (same nonce) is not foreign");
+ assertFalse(OpenCodeLauncher.isForeignWorker("claude-ltms-local-def456-1", nonce),
+ "a claude pane is never reaped by the opencode adapter");
+ }
+
+ @Test
+ void productionConstructorsWireThroughToTheBase() {
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = opencodeCfg(null, null, null);
+ // 5-arg (gate disabled) and 7-arg (gate enabled) production constructors both expose the profile.
+ OpenCodeLauncher disabled = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
+ OpenCodeLauncher gated = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null, 5000, 100);
+ assertEquals(java.util.Set.of("gemini"), disabled.profiles());
+ assertEquals("gemini", gated.defaultProfile());
+ }
+
+ @Test
+ void spawnGateThrowsPeerUnreachableWhenNeverInjectable(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ herdr.agentStatus("unknown"); // never injectable
+ long[] clock = {0};
+ OpenCodeLauncher svc = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ Map.of("gemini", opencodeCfg(null, null, null)), "gemini", _ -> null,
+ 1000, () -> clock[0], () -> clock[0] += 50, root);
+
+ PeerUnreachableException ex = assertThrows(PeerUnreachableException.class,
+ () -> svc.spawn(new SpawnRequest(null, null, null)));
+ assertTrue(clock[0] >= 1000, "the fake clock advanced past the timeout: " + clock[0]);
+ long closes = herdr.calls.stream()
+ .filter(c -> c.method().equals("pane.close"))
+ .filter(c -> "w9:pW_1".equals(((Map, ?>) c.params()).get("pane_id")))
+ .count();
+ assertEquals(1, closes, "the worker pane was reaped on timeout (no orphan)");
+ assertNotNull(ex.getMessage());
+ }
+
+ @Test
+ void spawnReturnsHandleWhenGateDisabled(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ PeerHandle handle = service(herdr, root, opencodeCfg(null, null, null))
+ .spawn(new SpawnRequest(null, null, null));
+ assertNotNull(handle, "spawn returns a handle when the gate is disabled");
+ assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled");
+ }
+}