diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/CodexLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/CodexLauncher.java new file mode 100644 index 0000000..48dafe2 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/CodexLauncher.java @@ -0,0 +1,192 @@ +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.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 OpenAI's {@code codex} CLI — the third + * peer kind behind the bridge (after Claude Code and opencode). Like {@link OpenCodeLauncher} it + * extends {@link HerdrPeerLauncher} and reuses every line of shared transport (tab/pane placement, + * the CB-306 readiness gate, unique naming + CB-117 reap, teardown, listing, cwd), overriding only + * the launch seams. + * + *

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

+ */ +public final class CodexLauncher extends HerdrPeerLauncher { + + /** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */ + private static final String NAME_PREFIX = "codex"; + + /** Provisions the isolated {@code CODEX_HOME} each peer runs against (CB-528). */ + private final CodexHome codexHome; + + /** + * Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it + * matches the legacy non-blocking spawn semantics. + */ + public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome, + Map profiles, String defaultProfile, + Function env) { + this(agents, spaces, codexHome, profiles, defaultProfile, env, 0, + System::currentTimeMillis, () -> sleepUninterruptibly(300)); + } + + /** + * Production constructor with the spawn-ready gate enabled. Polls {@code agents.status()} until + * the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses. + */ + public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome, + Map profiles, String defaultProfile, + Function env, + long spawnReadyTimeoutMs, long spawnReadyPollMs) { + this(agents, spaces, codexHome, profiles, defaultProfile, env, + spawnReadyTimeoutMs, System::currentTimeMillis, + () -> sleepUninterruptibly(spawnReadyPollMs)); + } + + /** + * Full testability constructor. Every injectable collaborator is explicit so unit tests supply a + * fake clock ({@code nowMillis}), poll-loop wait ({@code sleeper}), and a stub {@link CodexHome} + * whose returned path they inspect as {@code CODEX_HOME}. + * + * @param agents herdr agent control (start, status, close) + * @param spaces workspace / tab control (ensure, create, close) + * @param codexHome seam that provisions the isolated {@code CODEX_HOME} (CB-528) + * @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 (never called when the gate is disabled) + */ + public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome, + Map profiles, String defaultProfile, + Function env, + long spawnReadyTimeoutMs, + LongSupplier nowMillis, Runnable sleeper) { + super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env, + spawnReadyTimeoutMs, nowMillis, sleeper); + this.codexHome = codexHome; + } + + /** + * {@inheritDoc} + * + *

Builds the codex launch: no {@code ANTHROPIC_*} and no guard (codex reads its own + * {@code CODEX_HOME} credentials); provision an isolated home via {@link #codexHome} and point + * the worker at it with {@code CODEX_HOME}; carry the parity-neutral git-forge grant; and pass + * the model, bridge MCP override, and bearer-token variable as flags — with mandatory + * {@code --approve-for-me}. + */ + @Override + protected Launch buildLaunch(BridgedConfig.Worker cfg) { + Map workerEnv = baseEnv(cfg); + workerEnv.put("CODEX_HOME", codexHome.provision(cfg).toString()); + applyGitToken(workerEnv, cfg); + return new Launch(workerEnv, argvFor(cfg)); + } + + /** + * The launch argv: {@code cfg.argv()} as the base command (the profile may override the + * executable), then mandatory {@code --approve-for-me}, then the optional model / bridge-MCP / + * bearer-token flags. + * + *

{@code --approve-for-me} is not optional polish — without it Codex auto-rejects every MCP + * tool call ("user cancelled MCP tool call") and the peer can never answer via + * {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox + * on; it is deliberately not the escape-hatch bypass flag. + */ + private List argvFor(BridgedConfig.Worker cfg) { + List argv = mutableArgv(cfg.argv()); + argv.add("--approve-for-me"); + if (cfg.model() != null && !cfg.model().isBlank()) { + argv.add("-m"); + argv.add(cfg.model()); + } + if (cfg.hasMcp()) { + argv.add("-c"); + argv.add("mcp_servers.bridged.url=\"" + cfg.mcpUrl() + "\""); + } + if (cfg.tokenEnv() != null && !cfg.tokenEnv().isBlank()) { + argv.add("--bearer-token-env-var"); + argv.add(cfg.tokenEnv()); + } + return argv; + } + + // --- 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 (codex prefix), kept for direct unit testing -------------------- + + /** + * Whether {@code name} is a codex bridge worker started by a different process than + * {@code currentNonce}. A thin {@code codex}-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/CodexLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/CodexLauncherTest.java new file mode 100644 index 0000000..5192ffb --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/CodexLauncherTest.java @@ -0,0 +1,224 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The Codex adapter's launch build (CB-528): an isolated {@code CODEX_HOME} provisioned through the + * {@link CodexHome} seam, mandatory {@code --approve-for-me}, the {@code -m}/{@code -c}/ + * {@code --bearer-token-env-var} flags, and — like opencode — no {@code ANTHROPIC_*} and no + * subscription guard (Codex authenticates via its own home credentials). Plus the shared base + * transport (herdr kind, capabilities, reap). + */ +class CodexLauncherTest { + + /** A codex profile. {@code null} argv → the kind default {@code ["codex"]}. */ + private static BridgedConfig.Worker codexCfg(String model, String mcpUrl, + String tokenEnv, String gitTokenEnv) { + return new BridgedConfig.Worker("codex-peer", null, model, null, tokenEnv, + null, "tab", "bridged-workers", "codex: {model} #{n}", mcpUrl, + null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_CODEX); + } + + /** A stub {@link CodexHome} returning {@code path} from {@code provision} (records calls). */ + private static final class FakeCodexHome implements CodexHome { + private final Path path; + int provisions; + FakeCodexHome(Path path) { + this.path = path; + } + @Override + public Path provision(BridgedConfig.Worker cfg) { + provisions++; + return path; + } + @Override + public void release(Path home) { + } + } + + /** Gate-disabled launcher with a stub home and an env that resolves the forge token. */ + private CodexLauncher service(FakeHerdr herdr, FakeCodexHome home, BridgedConfig.Worker cfg) { + return new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), home, + Map.of(cfg.profile(), cfg), cfg.profile(), + k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null, + 0, System::currentTimeMillis, () -> { }); + } + + @SuppressWarnings("unchecked") + private static Map lastStart(FakeHerdr herdr) { + return (Map) herdr.lastCall("agent.start").params(); + } + + @SuppressWarnings("unchecked") + private static Map startEnv(FakeHerdr herdr) { + Map env = + (Map) ((Map) herdr.lastCall("tab.create").params()).get("env"); + return env == null ? Map.of() : env; + } + + @SuppressWarnings("unchecked") + private static List startArgs(FakeHerdr herdr) { + return (List) lastStart(herdr).get("args"); + } + + @Test + void approveForMeIsAlwaysPresent(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home")); + service(herdr, home, codexCfg(null, null, null, null)).spawn(); + + List args = startArgs(herdr); + assertTrue(args.contains("--approve-for-me"), + "--approve-for-me is mandatory — without it MCP tool calls are user-cancelled"); + assertEquals("--approve-for-me", args.getFirst(), + "--approve-for-me is the first launch flag, right after the executable"); + } + + @Test + void argvHasAllFlagsWhenModelMcpAndTokenSet(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, new FakeCodexHome(root.resolve("h")), + codexCfg("gpt-5.2", "http://127.0.0.1:8765/mcp", "CODEX_TOKEN", null)).spawn(); + + assertEquals(List.of( + "--approve-for-me", + "-m", "gpt-5.2", + "-c", "mcp_servers.bridged.url=\"http://127.0.0.1:8765/mcp\"", + "--bearer-token-env-var", "CODEX_TOKEN"), + startArgs(herdr), + "all three optional flags follow --approve-for-me when configured"); + } + + @Test + void argvOmitsModelAndMcpWhenUnsetButKeepsApproveForMe(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + // tokenEnv defaults to BRIDGED_WORKER_TOKEN (never null/blank after record normalization). + service(herdr, new FakeCodexHome(root.resolve("h")), + codexCfg(null, null, null, null)).spawn(); + + List args = startArgs(herdr); + assertFalse(args.contains("-m"), "no model → no -m flag"); + assertFalse(args.contains("-c"), "no mcp url → no -c override"); + assertTrue(args.contains("--approve-for-me"), "--approve-for-me is never dropped"); + assertEquals(List.of("--approve-for-me", "--bearer-token-env-var", "BRIDGED_WORKER_TOKEN"), args, + "only the mandatory flag and the default bearer-token var remain"); + } + + @Test + void codexHomeIsSetToExactlyWhatTheSeamReturned(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home")); + service(herdr, home, codexCfg(null, null, null, null)).spawn(); + + assertEquals(root.resolve("codex-home").toString(), startEnv(herdr).get("CODEX_HOME"), + "CODEX_HOME is the provisioned home, verbatim from the CodexHome seam"); + assertEquals(1, home.provisions, "provision is called exactly once per spawn"); + } + + @Test + void codexHomeIsSetEvenWithoutMcp(@TempDir Path root) { + // Codex reads everything from CODEX_HOME, so a peer must never inherit ~/.codex even when no + // bridge MCP is mounted — this asserts provision is unconditional, not gated on hasMcp(). + FakeHerdr herdr = new FakeHerdr(); + FakeCodexHome home = new FakeCodexHome(root.resolve("home")); + service(herdr, home, codexCfg(null, null, null, null)).spawn(); + assertEquals(root.resolve("home").toString(), startEnv(herdr).get("CODEX_HOME")); + } + + @Test + void noAnthropicOrClaudeVarsInWorkerEnv(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, new FakeCodexHome(root.resolve("h")), + codexCfg(null, null, null, null)).spawn(); + + // Assert on the whole env map (a prefix match, not named keys) so the next variable someone + // adds to this boundary is caught too. + startEnv(herdr).keySet().forEach(k -> { + String up = k.toUpperCase(); + assertFalse(up.startsWith("ANTHROPIC_"), "worker env must not carry " + k + + " — Codex has no Anthropic seam and must not borrow the subscription"); + assertFalse(up.startsWith("CLAUDE_"), "worker env must not carry " + k + + " — the Claude config dir is a Claude-private concern"); + }); + } + + @Test + void constructionNeedsNoSubscriptionGuard(@TempDir Path root) { + // Codex authenticates through its own CODEX_HOME credentials, so the launcher takes a + // CodexHome, not a SubscriptionGuard, and spawns without consulting one. + FakeHerdr herdr = new FakeHerdr(); + FakeCodexHome home = new FakeCodexHome(root.resolve("h")); + CodexLauncher launcher = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer", _ -> null); + + launcher.spawn(); + assertTrue(noAnthropicOrClaude(startEnv(herdr)), "the production constructor sets no ANTHROPIC_*"); + + // The gate-enabled constructor builds the same way (sanity access to profiles). + CodexLauncher gated = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer", + _ -> null, 5000, 100); + assertEquals("codex-peer", gated.defaultProfile()); + } + + private static boolean noAnthropicOrClaude(Map env) { + return env.keySet().stream() + .noneMatch(k -> k.toUpperCase().startsWith("ANTHROPIC_") + || k.toUpperCase().startsWith("CLAUDE_")); + } + + @Test + void herdrAgentKindIsCodex(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, new FakeCodexHome(root.resolve("h")), + codexCfg(null, null, null, null)).spawn(); + + assertEquals("codex", lastStart(herdr).get("kind"), + "herdr detects and status-tracks the pane natively under the codex kind"); + } + + @Test + void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + FakeCodexHome home = new FakeCodexHome(root.resolve("h")); + assertEquals(Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP), + service(herdr, home, codexCfg(null, null, null, null)).capabilities(), + "no git token → no SELF_PR"); + assertTrue(service(herdr, home, codexCfg(null, null, null, "GITEA_ACCESS_TOKEN")) + .capabilities().contains(Capability.SELF_PR), + "a git-token profile adds SELF_PR"); + } + + @Test + void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, new FakeCodexHome(root.resolve("h")), + codexCfg(null, 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/opencode"); + } + + @Test + void foreignWorkerMatchesCodexPrefixButNotClaude() { + String nonce = "abc123"; + assertTrue(CodexLauncher.isForeignWorker("codex-codex-peer-def456-1", nonce), + "a codex pane from another process is foreign"); + assertFalse(CodexLauncher.isForeignWorker("codex-codex-peer-" + nonce + "-1", nonce), + "our own codex pane (same nonce) is not foreign"); + assertFalse(CodexLauncher.isForeignWorker("claude-ltms-local-def456-1", nonce), + "a claude pane is never reaped by the codex adapter"); + } +}