Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eaa7aced3 | |||
| ef49835c4f |
@@ -126,6 +126,12 @@ public record BridgedConfig(
|
|||||||
public static final String KIND_CLAUDE_CODE = "claude-code";
|
public static final String KIND_CLAUDE_CODE = "claude-code";
|
||||||
/** Peer kind spawned by the opencode adapter (CB-402). */
|
/** Peer kind spawned by the opencode adapter (CB-402). */
|
||||||
public static final String KIND_OPENCODE = "opencode";
|
public static final String KIND_OPENCODE = "opencode";
|
||||||
|
/**
|
||||||
|
* Peer kind spawned by the Codex adapter (CB-528). Like {@link #KIND_OPENCODE} it carries
|
||||||
|
* its own argv and never inherits the Claude binary, and it sits outside the
|
||||||
|
* {@code ANTHROPIC_BASE_URL} subscription guard because Codex has no such seam.
|
||||||
|
*/
|
||||||
|
public static final String KIND_CODEX = "codex";
|
||||||
|
|
||||||
public Worker {
|
public Worker {
|
||||||
// A claude-code worker defaults its launch command to `claude`; other kinds carry their own
|
// A claude-code worker defaults its launch command to `claude`; other kinds carry their own
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.ltms.bridged.worker;
|
||||||
|
|
||||||
|
import dev.ltms.bridged.config.BridgedConfig;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions an isolated {@code CODEX_HOME} for one Codex peer (CB-528).
|
||||||
|
*
|
||||||
|
* <p>Codex reads <em>everything</em> from {@code CODEX_HOME} — its config, credentials, sessions,
|
||||||
|
* skills, plugins, and state. Pointing a peer at the operator's own {@code ~/.codex} would give it
|
||||||
|
* the operator's tool surface and let it write into the operator's session history, which is the
|
||||||
|
* same class of failure CB-525 exists to prevent on the Claude side. So every peer gets its own
|
||||||
|
* directory, and this is the seam that builds it.
|
||||||
|
*
|
||||||
|
* <p>It is an interface rather than a method on the launcher for two reasons: provisioning is
|
||||||
|
* filesystem work with its own failure modes (a missing credential is the most common, and it
|
||||||
|
* surfaces as an opaque {@code 401} from Codex rather than a spawn error), and keeping it separate
|
||||||
|
* lets the launcher be tested without touching a real home directory.
|
||||||
|
*
|
||||||
|
* <p>Three things the implementation must put in the home, because Codex has no launch flag for
|
||||||
|
* any of them:
|
||||||
|
* <ul>
|
||||||
|
* <li>the bridge MCP server, as {@code [mcp_servers.*]} in {@code config.toml};</li>
|
||||||
|
* <li>the reply charter, as {@code AGENTS.md} — Codex has no {@code --append-system-prompt},
|
||||||
|
* so the standing instruction has to reach it as a file;</li>
|
||||||
|
* <li>credentials, since a freshly created home has none and Codex fails closed.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public interface CodexHome {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh, isolated home for a peer launching under {@code cfg} and return its path,
|
||||||
|
* suitable for the {@code CODEX_HOME} environment variable.
|
||||||
|
*
|
||||||
|
* @param cfg the profile being launched; supplies the MCP URL and any bearer-token variable
|
||||||
|
* @return the provisioned directory
|
||||||
|
* @throws RuntimeException if the home cannot be provisioned — including when no credential is
|
||||||
|
* available, which must fail loudly here rather than as a 401 later
|
||||||
|
*/
|
||||||
|
Path provision(BridgedConfig.Worker cfg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a home previously returned by {@link #provision}. Idempotent: releasing an already
|
||||||
|
* released or never provisioned path is not an error, because teardown races teardown.
|
||||||
|
*/
|
||||||
|
void release(Path home);
|
||||||
|
}
|
||||||
@@ -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 <strong>OpenAI's {@code codex}</strong> 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.
|
||||||
|
*
|
||||||
|
* <p>The divergences, all confined to {@link #buildLaunch}:
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>No subscription boundary.</strong> Codex authenticates through its own
|
||||||
|
* {@code CODEX_HOME} credentials and has no {@code ANTHROPIC_BASE_URL} seam, so there is no
|
||||||
|
* {@link dev.ltms.bridged.guard.SubscriptionGuard} — the guard is a Claude-private concern,
|
||||||
|
* not part of the SPI. This asymmetry is deliberate and safe for the same reason opencode's is:
|
||||||
|
* the guard exists to stop a worker borrowing the primary's Anthropic subscription, and a
|
||||||
|
* codex process has no Anthropic credential path at all, so nothing can leak the subscription.
|
||||||
|
* The bridge injects no {@code ANTHROPIC_*}/{@code CLAUDE_*} variable and reads none.</li>
|
||||||
|
* <li><strong>Isolated home.</strong> Codex reads everything from {@code CODEX_HOME}. The
|
||||||
|
* {@link CodexHome} seam provisions a fresh, isolated directory (config, reply charter,
|
||||||
|
* credentials — none of which Codex can take as a launch flag) and the worker is pointed at it
|
||||||
|
* with {@code CODEX_HOME}, so a peer never inherits the operator's own {@code ~/.codex}.</li>
|
||||||
|
* <li><strong>{@code --approve-for-me}</strong> is mandatory: without it, every MCP tool call from
|
||||||
|
* the peer returns "user cancelled MCP tool call" and the peer can never call
|
||||||
|
* {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox
|
||||||
|
* on — deliberately not {@code --dangerously-bypass-approvals-and-sandbox}.</li>
|
||||||
|
* <li><strong>Flags, not env/files.</strong> the model ({@code -m}), the bridge MCP override
|
||||||
|
* ({@code -c mcp_servers.bridged.url=...}), and the bridge bearer-token variable
|
||||||
|
* ({@code --bearer-token-env-var}) are all command-line.</li>
|
||||||
|
* <li><strong>{@code codex} name prefix</strong> so reap matches {@code codex-*} panes and never
|
||||||
|
* another adapter's.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||||
|
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||||
|
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||||
|
Function<String, String> env,
|
||||||
|
long spawnReadyTimeoutMs,
|
||||||
|
LongSupplier nowMillis, Runnable sleeper) {
|
||||||
|
super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
|
||||||
|
spawnReadyTimeoutMs, nowMillis, sleeper);
|
||||||
|
this.codexHome = codexHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>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<String, String> 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.
|
||||||
|
*
|
||||||
|
* <p>{@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<String> argvFor(BridgedConfig.Worker cfg) {
|
||||||
|
List<String> 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<Capability> capabilities() {
|
||||||
|
Set<Capability> 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 <em>different</em> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> lastStart(FakeHerdr herdr) {
|
||||||
|
return (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<String, String> startEnv(FakeHerdr herdr) {
|
||||||
|
Map<String, String> env =
|
||||||
|
(Map<String, String>) ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("env");
|
||||||
|
return env == null ? Map.of() : env;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<String> startArgs(FakeHerdr herdr) {
|
||||||
|
return (List<String>) 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<String> 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<String> 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<String, String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user