From 0114bd1fa7271254f10772626347087c27d8e93d Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 13 Aug 2026 19:30:19 +0200 Subject: [PATCH] CB-543: neutralize tracked opencode.json and .autoenv in provisioned worktrees --- .../ltms/bridged/session/GitWorktrees.java | 81 +++++++++++++--- .../bridged/session/GitWorktreesTest.java | 97 ++++++++++++++++++- 2 files changed, 160 insertions(+), 18 deletions(-) diff --git a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java index 9bf58f4..7387ca5 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java @@ -27,12 +27,47 @@ public final class GitWorktrees implements Worktrees { private static final Logger log = LoggerFactory.getLogger(GitWorktrees.class); - /** Project-level MCP config. Present in the repo, so every worktree checks the primary's out. */ + /** Project-level MCP config. Present in the repo, so every worktree would otherwise inherit the + * primary's IDE server mounts (a CB-523 worker edited the primary checkout; see the isolation + * javadoc). Neutralized unconditionally. */ private static final String MCP_CONFIG = ".mcp.json"; - /** What {@link #isolateToolSurface} writes: a valid, explicitly empty server map. */ + /** What {@link #isolateToolSurface} writes for {@code .mcp.json}: a valid, explicitly empty server map. */ private static final String NEUTRAL_MCP_CONFIG = "{\n \"mcpServers\": {}\n}\n"; + /** OpenCode's repo-level config. Tracked here, so it lands in every worktree; it carries + * {@code {file:.secrets/...}} references to gitignored secrets that never reach a worktree, and + * opencode refuses to start on a dangling reference — so it is neutralized and the worker gets + * only the config its launcher writes via {@code OPENCODE_CONFIG}. */ + private static final String OPENCODE_CONFIG = "opencode.json"; + + /** What {@link #isolateToolSurface} writes for {@code opencode.json}: a valid, empty JSON object. */ + private static final String NEUTRAL_OPENCODE_CONFIG = "{}\n"; + + /** Autoenv's repo-level config. Not tracked today, but re-landing it must stay safe: autoenv + * authorizes by path, so a fresh worktree path is always unauthorized and its interactive prompt + * would block every spawn — neutralize it so it can never be committed. */ + private static final String AUTOENV_CONFIG = ".autoenv"; + + /** What {@link #isolateToolSurface} writes for {@code .autoenv}: a valid, empty env file. */ + private static final String NEUTRAL_AUTOENV_CONFIG = ""; + + /** + * A tracked project config that is hostile in a provisioned worktree, and what to replace it + * with. {@link #file} is the repo-relative path; {@link #stub} is a neutral but VALID payload for + * that file's format — a malformed stub would only trade one crash for another; + * {@link #createIfAbsent} keeps {@code .mcp.json}'s long-standing behaviour of writing its stub + * even when the repo carries no such file, whereas the others are only touched when present. + */ + private record WorktreeHostileConfig(String file, String stub, boolean createIfAbsent) {} + + /** The worktree-hostile configs neutralized in every provisioned worktree, in order. */ + private static final List WORKTREE_HOSTILE_CONFIGS = List.of( + new WorktreeHostileConfig(MCP_CONFIG, NEUTRAL_MCP_CONFIG, true), + new WorktreeHostileConfig(OPENCODE_CONFIG, NEUTRAL_OPENCODE_CONFIG, false), + new WorktreeHostileConfig(AUTOENV_CONFIG, NEUTRAL_AUTOENV_CONFIG, false) + ); + private final String configuredRoot; private final SecureRandom random = new SecureRandom(); private final AtomicLong seq = new AtomicLong(); @@ -66,8 +101,9 @@ public final class GitWorktrees implements Worktrees { } /** - * Neutralize the worktree's project MCP config so a worker inherits only the tools its launcher - * mounts (the bridge, via {@code --mcp-config}) — never the primary's. + * Neutralize the worktree's worktree-hostile project configs so a worker inherits only the tools + * and environment its launcher mounts (the bridge via {@code --mcp-config}, the opencode config + * via {@code OPENCODE_CONFIG}) — never the primary's. * *

This is unconditional, and it is not the same job as the parity overlay. The repo's own * committed {@code .mcp.json} declares the primary's IDE servers, so a fresh checkout mounts them @@ -75,25 +111,42 @@ public final class GitWorktrees implements Worktrees { * through tools bound to the primary's IntelliJ project, which silently hands it absolute * paths outside its own worktree. That is not hypothetical: a CB-523 worker made all 59 of its * edits in the primary checkout while compiling its worktree, so every build it ran was of code - * that did not contain its changes. + * that did not contain its changes. {@code opencode.json} is the same trap one tool over — tracked, + * so it lands in every worktree, referencing gitignored {@code .secrets/} files that never do, and + * opencode refuses to start on the dangling reference. {@code .autoenv} extends the principle to a + * config that is not tracked today: autoenv authorizes by path, so a fresh worktree path is always + * unauthorized and its interactive prompt would block every spawn, so re-landing one must be safe. * - *

Writing an empty server map (rather than deleting the file) keeps a project-level - * {@code .mcp.json} present and explicit, and the {@code --skip-worktree} bit keeps the - * neutralized copy from ever showing up as a local modification the worker might commit. + *

Where a config exists it is replaced by a valid neutral stub (an explicitly empty + * map/object, or an empty env file — never a deletion, which would still let a later + * {@code git checkout} restore the hostile copy). The {@code --skip-worktree} bit keeps the + * neutralized copy from ever showing up as a local modification the worker might commit. A config + * the repo does not carry is skipped silently — no stub is invented for a file the repo does not + * have, and one missing file must never fail provisioning. */ private void isolateToolSurface(String worktreePath) { Path root = Path.of(worktreePath).toAbsolutePath().normalize(); - Path mcp = root.resolve(MCP_CONFIG); + for (WorktreeHostileConfig cfg : WORKTREE_HOSTILE_CONFIGS) { + neutralize(root, worktreePath, cfg); + } + } + + private void neutralize(Path root, String worktreePath, WorktreeHostileConfig cfg) { + Path target = root.resolve(cfg.file()); + if (!Files.exists(target) && !cfg.createIfAbsent()) { + log.debug("{} absent in the worktree — skipping (repo does not carry it)", cfg.file()); + return; + } try { - Files.writeString(mcp, NEUTRAL_MCP_CONFIG); + Files.writeString(target, cfg.stub()); } catch (IOException e) { - throw new WorktreeException("cannot neutralize " + MCP_CONFIG + " in the worktree: " + throw new WorktreeException("cannot neutralize " + cfg.file() + " in the worktree: " + e.getMessage(), e); } - if (isTracked(root, MCP_CONFIG)) { - exec("git", "-C", worktreePath, "update-index", "--skip-worktree", MCP_CONFIG); + if (isTracked(root, cfg.file())) { + exec("git", "-C", worktreePath, "update-index", "--skip-worktree", cfg.file()); } - log.debug("neutralized {} — worker tool surface is launcher-mounted only", MCP_CONFIG); + log.debug("neutralized {} — worker tool surface is launcher-mounted only", cfg.file()); } @Override diff --git a/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java b/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java index 126a25b..3ebd8c2 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java @@ -26,6 +26,18 @@ class GitWorktreesTest { } """; + /** An opencode config carrying a {@code {file:.secrets/...}} reference — CB-543's crash repro. */ + private static final String OPENCODE_WITH_FILE_REF = """ + { + "env": { + "CONTEXT7_TOKEN": "{file:.secrets/context7-token}" + } + } + """; + + /** A non-empty autoenv file — the form that would prompt for authorization in a worktree. */ + private static final String AUTOENV_WITH_DIRECTIVE = "export HELLO=world\n"; + private static Path initRepo(Path dir) throws Exception { Files.createDirectories(dir); git(dir, "init", "-q", "-b", "main"); @@ -47,9 +59,9 @@ class GitWorktreesTest { assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out); } - /** Pending changes to {@code .mcp.json} in {@code cwd}, empty when git considers it unmodified. */ - private static String mcpStatus(Path cwd) throws Exception { - Process p = new ProcessBuilder("git", "status", "--porcelain", "--", ".mcp.json") + /** Pending changes to {@code file} in {@code cwd}, empty when git considers it unmodified. */ + private static String status(Path cwd, String file) throws Exception { + Process p = new ProcessBuilder("git", "status", "--porcelain", "--", file) .directory(cwd.toFile()).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()); assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git status timed out"); @@ -82,7 +94,7 @@ class GitWorktreesTest { String wt = new GitWorktrees(tmp.resolve("wts").toString()) .add(repo.toString(), "cb-525-b", "HEAD"); - assertEquals("", mcpStatus(Path.of(wt)), + assertEquals("", status(Path.of(wt), ".mcp.json"), "the neutralized .mcp.json shows as modified — --skip-worktree did not take"); } @@ -116,4 +128,81 @@ class GitWorktreesTest { String body = Files.readString(Path.of(wt).resolve(".mcp.json")); assertTrue(body.replaceAll("\\s+", "").contains("\"mcpServers\":{}"), body); } + + /** + * CB-543's repro: a tracked {@code opencode.json} carries a {@code {file:.secrets/...}} reference + * to a gitignored secret that never reaches a worktree, and opencode refuses to start on it. The + * worktree's copy must be neutralized and hidden like {@code .mcp.json}. + */ + @Test + void aTrackedOpencodeConfigIsNeutralizedAndHidden(@TempDir Path tmp) throws Exception { + Path repo = tmp.resolve("repo"); + Files.createDirectories(repo); + git(repo, "init", "-q", "-b", "main"); + git(repo, "config", "user.email", "test@example.invalid"); + git(repo, "config", "user.name", "Test"); + Files.writeString(repo.resolve(".mcp.json"), WITH_SERVERS); + Files.writeString(repo.resolve("opencode.json"), OPENCODE_WITH_FILE_REF); + Files.writeString(repo.resolve("README.md"), "seed\n"); + git(repo, "add", ".mcp.json", "opencode.json", "README.md"); + git(repo, "commit", "-q", "-m", "seed"); + + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "cb-543-a", "HEAD"); + + String body = Files.readString(Path.of(wt).resolve("opencode.json")); + assertFalse(body.contains(".secrets"), + "worktree kept a dangling {file:...} secret reference:\n" + body); + assertEquals("{}", body.replaceAll("\\s+", ""), + "expected an empty JSON object stub, got:\n" + body); + assertEquals("", status(Path.of(wt), "opencode.json"), + "the neutralized opencode.json shows as modified — --skip-worktree did not take"); + } + + /** A config the repo does not carry must be skipped — no stub invented, provisioning still succeeds. */ + @Test + void anAbsentConfigIsSkippedWithoutError(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); // only .mcp.json + README are committed + + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "cb-543-b", "HEAD"); + + assertFalse(Files.exists(Path.of(wt).resolve("opencode.json")), + "a stub was invented for a config the repo does not carry"); + assertFalse(Files.exists(Path.of(wt).resolve(".autoenv")), + "a stub was invented for a config the repo does not carry"); + // .mcp.json's long-standing create-always behaviour must be unchanged. + assertTrue(Files.exists(Path.of(wt).resolve(".mcp.json")), ".mcp.json stub was dropped"); + } + + /** All three protected configs are covered: each one present in a worktree is neutralized and hidden. */ + @Test + void allThreeConfigsAreNeutralizedWhenPresent(@TempDir Path tmp) throws Exception { + Path repo = tmp.resolve("repo"); + Files.createDirectories(repo); + git(repo, "init", "-q", "-b", "main"); + git(repo, "config", "user.email", "test@example.invalid"); + git(repo, "config", "user.name", "Test"); + Files.writeString(repo.resolve(".mcp.json"), WITH_SERVERS); + Files.writeString(repo.resolve("opencode.json"), OPENCODE_WITH_FILE_REF); + Files.writeString(repo.resolve(".autoenv"), AUTOENV_WITH_DIRECTIVE); + Files.writeString(repo.resolve("README.md"), "seed\n"); + git(repo, "add", ".mcp.json", "opencode.json", ".autoenv", "README.md"); + git(repo, "commit", "-q", "-m", "seed"); + + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "cb-543-c", "HEAD"); + + assertTrue(Files.readString(Path.of(wt).resolve(".mcp.json")) + .replaceAll("\\s+", "").contains("\"mcpServers\":{}"), + ".mcp.json was not neutralized"); + assertEquals("{}", Files.readString(Path.of(wt).resolve("opencode.json")).replaceAll("\\s+", ""), + "opencode.json was not neutralized"); + assertEquals("", Files.readString(Path.of(wt).resolve(".autoenv")), + ".autoenv was not neutralized"); + + assertEquals("", status(Path.of(wt), ".mcp.json"), ".mcp.json still shows as modified"); + assertEquals("", status(Path.of(wt), "opencode.json"), "opencode.json still shows as modified"); + assertEquals("", status(Path.of(wt), ".autoenv"), ".autoenv still shows as modified"); + } } -- 2.52.0