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:
+ *
+ * - {@code config.toml} registering the bridge as a streamable-HTTP MCP server (with the
+ * profile's bearer-token env var, when one is configured);
+ * - {@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;
+ * - {@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.
+ *
+ *
+ * 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")));
+ }
+}