diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/DefaultCodexHome.java b/bridged/src/main/java/dev/ltms/bridged/worker/DefaultCodexHome.java new file mode 100644 index 0000000..eddb0d5 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/DefaultCodexHome.java @@ -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). + * + *

Codex reads everything 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: + *

+ * + *

The credential is copied, 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 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 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(); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/DefaultCodexHomeTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/DefaultCodexHomeTest.java new file mode 100644 index 0000000..4dc4a77 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/DefaultCodexHomeTest.java @@ -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 copy 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 snapshot(Path dir) throws IOException { + Map 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 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"))); + } +}