diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index 64b5101..f93ae1d 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -19,6 +19,8 @@ import dev.ltms.fleet.peer.PeerHandle; import dev.ltms.fleet.peer.PeerLauncher; import dev.ltms.fleet.peer.PeerUnreachableException; import dev.ltms.fleet.peer.SpawnRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.slf4j.LoggerFactory; @@ -32,6 +34,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -147,18 +150,25 @@ class ClaudeCodeLauncherTest { // every ide_* call to the member's own worktree via the charter. /** A profile carrying an ideMcpUrl (plus optional bridge mcpUrl and cwd). ideMcpUrl is the last record component. */ - private FleetConfig.Profile ideProfile(String mcpUrl, String ideMcpUrl, String cwd) { + /** + * {@code configDir} is first and mandatory on purpose (fleetd #258). A profile that sets no + * {@code configDir} sends {@code seedTrustDialog}'s write to the operator's real + * {@code ~/.claude.json}, and the fleetd #149 gate does not stop that when the fixture's + * {@code cwd} is worktree-shaped — which every IDE-overlay fixture's is. Pass a {@code @TempDir} + * whenever {@code cwd} has a {@code .git} FILE; {@code null} is only safe when it does not. + */ + private FleetConfig.Profile ideProfile(String configDir, String mcpUrl, String ideMcpUrl, String cwd) { return new FleetConfig.Profile( - "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + "ltms-local", "http://gx00.gw:8000", "coder", configDir, "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", "w #{n}", mcpUrl, cwd, null, null, null, null, null, null, null, null, null, null, ideMcpUrl); } /** As {@link #ideProfile} but carrying the CB-634 auto-open fields (module subdir + open command). */ - private FleetConfig.Profile ideProfileModule(String ideMcpUrl, String cwd, String ideProjectDir, - String ideOpenCommand) { + private FleetConfig.Profile ideProfileModule(String configDir, String ideMcpUrl, String cwd, + String ideProjectDir, String ideOpenCommand) { return new FleetConfig.Profile( - "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + "ltms-local", "http://gx00.gw:8000", "coder", configDir, "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", "w #{n}", null, cwd, null, null, null, null, null, null, null, null, null, null, ideMcpUrl, ideProjectDir, ideOpenCommand); } @@ -171,7 +181,7 @@ class ClaudeCodeLauncherTest { @Test void mountsIdeMcpAsSecondServerWhenIdeMcpUrlSet() { FakeHerdr herdr = new FakeHerdr(); - launcher(herdr, ideProfile("http://127.0.0.1:8765/mcp", + launcher(herdr, ideProfile(null, "http://127.0.0.1:8765/mcp", "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn(); List args = spawnedArgs(herdr); @@ -186,7 +196,8 @@ class ClaudeCodeLauncherTest { @Test void ideMcpUrlAloneStillEmitsTheMount() { FakeHerdr herdr = new FakeHerdr(); - launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn(); + launcher(herdr, ideProfile(null, null, "http://127.0.0.1:29170/index-mcp/streamable-http", null)) + .spawn(); List args = spawnedArgs(herdr); assertTrue(args.contains("--mcp-config"), @@ -201,7 +212,7 @@ class ClaudeCodeLauncherTest { FakeHerdr herdr = new FakeHerdr(); String roleCharter = "You review changes."; String worktree = "/tmp/.fleet-worktrees/rev-1"; - FleetConfig.Profile cfg = ideProfile("http://127.0.0.1:8765/mcp", + FleetConfig.Profile cfg = ideProfile(null, "http://127.0.0.1:8765/mcp", "http://127.0.0.1:29170/index-mcp/streamable-http", worktree); ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null, @@ -226,6 +237,62 @@ class ClaudeCodeLauncherTest { }, "the --append-system-prompt-file path must be a readable file"); } + // ---- fleetd #258: no fixture in this class may seed the operator's real ~/.claude.json ---- + // + // seedTrustDialog writes /.claude.json, or ~/.claude.json when the profile sets no + // configDir. The fleetd #149 gate (isProvisionedWorktree) closes the case where a fixture leaves + // cwd unset and it falls back to user.dir. It does NOT close the case where a fixture builds a + // worktree-shaped @TempDir on purpose — the gate opens, and a null configDir still points the + // write at the real home. Two IDE-overlay fixtures did exactly that on EVERY run; by 2026-09-03 + // the operator's ~/.claude.json carried 116 dead JUnit temp paths, none of which still existed. + // + // DIFFERENTIAL, not absolute: it snapshots the temp-dir project keys already present and fails + // only on keys this class ADDS. An absolute check would fail on any host still carrying the + // historical entries, and a check that fails for a reason nobody can fix gets deleted, not fixed. + + private static Set tempProjectKeysBefore; + + @BeforeAll + static void snapshotTempProjectKeysInTheDefaultClaudeJson() { + tempProjectKeysBefore = tempProjectKeysInDefaultClaudeJson(); + } + + @AfterAll + static void noFixtureSeededTheDefaultClaudeJson() { + Set added = new TreeSet<>(tempProjectKeysInDefaultClaudeJson()); + added.removeAll(tempProjectKeysBefore); + assertTrue(added.isEmpty(), + "a fixture in this class seeded the DEFAULT .claude.json (the operator's real file " + + "when user.home is not redirected) with " + added.size() + " temp-dir " + + "project entry/entries: " + added + ". Give that fixture's profile a " + + "@TempDir configDir — see ideProfile's javadoc."); + } + + /** + * Project keys under the JVM temp dir in {@code /.claude.json}, or an empty set when + * the file is absent or unreadable. Only key NAMES are read; nothing in the operator's file is + * copied, asserted on, or written back. + */ + private static Set tempProjectKeysInDefaultClaudeJson() { + Set keys = new TreeSet<>(); + Path target = Path.of(System.getProperty("user.home"), ".claude.json"); + if (!Files.isRegularFile(target)) { + return keys; + } + String tmp = System.getProperty("java.io.tmpdir"); + try { + JsonNode projects = new ObjectMapper().readTree(target.toFile()).path("projects"); + projects.fieldNames().forEachRemaining(name -> { + if (name.startsWith(tmp) || name.contains("/junit-")) { + keys.add(name); + } + }); + } catch (IOException e) { + return keys; // unreadable file proves nothing either way + } + return keys; + } + // CB-634: the IDE guidance is delivered as a CLAUDE.local.md overlay (written only into a // provisioned worktree — cwd with a `.git` FILE) and registered in the repository's COMMON // info/exclude. git reads a worktree's excludes from the common dir, not the per-worktree @@ -241,8 +308,8 @@ class ClaudeCodeLauncherTest { Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir); FakeHerdr herdr = new FakeHerdr(); - launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", - worktree.toString())).spawn(); + launcher(herdr, ideProfile(root.toString(), null, + "http://127.0.0.1:29170/index-mcp/streamable-http", worktree.toString())).spawn(); Path overlay = worktree.resolve("CLAUDE.local.md"); assertTrue(Files.exists(overlay), "the overlay is written beside the project's CLAUDE.md"); @@ -268,8 +335,9 @@ class ClaudeCodeLauncherTest { FakeHerdr herdr = new FakeHerdr(); // ideProjectDir "fleetd" ⇒ the pin is /fleetd, not . - launcher(herdr, ideProfileModule("http://127.0.0.1:29170/index-mcp/streamable-http", - worktree.toString(), "fleetd", null)).spawn(); + launcher(herdr, ideProfileModule(root.toString(), + "http://127.0.0.1:29170/index-mcp/streamable-http", worktree.toString(), "fleetd", null)) + .spawn(); Path overlay = worktree.resolve("CLAUDE.local.md"); assertTrue(Files.exists(overlay), "the overlay file still lives at the worktree root"); @@ -304,8 +372,8 @@ class ClaudeCodeLauncherTest { Files.createDirectories(worktree.resolve(".git")); FakeHerdr herdr = new FakeHerdr(); - launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", - worktree.toString())).spawn(); + launcher(herdr, ideProfile(root.toString(), null, + "http://127.0.0.1:29170/index-mcp/streamable-http", worktree.toString())).spawn(); assertFalse(Files.exists(worktree.resolve("CLAUDE.local.md")), "the safety gate refuses to write into a non-worktree cwd (.git directory)");