Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f5b3a1fd6 |
@@ -1,192 +0,0 @@
|
||||
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,205 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The default {@link CodexHome} provisioner (CB-528): builds a fresh, isolated {@code CODEX_HOME}
|
||||
* for one Codex peer under a configurable root (default the JVM temp dir, mirroring how
|
||||
* {@link OpenCodeLauncher} picks its config root).
|
||||
*
|
||||
* <p>Codex reads <em>everything</em> from {@code CODEX_HOME} — config, credentials, sessions,
|
||||
* skills, plugins, state — so a peer must never be pointed at the operator's own {@code ~/.codex}.
|
||||
* Provisioning therefore assembles, in one throwaway directory:
|
||||
* <ul>
|
||||
* <li>{@code config.toml} registering the bridge as a streamable-HTTP MCP server (with the
|
||||
* profile's bearer-token env var, when one is configured);</li>
|
||||
* <li>{@code AGENTS.md} carrying the reply charter — codex has no {@code --append-system-prompt},
|
||||
* so the standing instruction has to reach the peer as a file;</li>
|
||||
* <li>{@code auth.json} copied from the operator's real codex home, since a fresh home has no
|
||||
* credential and codex then fails closed with an opaque {@code 401} mid-run.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The credential is <em>copied</em>, never symlinked: a peer that could write through a symlink
|
||||
* would be able to modify the operator's real {@code auth.json}. Each peer gets its own on-disk
|
||||
* copy, so nothing outside the provisioned home is ever touched on write.
|
||||
*/
|
||||
public final class DefaultCodexHome implements CodexHome {
|
||||
|
||||
/** Writer for the generated {@code AGENTS.md} (kept as a field for unit-test inspection). */
|
||||
static final String AGENTS_HEADER = "# Standing instruction\n\n";
|
||||
|
||||
/** Root under which per-peer Codex homes are created (injectable for tests). */
|
||||
private final Path root;
|
||||
|
||||
/** Host env lookup used to resolve the operator's codex home (injectable for tests). */
|
||||
private final Function<String, String> env;
|
||||
|
||||
/** Production constructor — homes land under the JVM temp dir and the operator's credentials
|
||||
* are resolved from the real environment ({@code CODEX_HOME}, else {@code ~/.codex}). */
|
||||
public DefaultCodexHome() {
|
||||
this(Path.of(System.getProperty("java.io.tmpdir")), System::getenv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full testability constructor: an injectable {@code root} (so tests never touch the JVM temp
|
||||
* dir's real real estate) and an injectable env lookup (so the credential source can be pointed
|
||||
* at a temp dir instead of the operator's real {@code ~/.codex}).
|
||||
*
|
||||
* @param root directory under which per-peer Codex homes are created (must exist)
|
||||
* @param env host environment lookup, used to resolve the operator's codex home
|
||||
*/
|
||||
public DefaultCodexHome(Path root, Function<String, String> env) {
|
||||
this.root = root;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
/** Standing instruction written to {@code AGENTS.md}. Adapted from
|
||||
* {@link OpenCodeLauncher#REPLY_CHARTER}: codex has no {@code --append-system-prompt}, so the
|
||||
* only way a codex peer receives the reply contract is as a file in its home. The substance must
|
||||
* survive — every message arrives through the bridge, terminal text reaches nobody, so the turn
|
||||
* MUST end with exactly one {@code bridge_reply} carrying the complete answer. Kept as a single
|
||||
* logical line so it reads the same way the opencode charter does (Markdown wraps it on render). */
|
||||
private static final String REPLY_CHARTER =
|
||||
"You are an off-subscription worker in the claude-bridge fleet, running under codex. "
|
||||
+ "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.";
|
||||
|
||||
@Override
|
||||
public Path provision(BridgedConfig.Worker cfg) {
|
||||
Path home;
|
||||
try {
|
||||
home = Files.createTempDirectory(root, "bridged-codex-");
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot create Codex home for profile " + cfg.profile(), e);
|
||||
}
|
||||
try {
|
||||
if (cfg.hasMcp()) {
|
||||
writeConfig(home, cfg);
|
||||
}
|
||||
writeCharter(home);
|
||||
provisionCredential(home);
|
||||
return home;
|
||||
} catch (RuntimeException e) {
|
||||
// Every failure below is already a RuntimeException (wrapped IOException, or the missing-
|
||||
// credential IllegalStateException). Best-effort remove the partially-built home so a
|
||||
// failed spawn does not leak a temp dir, then rethrow.
|
||||
try {
|
||||
release(home);
|
||||
} catch (RuntimeException ignored) {
|
||||
// A cleanup failure must not mask the provisioning failure that got us here.
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release(Path home) {
|
||||
if (home == null || !Files.exists(home)) {
|
||||
return; // already released, or never provisioned — teardown races teardown
|
||||
}
|
||||
try (var stream = Files.walk(home)) {
|
||||
// Delete deepest-first so directories are empty when their turn comes.
|
||||
stream.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot remove " + p, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot walk " + home + " for release", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write {@code config.toml} registering the bridge as a streamable-HTTP MCP server. */
|
||||
private void writeConfig(Path home, BridgedConfig.Worker cfg) {
|
||||
StringBuilder sb = new StringBuilder("[mcp_servers.bridged]\n");
|
||||
sb.append("url = ").append(tomlString(cfg.mcpUrl())).append('\n');
|
||||
if (cfg.tokenEnv() != null && !cfg.tokenEnv().isBlank()) {
|
||||
sb.append("bearer_token_env_var = ").append(tomlString(cfg.tokenEnv())).append('\n');
|
||||
}
|
||||
writeString(home.resolve("config.toml"), sb.toString());
|
||||
}
|
||||
|
||||
/** Write {@code AGENTS.md} carrying the reply charter (always — the standing instruction applies
|
||||
* to every codex peer, not only those that mount the bridge). */
|
||||
private void writeCharter(Path home) {
|
||||
writeString(home.resolve("AGENTS.md"), AGENTS_HEADER + REPLY_CHARTER + "\n");
|
||||
}
|
||||
|
||||
/** Copy {@code auth.json} from the operator's real codex home into the fresh home. A freshly
|
||||
* created home has no credential, and codex then fails closed with an opaque {@code 401} mid-run
|
||||
* rather than a spawn error — so a missing source is thrown from here, loudly, naming the path. */
|
||||
private void provisionCredential(Path home) {
|
||||
Path source = credentialSource();
|
||||
if (!Files.isRegularFile(source)) {
|
||||
throw new IllegalStateException("no Codex credential for the peer: looked for "
|
||||
+ source + " but it does not exist. A fresh CODEX_HOME has no credentials and the "
|
||||
+ "peer would otherwise fail with an opaque 401 Unauthorized mid-run; provision one "
|
||||
+ "at that path or mount the operator's codex home before spawning codex peers.");
|
||||
}
|
||||
try {
|
||||
Files.copy(source, home.resolve("auth.json"), StandardCopyOption.COPY_ATTRIBUTES);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot copy Codex credential from " + source, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator's codex credential source: {@code CODEX_HOME}/auth.json when the env var is set,
|
||||
* else {@code ~/.codex}/auth.json. */
|
||||
private Path credentialSource() {
|
||||
String codexHome = env.apply("CODEX_HOME");
|
||||
Path homeDir = (codexHome == null || codexHome.isBlank())
|
||||
? Path.of(System.getProperty("user.home"), ".codex")
|
||||
: Path.of(codexHome);
|
||||
return homeDir.resolve("auth.json");
|
||||
}
|
||||
|
||||
private static void writeString(Path file, String content) {
|
||||
try {
|
||||
Files.writeString(file, content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot write " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render {@code s} as a TOML basic string, escaping what TOML requires. Values here are
|
||||
* operator- or config-supplied (a URL, an env-var name), so escaping must be real — a malformed
|
||||
* config.toml makes codex fail in a way that looks like a network problem. */
|
||||
private static String tomlString(String s) {
|
||||
StringBuilder sb = new StringBuilder("\"");
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '"' -> sb.append("\\\"");
|
||||
case '\n' -> sb.append("\\n");
|
||||
case '\r' -> sb.append("\\r");
|
||||
case '\t' -> sb.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04X", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.append('"').toString();
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
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.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The {@link CodexHome} provisioner: a fresh, isolated {@code CODEX_HOME} per peer, holding the
|
||||
* bridge MCP config, the reply charter ({@code AGENTS.md} — the only way a codex peer receives a
|
||||
* standing instruction), and a <em>copy</em> of the operator's credential. The credential source is
|
||||
* always injected (via {@code CODEX_HOME}) so these tests never read the real {@code ~/.codex}, and
|
||||
* the operator's home is asserted byte-for-byte untouched after provisioning.
|
||||
*/
|
||||
class DefaultCodexHomeTest {
|
||||
|
||||
private static final String MCP_URL = "http://127.0.0.1:8765/mcp";
|
||||
|
||||
/** A codex-profile worker; {@code tokenEnv} may be {@code null} to exercise the record default. */
|
||||
private static BridgedConfig.Worker codexCfg(String mcpUrl, String tokenEnv) {
|
||||
return new BridgedConfig.Worker("codex-peer", null, null, null, tokenEnv, List.of("codex"),
|
||||
"tab", "bridged-workers", "codex: {profile} #{n}", mcpUrl, null, null, null, null,
|
||||
BridgedConfig.Worker.KIND_CODEX);
|
||||
}
|
||||
|
||||
/** A provisioner whose homes land under a temp root and whose env maps {@code CODEX_HOME} to a
|
||||
* temp operator home — so every assertion runs against temp dirs, never the real {@code ~/.codex}. */
|
||||
private static DefaultCodexHome service(Path root, String operatorCodexHome) {
|
||||
return new DefaultCodexHome(root, key -> "CODEX_HOME".equals(key) ? operatorCodexHome : null);
|
||||
}
|
||||
|
||||
private static String read(Path home, String name) throws IOException {
|
||||
return Files.readString(home.resolve(name));
|
||||
}
|
||||
|
||||
/** Snapshot of a directory's contents (relative paths + hashes) — for the isolation assertion. */
|
||||
private static Map<String, String> snapshot(Path dir) throws IOException {
|
||||
Map<String, String> out = new java.util.HashMap<>();
|
||||
try (var stream = Files.walk(dir)) {
|
||||
for (Path p : stream.filter(Files::isRegularFile).toList()) {
|
||||
out.put(dir.relativize(p).toString(), hex(Files.readAllBytes(p)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String hex(byte[] b) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte x : b) {
|
||||
sb.append(String.format("%02x", x));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void configTomlRegistersBridgeWithExactUrl(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(MCP_URL, "MY_BRIDGED_TOKEN"));
|
||||
|
||||
String config = read(home, "config.toml");
|
||||
assertTrue(config.contains("[mcp_servers.bridged]"), config);
|
||||
assertTrue(config.contains("url = \"" + MCP_URL + "\""), config);
|
||||
// The schema is the one codex itself writes for `codex mcp add --url ... --bearer-token-env-var`.
|
||||
assertTrue(config.contains("bearer_token_env_var = \"MY_BRIDGED_TOKEN\""), config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noConfigTomlWhenMcpUrlUnset(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(null, "MY_BRIDGED_TOKEN"));
|
||||
|
||||
assertFalse(Files.exists(home.resolve("config.toml")));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bearer-token key is emitted only when {@code tokenEnv} is set. Note the {@code Worker}
|
||||
* record normalises a blank {@code tokenEnv} to {@code "BRIDGED_WORKER_TOKEN"}
|
||||
* ({@link BridgedConfig.Worker} compact constructor), so a truly blank token env is not
|
||||
* constructible through the public API; the negative is therefore exercised by the no-MCP case
|
||||
* above (no {@code config.toml}, hence no key), and here we pin the emitted value to the
|
||||
* configured env-var name — including that a {@code null} tokenEnv resolves to the record default.
|
||||
*/
|
||||
@Test
|
||||
void bearerKeyNamesConfiguredTokenEnvAndDefaultsWhenUnset(@TempDir Path root, @TempDir Path operator)
|
||||
throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home1 = svc.provision(codexCfg(MCP_URL, "CUSTOM_TOKEN"));
|
||||
assertTrue(read(home1, "config.toml").contains("bearer_token_env_var = \"CUSTOM_TOKEN\""));
|
||||
|
||||
// null tokenEnv -> record default BRIDGED_WORKER_TOKEN, which is itself a meaningful name.
|
||||
Path home2 = svc.provision(codexCfg(MCP_URL, null));
|
||||
assertTrue(read(home2, "config.toml").contains("bearer_token_env_var = \"BRIDGED_WORKER_TOKEN\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentsMdCarriesTheReplyCharter(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(MCP_URL, "MY_BRIDGED_TOKEN"));
|
||||
|
||||
String agents = read(home, "AGENTS.md");
|
||||
assertTrue(agents.contains("bridge_reply"), agents);
|
||||
assertTrue(agents.contains("running under codex"), agents);
|
||||
}
|
||||
|
||||
@Test
|
||||
void credentialCopiedFromSourceWhenPresent(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
String auth = "{\"account\":\"dai.ha\",\"token\":\"secret-copy\"}";
|
||||
Files.writeString(operator.resolve("auth.json"), auth);
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(MCP_URL, "MY_BRIDGED_TOKEN"));
|
||||
|
||||
assertTrue(Files.exists(home.resolve("auth.json")));
|
||||
assertEquals(auth, read(home, "auth.json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void provisionThrowsNamingPathWhenNoCredentialSource(@TempDir Path root, @TempDir Path operator) {
|
||||
// operator home exists but holds no auth.json.
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path missing = operator.resolve("auth.json");
|
||||
IllegalStateException ex =
|
||||
assertThrows(IllegalStateException.class, () -> svc.provision(codexCfg(MCP_URL, "TOK")));
|
||||
|
||||
assertTrue(ex.getMessage().contains(missing.toString()), ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("401"), ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRemovesHomeAndIsIdempotent(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(MCP_URL, "MY_BRIDGED_TOKEN"));
|
||||
assertTrue(Files.exists(home));
|
||||
|
||||
svc.release(home);
|
||||
assertFalse(Files.exists(home));
|
||||
|
||||
// second release of the same path must not throw (teardown races teardown).
|
||||
svc.release(home);
|
||||
// releasing a never-provisioned path must not throw either.
|
||||
svc.release(root.resolve("bridged-codex-never-made"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolation: provisioning writes only under the returned home. The operator's codex home (here a
|
||||
* temp dir, the same code path that production resolves to {@code ~/.codex}) must be byte-for-byte
|
||||
* untouched afterwards — no leaked {@code config.toml}/AGENTS.md, and its {@code auth.json} only
|
||||
* ever read, never written. This is a real assertion: a future change that starts writing into
|
||||
* the operator's home fails here.
|
||||
*/
|
||||
@Test
|
||||
void nothingWrittenToTheOperatorsCodexHome(@TempDir Path root, @TempDir Path operator) throws IOException {
|
||||
Files.writeString(operator.resolve("auth.json"), "{\"token\":\"x\"}");
|
||||
Map<String, String> before = snapshot(operator);
|
||||
DefaultCodexHome svc = service(root, operator.toString());
|
||||
|
||||
Path home = svc.provision(codexCfg(MCP_URL, "MY_BRIDGED_TOKEN"));
|
||||
|
||||
assertEquals(before, snapshot(operator), "operator codex home must be untouched");
|
||||
// The peer's artifacts live only inside the returned home, never in the operator's home.
|
||||
assertFalse(Files.exists(operator.resolve("config.toml")));
|
||||
assertFalse(Files.exists(operator.resolve("AGENTS.md")));
|
||||
// And the returned home carries all three artifacts.
|
||||
assertTrue(Files.exists(home.resolve("auth.json")));
|
||||
assertTrue(Files.exists(home.resolve("config.toml")));
|
||||
assertTrue(Files.exists(home.resolve("AGENTS.md")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user