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");
+ }
+}