diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index f66fe33..652b28d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -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; @@ -14,6 +17,8 @@ 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.nio.file.attribute.PosixFileAttributeView; import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -47,6 +52,21 @@ 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(); + + /** + * Serialises every {@link #seedTrustDialog} read-modify-write for the whole daemon process. + * Two claude-code spawns starting at once are normal (fleetd runs several members in parallel + * routinely) and both would otherwise read the same {@code .claude.json}, add their own entry + * to their own in-memory copy, and write — the second write wins and the first spawn's trust + * entry silently disappears. A single process-wide lock is enough because every spawn on this + * daemon runs in this one JVM; it does not protect against a second daemon process or the + * operator's own Claude Code process writing at the same instant, which {@link #writeAtomically} + * covers instead (each writer only ever sees a fully-old or fully-new file, never a torn one). + */ + private static final Object TRUST_JSON_LOCK = new Object(); + private final SubscriptionGuard guard; /** @@ -263,6 +283,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 +436,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 +471,189 @@ 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. + * + *
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. + * + *
The key Claude Code reads is per-project, in {@code .claude.json}: {@code
+ * projects. Additive, not a rewrite. {@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. 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.
+ *
+ * Gated to a provisioned worktree ({@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.
+ *
+ * Atomic and lock-protected. {@code .claude.json} is a live file — Claude Code itself
+ * rewrites it while running, and this daemon routinely spawns several members at once, each
+ * calling this method for its own cwd. Every write goes through {@link #writeAtomically} (a
+ * sibling-temp-file + {@code ATOMIC_MOVE}, never a truncate-in-place) so a crash mid-write or a
+ * concurrent reader never observes a half-written file, and through {@link #TRUST_JSON_LOCK} so
+ * two concurrent spawns' entries both survive instead of the second write silently discarding
+ * the first. Both exist because of a real incident: see {@link #isProvisionedWorktree}'s javadoc
+ * and {@link #writeAtomically}'s javadoc.
+ *
+ * @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");
+ synchronized (TRUST_JSON_LOCK) {
+ try {
+ if (target.getParent() != null) {
+ Files.createDirectories(target.getParent());
+ }
+ 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);
+ writeAtomically(target, TRUST_JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root));
+ } catch (Exception e) {
+ log.debug("cannot seed workspace-trust entry for cwd '{}' into '{}'", cwd, target, e);
+ }
+ }
+ }
+
+ /**
+ * Write {@code content} to {@code target} atomically: serialise to a sibling temp file in the
+ * same directory as {@code target} (an atomic move is only guaranteed within
+ * one filesystem — a different directory could mean a different filesystem), then
+ * {@link StandardCopyOption#ATOMIC_MOVE} it into place. A reader — Claude Code itself, or
+ * another {@code seedTrustDialog} call — only ever observes the fully-old file or the
+ * fully-new one, never a truncated or half-written one.
+ *
+ * fleetd #149 incident. The original implementation used
+ * {@code Files.writeString(target, content)} directly, which truncates {@code target} in place
+ * before writing the replacement bytes. Combined with an ungated {@code cwd} (see
+ * {@link #isProvisionedWorktree}'s javadoc), a mutation-testing run hit that truncation window
+ * against the operator's real {@code ~/.claude.json} and left it at 178 bytes. The gate closes
+ * which file this can ever target; this closes how the target is written, so
+ * that even a legitimate write against a real, live, concurrently-read {@code .claude.json}
+ * cannot leave it observably empty or partial.
+ *
+ * Preserves {@code target}'s existing POSIX permissions (Claude Code ships {@code
+ * .claude.json} as {@code 0600}) when the filesystem reports them; a freshly created temp file
+ * already defaults to owner-only permissions on a POSIX filesystem, so a first-ever write (no
+ * existing {@code target}) is no less private without this. On a non-POSIX filesystem (e.g.
+ * Windows) the permission copy is a silent no-op rather than a failure.
+ *
+ * Package-visible (not {@code private}) so a test can drive it directly with a concurrent
+ * reader thread and prove the torn-file property this method exists for — the LOCK in
+ * {@link #seedTrustDialog} already fully serialises every call this launcher itself makes, so a
+ * test that only ever goes through {@code seedTrustDialog}/{@code spawn()} could never observe
+ * a torn file regardless of whether this method is atomic; it would be proving the lock, not
+ * this method. Atomicity's actual job is protecting against a writer the lock cannot reach at
+ * all — a second daemon process, or the operator's own live Claude Code — so the test for it
+ * has to reach this method on its own.
+ */
+ static void writeAtomically(Path target, String content) throws IOException {
+ Path parent = target.getParent();
+ Path tmp = Files.createTempFile(parent, target.getFileName() + ".", ".tmp");
+ try {
+ Files.writeString(tmp, content);
+ copyPosixPermissionsIfPresent(target, tmp);
+ Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ Files.deleteIfExists(tmp);
+ throw e;
+ }
+ }
+
+ /** Copy {@code target}'s POSIX permissions onto {@code tmp}, or no-op where either is unsupported. */
+ private static void copyPosixPermissionsIfPresent(Path target, Path tmp) {
+ try {
+ if (!Files.isRegularFile(target)) {
+ return; // nothing to inherit from — first-ever write, temp file's own default stands
+ }
+ PosixFileAttributeView view = Files.getFileAttributeView(target, PosixFileAttributeView.class);
+ if (view == null) {
+ return; // non-POSIX filesystem — nothing this JVM can read/set here
+ }
+ Files.setPosixFilePermissions(tmp, Files.getPosixFilePermissions(target));
+ } catch (IOException e) {
+ log.debug("cannot preserve permissions of '{}' onto its replacement", 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 regular
+ * file holding a {@code gitdir:} pointer, as opposed to a real checkout's {@code .git}
+ * directory. {@code null}/blank never qualifies.
+ *
+ * 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.
+ *
+ * fleetd #149 incident. {@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;
diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java
index 99357cd..0c51083 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java
@@ -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"}) {
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 20d87db..ea5f991 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
@@ -4,6 +4,9 @@ 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 com.fasterxml.jackson.databind.node.ObjectNode;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.GuardException;
import dev.ltms.fleet.guard.SubscriptionGuard;
@@ -23,11 +26,16 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermissions;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -2098,4 +2106,433 @@ 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 This is a test of {@code TRUST_JSON_LOCK}, not of {@code writeAtomically}: the lock fully
+ * serialises every {@code seedTrustDialog} call this launcher itself makes, so this test would
+ * pass even without atomicity. See {@link #writeAtomicallyNeverExposesATornFileToAConcurrentReader}
+ * for the test that exercises atomicity specifically.
+ */
+ @Test
+ void concurrentSeedsForDifferentCwdsBothSurvive(
+ @TempDir Path configDir, @TempDir Path worktreeA, @TempDir Path worktreeB) throws Exception {
+ markAsProvisionedWorktree(worktreeA);
+ markAsProvisionedWorktree(worktreeB);
+
+ CountDownLatch ready = new CountDownLatch(2);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicReference The new content is made large (tens of MB) so a naive truncate-then-write has a real,
+ * non-instantaneous window for the busy-poll reader thread to land in — this is inherently a
+ * race, not a guaranteed-deterministic assertion, but it uses no {@code Thread.sleep} and
+ * reliably reproduced the torn read when run against the pre-fix
+ * {@code Files.writeString(target, content)} implementation (see the PR's mutation table).
+ */
+ @Test
+ void writeAtomicallyNeverExposesATornFileToAConcurrentReader(@TempDir Path dir) throws Exception {
+ Path target = dir.resolve(".claude.json");
+ String oldContent = "{\"marker\":\"OLD\"}";
+ Files.writeString(target, oldContent);
+ String newContent = "{\"marker\":\"NEW\",\"pad\":\"" + "x".repeat(20_000_000) + "\"}";
+
+ AtomicReference