#258: stop two test fixtures writing the operator's real ~/.claude.json
CI / contract (push) Successful in 48s
CI / build (push) Successful in 1m29s

seedTrustDialog targets ~/.claude.json when a profile sets no configDir.
Two IDE-overlay fixtures built a worktree-shaped @TempDir, which opens the
#149 isProvisionedWorktree gate, and left configDir null — so every test run
added two project entries to the operator's real file. 116 had accumulated,
none of them still existing on disk, and 32 of those came from current code.

The #149 gate only closed the opposite case: a fixture with cwd unset falling
back to user.dir. A fixture that builds a worktree on purpose walks straight
through it.

- ideProfile/ideProfileModule now take configDir first and mandatory, so each
  fixture states where the trust seed goes.
- noFixtureSeededTheDefaultClaudeJson snapshots the temp-dir project keys in
  @BeforeAll and fails in @AfterAll on any key this class added. Differential,
  not absolute: an absolute check would fail on every host still carrying the
  historical entries, and such a check gets deleted rather than fixed.

Not done: a blanket -Duser.home redirect in surefire. EnvAllowListScrubTest
tests the credential scrub against the operator's real login chain and guards
with assumeTrue($HOME/.zshrc exists), so the redirect would silently skip two
security tests.

Proof: with the bug put back on one fixture the guard fails and names the path;
reverted and confirmed identical with diff -q. A full suite run under a fake
home now creates no .claude.json at all. mvn clean install: 0 compile errors,
1255 tests, BUILD SUCCESS.
This commit is contained in:
Dai Ha
2026-09-03 14:01:45 +07:00
parent 6b5f3f472f
commit ac790e4cce
@@ -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<String> 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<String> 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 <configDir>/.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<String> tempProjectKeysBefore;
@BeforeAll
static void snapshotTempProjectKeysInTheDefaultClaudeJson() {
tempProjectKeysBefore = tempProjectKeysInDefaultClaudeJson();
}
@AfterAll
static void noFixtureSeededTheDefaultClaudeJson() {
Set<String> 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 <user.home>/.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<String> tempProjectKeysInDefaultClaudeJson() {
Set<String> 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 <worktree>/fleetd, not <worktree>.
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)");