diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java new file mode 100644 index 0000000..48b534e --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java @@ -0,0 +1,233 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.Agent; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.LongSupplier; + +/** + * The {@link HerdrPeerLauncher} adapter for opencode — an open-source, + * provider-agnostic terminal coding agent. Its whole reason for existing is to prove the + * {@code PeerLauncher} SPI is genuinely provider-neutral: opencode shares none of Claude Code's + * private launch seams, yet reuses every line of shared transport in the base (tab/pane placement, + * the CB-306 readiness gate, unique naming + CB-117 reap, teardown, listing, cwd). + * + *

The divergences from {@link ClaudeCodeLauncher}, all confined to {@link #buildLaunch}: + *

+ */ +public final class OpenCodeLauncher extends HerdrPeerLauncher { + + /** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */ + private static final String NAME_PREFIX = "opencode"; + + /** + * Standing instruction written to the charter file and mounted via the config's + * {@code instructions} so the worker returns its result through {@code bridge_reply}. Kept on + * disk (not a launch flag) because opencode's {@code instructions} takes file paths, not inline + * text — the file is regenerated per spawn and never touches the worker's own profile. + */ + static final String REPLY_CHARTER = + "You are an off-subscription worker in the claude-bridge fleet, running under opencode. " + + "Every message you receive arrives through the bridge, and the ONLY channel back to the " + + "sender is the bridge_reply MCP tool. Text you write in your terminal is NOT sent " + + "anywhere — the sender cannot see your screen, so an in-terminal answer is silently " + + "discarded. Therefore you MUST end EVERY turn by calling bridge_reply with `content` set " + + "to your complete response. This holds for every message without exception — tasks, " + + "questions, clarifications, acknowledgements, and ordinary back-and-forth conversation. " + + "Call bridge_reply exactly once, as the final action of your turn, with your full answer " + + "in `content`; never wait for confirmation first. If you end a turn without calling " + + "bridge_reply, the sender receives nothing and the exchange stalls."; + + /** Root under which per-spawn opencode config dirs are created (injectable for tests). */ + private final Path configRoot; + + /** + * Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it + * matches the legacy non-blocking spawn semantics. Config dirs are created under the JVM temp dir. + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function env) { + this(agents, spaces, profiles, defaultProfile, env, 0, + System::currentTimeMillis, () -> sleepUninterruptibly(300), + defaultConfigRoot()); + } + + /** + * Production constructor with the spawn-ready gate enabled. Polls {@code agents.status()} until + * the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses. + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function env, + long spawnReadyTimeoutMs, long spawnReadyPollMs) { + this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, + System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs), + defaultConfigRoot()); + } + + /** + * Full testability constructor. Every injectable collaborator is explicit so unit tests supply a + * fake clock ({@code nowMillis}), poll-loop wait ({@code sleeper}), and a temp {@code configRoot} + * they can inspect the generated {@code opencode.json}/charter under. + * + * @param agents herdr agent control (start, status, close) + * @param spaces workspace / tab control (ensure, create, close) + * @param profiles configured worker profiles + * @param defaultProfile profile a no-argument spawn uses (nullable) + * @param env host env lookup (injectable for tests) + * @param spawnReadyTimeoutMs max ms to wait for injectable state (0 disables the gate) + * @param nowMillis monotonic clock source (e.g. {@code System::currentTimeMillis}) + * @param sleeper sleep/wait hook (encodes the poll interval; never called when the + * gate is disabled) + * @param configRoot existing directory under which per-spawn config dirs are created + */ + public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, + Map profiles, String defaultProfile, + Function 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"); + } +}