fleetd #149: seed the workspace-trust entry before a claude-code spawn
A claude-code member spawned into a fresh worktree hits an interactive, un-timed workspace-trust prompt on its first start in a directory it has never seen. It never reaches its first turn and never mounts the bridge. Fix: ClaudeCodeLauncher.seedTrustDialog writes projects.<cwd>.hasTrustDialogAccepted / hasCompletedProjectOnboarding into the profile's configDir/.claude.json (or ~/.claude.json when configDir is unset) BEFORE the herdr spawn call, additively (existing keys/projects are preserved). Gated to isProvisionedWorktree(cwd) - a .git that is a regular gitdir-pointer file, never a real checkout's .git directory - the same signal writeIdeOverlay already used, now shared between both. That gate is a fix for a real incident hit while building this: an earlier ungated version ran against this file's own pre-existing tests (configDir=null, no cwd -> falls back to the real user.dir and ~/.claude.json) and corrupted the operator's actual ~/.claude.json down to a single entry during a mutation-testing run. See PR body for the full incident report. FakeHerdr gained onAgentStart(Runnable) so a test can assert the seed is on disk at the exact instant herdr's agent.start call is reached - i.e. strictly before the peer process itself would start.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||
import dev.ltms.fleet.herdr.Agent;
|
||||
@@ -47,6 +50,9 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClaudeCodeLauncher.class);
|
||||
|
||||
/** JSON codec for the additive workspace-trust seed (fleetd #149) — Jackson's default settings. */
|
||||
private static final ObjectMapper TRUST_JSON = new ObjectMapper();
|
||||
|
||||
private final SubscriptionGuard guard;
|
||||
|
||||
/**
|
||||
@@ -263,6 +269,10 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
putIfPresent(workerEnv, "ANTHROPIC_MODEL", cfg.model());
|
||||
putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir());
|
||||
applyGitToken(workerEnv, cfg);
|
||||
// fleetd #149: seed the workspace-trust entry BEFORE this spawn ever reaches herdr — see
|
||||
// seedTrustDialog for why, and isProvisionedWorktree for why this is gated to a worktree
|
||||
// fleetd itself provisioned (never a real checkout, never an un-configured fallback cwd).
|
||||
seedTrustDialog(cfg.configDir(), spec.cwd());
|
||||
|
||||
// CB-547a: Claude Code can MINT its own session id, so fleetd chooses it — a fresh spawn
|
||||
// gets a UUID we pass as --session-id and return from agentSessionId(), so the resume
|
||||
@@ -412,12 +422,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
*/
|
||||
private static void writeIdeOverlay(String cwd, String projectPath) {
|
||||
try {
|
||||
Path dotGit = Path.of(cwd, ".git");
|
||||
if (!Files.isRegularFile(dotGit)) {
|
||||
if (!isProvisionedWorktree(cwd)) {
|
||||
// Not a provisioned worktree (primary's real checkout has a .git directory, or the
|
||||
// cwd is not a repo at all). Never write into it.
|
||||
return;
|
||||
}
|
||||
Path dotGit = Path.of(cwd, ".git");
|
||||
// The overlay FILE lives at the worktree root (claude-code's cwd), but its CONTENT pins
|
||||
// project_path to the module dir the IDE opened (projectPath), not the worktree root.
|
||||
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(projectPath));
|
||||
@@ -447,6 +457,117 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #149: pre-seed the workspace-trust entry for {@code cwd} in Claude Code's own config
|
||||
* file, BEFORE this launch ever reaches herdr (called from {@link #buildLaunch}, which always
|
||||
* runs before the base class starts the process). Claude Code asks an interactive, un-timed
|
||||
* "Is this a project you created or one you trust?" the first time it starts in a directory it
|
||||
* has not seen before, and every {@code worktree: true} spawn lands in a brand-new directory —
|
||||
* so without this seed the member sits on that dialog forever, never mounts the bridge MCP, and
|
||||
* never calls {@code fleet_reply}. herdr reports it as healthy the whole time ({@code
|
||||
* agent_status: blocked}, {@code interactive_ready: true}), so nothing else catches it. Measured
|
||||
* live on fleet01 2026-08-23: an unseeded fresh cwd sat on the dialog indefinitely; a seeded one
|
||||
* reached {@code idle} clean.
|
||||
*
|
||||
* <p>This is not a new grant — the operator already trusted this repo by configuring the
|
||||
* profile against it, and a worktree is a checkout of that same repo.
|
||||
*
|
||||
* <p>The key Claude Code reads is per-project, in {@code .claude.json}: {@code
|
||||
* projects.<cwd>.hasTrustDialogAccepted}. The file lives at {@code <configDir>/.claude.json}
|
||||
* when the profile sets {@code CLAUDE_CONFIG_DIR} (mirrors this launcher's own env var above),
|
||||
* else the default {@code ~/.claude.json} — the same file Claude Code itself would read either
|
||||
* way, so this seeds exactly what the spawned peer is about to open.
|
||||
*
|
||||
* <p><b>Additive, not a rewrite.</b> {@code .claude.json} is large (tens of KB, dozens of
|
||||
* projects) and Claude Code itself rewrites it while running, so this reads the file as a JSON
|
||||
* tree (missing or unreadable → treated as an empty object) and changes only
|
||||
* {@code projects.<cwd>.hasTrustDialogAccepted} / {@code .hasCompletedProjectOnboarding} —
|
||||
* every other top-level key and every other project entry is written back untouched. Only the
|
||||
* one project entry for {@code cwd} is replaced/created; an existing entry for a DIFFERENT cwd
|
||||
* (or the operator's own project history) is never touched.
|
||||
*
|
||||
* <p>Best-effort, like {@link #writeIdeOverlay}: a failure here (unwritable configDir, a
|
||||
* corrupt existing file, …) must never fail the spawn — it is logged at debug and swallowed. A
|
||||
* peer that starts without the seed still starts; it just may hit the dialog fleetd #149
|
||||
* describes.
|
||||
*
|
||||
* <p><b>Gated to a provisioned worktree</b> ({@link #isProvisionedWorktree}) — see that
|
||||
* method's javadoc for the incident that made this gate mandatory, not optional: this must
|
||||
* never run against a real checkout or an un-configured fallback cwd, only the exact
|
||||
* always-fresh-directory population fleetd #149 describes.
|
||||
*
|
||||
* @param configDir the profile's {@code CLAUDE_CONFIG_DIR} ({@code cfg.configDir()}), or
|
||||
* {@code null}/blank to target the default {@code ~/.claude.json}
|
||||
* @param cwd the spawn's resolved working directory — the exact key Claude Code will look
|
||||
* up for itself once it starts there
|
||||
*/
|
||||
private static void seedTrustDialog(String configDir, String cwd) {
|
||||
if (!isProvisionedWorktree(cwd)) {
|
||||
return;
|
||||
}
|
||||
Path target = (configDir == null || configDir.isBlank())
|
||||
? Path.of(System.getProperty("user.home"), ".claude.json")
|
||||
: Path.of(configDir, ".claude.json");
|
||||
try {
|
||||
ObjectNode root = null;
|
||||
if (Files.isRegularFile(target)) {
|
||||
JsonNode existing = TRUST_JSON.readTree(target.toFile());
|
||||
if (existing instanceof ObjectNode existingObject) {
|
||||
root = existingObject;
|
||||
}
|
||||
}
|
||||
if (root == null) {
|
||||
root = TRUST_JSON.createObjectNode();
|
||||
}
|
||||
JsonNode projectsNode = root.get("projects");
|
||||
ObjectNode projects = projectsNode instanceof ObjectNode projectsObject
|
||||
? projectsObject : TRUST_JSON.createObjectNode();
|
||||
if (!(projectsNode instanceof ObjectNode)) {
|
||||
root.set("projects", projects);
|
||||
}
|
||||
JsonNode projectNode = projects.get(cwd);
|
||||
ObjectNode project = projectNode instanceof ObjectNode projectObject
|
||||
? projectObject : TRUST_JSON.createObjectNode();
|
||||
if (!(projectNode instanceof ObjectNode)) {
|
||||
projects.set(cwd, project);
|
||||
}
|
||||
project.put("hasTrustDialogAccepted", true);
|
||||
project.put("hasCompletedProjectOnboarding", true);
|
||||
if (target.getParent() != null) {
|
||||
Files.createDirectories(target.getParent());
|
||||
}
|
||||
Files.writeString(target, TRUST_JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root));
|
||||
} catch (Exception e) {
|
||||
log.debug("cannot seed workspace-trust entry for cwd '{}' into '{}'", cwd, target, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code cwd} is a fleetd-provisioned git worktree — signalled the same way
|
||||
* {@link #writeIdeOverlay} already gates on: a {@code .git} that is a <strong>regular
|
||||
* file</strong> holding a {@code gitdir:} pointer, as opposed to a real checkout's {@code .git}
|
||||
* <strong>directory</strong>. {@code null}/blank never qualifies.
|
||||
*
|
||||
* <p>Shared by every write that must land only in a worktree fleetd itself created for a
|
||||
* member — never in a real checkout, an arbitrary configured directory, or (see the incident
|
||||
* below) the daemon's own fallback cwd.
|
||||
*
|
||||
* <p><b>fleetd #149 incident.</b> {@link #seedTrustDialog} originally ran unconditionally on
|
||||
* any non-blank {@code cwd}. Most of this launcher's OWN tests spawn a profile with no
|
||||
* {@code cwd} configured, so the base class's {@code resolveCwd} falls through to the real
|
||||
* {@code user.dir} — and with no {@code configDir} either (also the common case in this
|
||||
* file's fixtures), the seed's target falls through the same way to the real
|
||||
* {@code ~/.claude.json}. Running this repo's own test suite corrupted the operator's actual
|
||||
* config file (it shrank from ~72 KB to a single seeded entry) the first time a mutation
|
||||
* happened to make the write non-additive. Gating both cwd-targeted writes on "this is a
|
||||
* worktree fleetd provisioned" — exactly the population fleetd #149 describes
|
||||
* ({@code worktree: true} always lands in a brand-new directory) — makes that class of write
|
||||
* impossible against a real checkout or an untouched fallback cwd, in production or in tests.
|
||||
*/
|
||||
private static boolean isProvisionedWorktree(String cwd) {
|
||||
return cwd != null && !cwd.isBlank() && Files.isRegularFile(Path.of(cwd, ".git"));
|
||||
}
|
||||
|
||||
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
|
||||
private static String nonBlank(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s;
|
||||
|
||||
@@ -49,6 +49,7 @@ public final class FakeHerdr implements HerdrClient {
|
||||
private int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane
|
||||
private String pinnedStartTerminal;
|
||||
private String pinnedStartPane;
|
||||
private Runnable onAgentStart; // fires the instant agent.start is called — see onAgentStart(Runnable)
|
||||
private volatile int agentGetOkCalls = Integer.MAX_VALUE; // how many agent.get calls succeed first
|
||||
private volatile String agentGetFailCode = null; // error code every agent.get call after that reports
|
||||
|
||||
@@ -152,6 +153,18 @@ public final class FakeHerdr implements HerdrClient {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code hook} synchronously the instant an {@code agent.start} call reaches this fake —
|
||||
* i.e. the instant the peer PROCESS would start against a real herdr daemon. A test uses this
|
||||
* to assert something is already true at that exact point (rather than merely true once
|
||||
* {@code spawn()} returns), e.g. fleetd #149's trust-dialog seed having already been written to
|
||||
* disk before the process herdr would launch ever starts.
|
||||
*/
|
||||
public FakeHerdr onAgentStart(Runnable hook) {
|
||||
this.onAgentStart = hook;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Seed a named agent into {@code agent.list} (e.g. an orphaned worker for CB-117 reaper tests).
|
||||
@@ -250,6 +263,9 @@ public final class FakeHerdr implements HerdrClient {
|
||||
case "agent.read" -> mapper.readTree(mapper.writeValueAsString(
|
||||
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", readText))));
|
||||
case "agent.start" -> {
|
||||
if (onAgentStart != null) {
|
||||
onAgentStart.run();
|
||||
}
|
||||
// Protocol 19: kind and pane_id are required — reject like the real daemon.
|
||||
java.util.Map<?, ?> p = params instanceof java.util.Map<?, ?> m ? m : java.util.Map.of();
|
||||
for (String required : new String[]{"kind", "pane_id"}) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
import dev.ltms.fleet.guard.GuardException;
|
||||
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||
@@ -2098,4 +2100,217 @@ class ClaudeCodeLauncherTest {
|
||||
assertTrue(charterFile.getFileName().toString().startsWith("fleetd-role-charter-"),
|
||||
"same file-naming scheme as before this fix (no wrapping directory): " + charterFile);
|
||||
}
|
||||
|
||||
// --- fleetd #149: workspace-trust dialog seed ------------------------------------------------
|
||||
//
|
||||
// Claude Code asks an interactive, un-timed "Is this a project you created or one you trust?"
|
||||
// the first time it starts in a directory it has not seen. Every worktree: true spawn lands in
|
||||
// a brand-new directory, so without a seed the member sits on that dialog forever — herdr still
|
||||
// reports it healthy (agent_status: blocked, interactive_ready: true) — and never mounts the
|
||||
// bridge MCP or calls fleet_reply. These tests start the REAL launcher (only the herdr transport
|
||||
// is faked) so the seed is proven to run inside buildLaunch, before agent.start (the
|
||||
// process-starting call) ever fires — a test that only checked the JSON writer in isolation
|
||||
// would prove nothing about whether the launcher actually calls it at the right time.
|
||||
//
|
||||
// INCIDENT: the seed originally ran on ANY non-blank cwd. Running this file's own test suite —
|
||||
// most of whose fixtures spawn with no cwd/configDir set, so both fall back to the real
|
||||
// user.dir / ~/.claude.json — corrupted the operator's actual ~/.claude.json (it shrank from
|
||||
// ~72 KB to a single seeded entry) the first time a manual mutation run made the write
|
||||
// non-additive. The fix restricts the seed to isProvisionedWorktree(cwd) — a real .git FILE
|
||||
// (not directory) — exactly the writeIdeOverlay gate already used for the same category of
|
||||
// risk. Every test below marks its own worktree fixture with that .git file, and
|
||||
// seedTrustDialogNeverWritesWhenCwdIsNotAProvisionedWorktree is the regression test for the
|
||||
// incident itself.
|
||||
|
||||
private FleetConfig.Profile trustProfile(String configDir, String cwd) {
|
||||
return new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", configDir, "FLEETD_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "fleetd-workers", "w #{n}", "http://127.0.0.1:8765/mcp",
|
||||
cwd, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give {@code dir} the exact signature {@link ClaudeCodeLauncher#isProvisionedWorktree} (and
|
||||
* {@code writeIdeOverlay} before it) checks for: a {@code .git} REGULAR FILE, never a
|
||||
* directory. The content is never parsed by the trust seed, so any {@code gitdir:} pointer is
|
||||
* fine.
|
||||
*/
|
||||
private static void markAsProvisionedWorktree(Path dir) throws IOException {
|
||||
Files.writeString(dir.resolve(".git"), "gitdir: /tmp/not-a-real-gitdir");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedsWorkspaceTrustForTheCwdBeforeTheProcessStarts(
|
||||
@TempDir Path configDir, @TempDir Path worktree) throws Exception {
|
||||
markAsProvisionedWorktree(worktree);
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Path claudeJson = configDir.resolve(".claude.json");
|
||||
AtomicReference<Boolean> seededBeforeStart = new AtomicReference<>(false);
|
||||
herdr.onAgentStart(() -> {
|
||||
try {
|
||||
if (!Files.exists(claudeJson)) {
|
||||
return;
|
||||
}
|
||||
JsonNode root = new ObjectMapper().readTree(claudeJson.toFile());
|
||||
JsonNode project = root.path("projects").path(worktree.toString());
|
||||
seededBeforeStart.set(project.path("hasTrustDialogAccepted").asBoolean(false)
|
||||
&& project.path("hasCompletedProjectOnboarding").asBoolean(false));
|
||||
} catch (IOException e) {
|
||||
seededBeforeStart.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
FleetConfig.Profile cfg = trustProfile(configDir.toString(), worktree.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
assertTrue(herdr.called("agent.start"), "the hook must actually have fired during the spawn");
|
||||
assertEquals(Boolean.TRUE, seededBeforeStart.get(),
|
||||
"the trust entry for the cwd must already exist at the instant agent.start (the "
|
||||
+ "process-starting herdr call) fires — not merely once spawn() returns");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedTrustDialogWritesBothTrustFlagsForTheResolvedCwd(
|
||||
@TempDir Path configDir, @TempDir Path worktree) throws Exception {
|
||||
markAsProvisionedWorktree(worktree);
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FleetConfig.Profile cfg = trustProfile(configDir.toString(), worktree.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
Path claudeJson = configDir.resolve(".claude.json");
|
||||
assertTrue(Files.exists(claudeJson), "seeded into <configDir>/.claude.json");
|
||||
JsonNode project = new ObjectMapper().readTree(claudeJson.toFile())
|
||||
.path("projects").path(worktree.toString());
|
||||
assertTrue(project.path("hasTrustDialogAccepted").asBoolean(false));
|
||||
assertTrue(project.path("hasCompletedProjectOnboarding").asBoolean(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Criterion 3: seeding is additive. An existing {@code .claude.json} carries the operator's own
|
||||
* project history and unrelated top-level settings — the seed must change only
|
||||
* {@code projects.<cwd>} for THIS cwd and leave everything else, including a different project's
|
||||
* own unrelated data, exactly as it was.
|
||||
*/
|
||||
@Test
|
||||
void seedTrustDialogIsAdditiveAndPreservesUnknownKeysAndOtherProjects(
|
||||
@TempDir Path configDir, @TempDir Path worktree) throws Exception {
|
||||
markAsProvisionedWorktree(worktree);
|
||||
Path claudeJson = configDir.resolve(".claude.json");
|
||||
Files.writeString(claudeJson, """
|
||||
{
|
||||
"numStartups": 42,
|
||||
"oauthAccount": {"emailAddress": "operator@example.com"},
|
||||
"projects": {
|
||||
"/some/other/project": {
|
||||
"hasTrustDialogAccepted": true,
|
||||
"mcpServers": {"foo": {"type": "stdio", "command": "foo-mcp"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FleetConfig.Profile cfg = trustProfile(configDir.toString(), worktree.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
JsonNode root = new ObjectMapper().readTree(claudeJson.toFile());
|
||||
assertEquals(42, root.path("numStartups").asInt(), "unrelated top-level key survives untouched");
|
||||
assertEquals("operator@example.com", root.path("oauthAccount").path("emailAddress").asText(),
|
||||
"an unrelated nested top-level key survives untouched");
|
||||
|
||||
JsonNode other = root.path("projects").path("/some/other/project");
|
||||
assertTrue(other.path("hasTrustDialogAccepted").asBoolean(false),
|
||||
"a different project's own trust entry survives");
|
||||
assertEquals("foo-mcp", other.path("mcpServers").path("foo").path("command").asText(),
|
||||
"a different project's own unrelated nested data survives");
|
||||
|
||||
JsonNode mine = root.path("projects").path(worktree.toString());
|
||||
assertTrue(mine.path("hasTrustDialogAccepted").asBoolean(false));
|
||||
assertTrue(mine.path("hasCompletedProjectOnboarding").asBoolean(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Criterion 2: where the profile sets no {@code configDir}, the seed goes to the default
|
||||
* {@code ~/.claude.json}. {@code user.home} is redirected to a {@code @TempDir} for the
|
||||
* duration of this test and restored in a {@code finally} — the real operator {@code
|
||||
* ~/.claude.json} must never be touched by a test.
|
||||
*/
|
||||
@Test
|
||||
void seedTrustDialogTargetsDefaultClaudeJsonWhenConfigDirIsUnset(
|
||||
@TempDir Path fakeHome, @TempDir Path worktree) throws Exception {
|
||||
markAsProvisionedWorktree(worktree);
|
||||
String originalHome = System.getProperty("user.home");
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
try {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FleetConfig.Profile cfg = trustProfile(null, worktree.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
Path claudeJson = fakeHome.resolve(".claude.json");
|
||||
assertTrue(Files.exists(claudeJson),
|
||||
"no configDir set — the default target is ~/.claude.json, here the redirected fake home");
|
||||
JsonNode project = new ObjectMapper().readTree(claudeJson.toFile())
|
||||
.path("projects").path(worktree.toString());
|
||||
assertTrue(project.path("hasTrustDialogAccepted").asBoolean(false));
|
||||
} finally {
|
||||
System.setProperty("user.home", originalHome);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the fleetd #149 incident itself: a cwd that is NOT a fleetd-provisioned
|
||||
* worktree (no {@code .git} FILE — the exact shape a real checkout, or an un-configured
|
||||
* fallback cwd, has) must never be written to, however {@code configDir} is set. This is the
|
||||
* fix for the exact defect that corrupted the operator's real {@code ~/.claude.json}.
|
||||
*/
|
||||
@Test
|
||||
void seedTrustDialogNeverWritesWhenCwdIsNotAProvisionedWorktree(
|
||||
@TempDir Path configDir, @TempDir Path plainCwd) {
|
||||
// plainCwd deliberately carries NO .git file — the same shape a real checkout's cwd
|
||||
// fallback has.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FleetConfig.Profile cfg = trustProfile(configDir.toString(), plainCwd.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
assertFalse(Files.exists(configDir.resolve(".claude.json")),
|
||||
"a non-worktree cwd must never get a .claude.json written for it — this is the fix "
|
||||
+ "for the incident where writing unconditionally corrupted the operator's own "
|
||||
+ "real ~/.claude.json via this file's own no-cwd/no-configDir test fixtures");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same regression, for the default (no {@code configDir}) path — the exact combination (no
|
||||
* {@code configDir}, no worktree-shaped {@code cwd}) that hit the operator's real
|
||||
* {@code ~/.claude.json} during the incident. {@code user.home} is still redirected to a
|
||||
* {@code @TempDir} out of caution, so even a reintroduced bug here cannot touch the real file.
|
||||
*/
|
||||
@Test
|
||||
void seedTrustDialogNeverWritesToDefaultHomeWhenCwdIsNotAProvisionedWorktree(
|
||||
@TempDir Path fakeHome, @TempDir Path plainCwd) {
|
||||
String originalHome = System.getProperty("user.home");
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
try {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FleetConfig.Profile cfg = trustProfile(null, plainCwd.toString());
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null)
|
||||
.spawn();
|
||||
|
||||
assertFalse(Files.exists(fakeHome.resolve(".claude.json")),
|
||||
"the exact incident combination — no configDir, non-worktree cwd — must never "
|
||||
+ "write, even to the (redirected) default ~/.claude.json");
|
||||
} finally {
|
||||
System.setProperty("user.home", originalHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user