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 6dddea8..fa1b391 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -459,12 +459,83 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * {@link OpenCodeLauncher#writeConfig} already uses for its charter file, since the process that * reads this file (the spawned peer) outlives this JVM call and there is no spawn-scoped teardown * hook to delete it synchronously. + * + *
fleetd #222. {@code Files.createTempFile(prefix, suffix)} with no directory argument + * resolves against {@code java.io.tmpdir} — on macOS the per-user {@code $TMPDIR} under + * {@code /var/folders/...}, mode {@code 0700}, both resolved against FLEETD's own OS user. Under + * {@code memberHerdrSocket:} the member pane runs as a DIFFERENT OS user, so that user cannot even + * traverse the directory, let alone read the file — and since fleetd #220 the charter file is the + * ONLY delivery path for {@code --append-system-prompt-file}, always, not merely the fallback it + * used to be. A member handed a path it cannot read is not degraded, it is broken: see this + * method's refusal branch below. + * + *
Measured severity (fleetd #222 real-binary check, claude 2.1.258): an unreadable
+ * {@code --append-system-prompt-file} is the LOUD failure, not the silent one. {@code claude}
+ * checks the file before touching auth or the network — invoked with a bogus API key against a
+ * {@code chmod 000} file, it printed {@code Error reading append system prompt file: EACCES:
+ * permission denied, open ' Follows fleetd #219's REFUSAL decision, not #213's degrade decision. The ZDOTDIR
+ * scrub is a credential CONTROL — a degraded control (the CB-596 sentinel overlay) still has
+ * value, so #213 falls back rather than refusing. A charter is NOT a control, it is the member's
+ * TURN CONTRACT (the rule that ends every turn with {@code fleet_reply}). A member spawned with no
+ * charter is not degraded, it is broken: either claude-code exits on the unreadable
+ * {@code --append-system-prompt-file} path and the spawn dies at the readiness gate (loud), or it
+ * starts anyway with no charter and never calls {@code fleet_reply} — the sender silently gets
+ * nothing (silent). Neither outcome is worth trading for "spawn something." So a missing {@code
+ * worktreeRoot}/{@code worktreeGroup} under {@code memberHerdrSocket} refuses the spawn here,
+ * naming the missing key, exactly like {@link OpenCodeLauncher#configParentDir()}.
+ *
+ * @throws IllegalStateException when {@code memberHerdrSocket} is configured but {@code
+ * worktreeRoot} and/or {@code worktreeGroup} is not
*/
- private static Path writeCharterFile(String charterText) {
+ private Path writeCharterFile(String charterText) {
try {
- Path file = Files.createTempFile("fleetd-role-charter-", ".md");
+ if (!memberHerdrSocketConfigured()) {
+ Path file = Files.createTempFile("fleetd-role-charter-", ".md");
+ Files.writeString(file, charterText);
+ file.toFile().deleteOnExit();
+ return file;
+ }
+ Path parentDir = memberScrubParentDir();
+ String group = memberGroup();
+ if (parentDir == null || group == null) {
+ throw new IllegalStateException("memberHerdrSocket is configured, so the role/reply "
+ + "charter file (mounted via --append-system-prompt-file) must be placed where "
+ + "the member's OS user can read it — worktreeRoot, shared via worktreeGroup — "
+ + "but " + (parentDir == null ? "worktreeRoot" : "worktreeGroup") + " is not "
+ + "configured. Refusing to spawn rather than hand the member a charter path it "
+ + "cannot read: that member's turn contract (the fleet_reply rule) would never "
+ + "reach it. Configure both worktreeRoot and worktreeGroup to enable claude-code "
+ + "member spawns under memberHerdrSocket.");
+ }
+ Path dir = Files.createTempDirectory(parentDir, "fleetd-role-charter-");
+ dir.toFile().deleteOnExit();
+ Path file = dir.resolve("charter.md");
Files.writeString(file, charterText);
file.toFile().deleteOnExit();
+ EnvAllowListScrub.shareWithGroup(dir, group);
return file;
} catch (IOException e) {
throw new UncheckedIOException("cannot write role charter temp file", e);
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 3d7a4ec..255e947 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
@@ -20,8 +20,10 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory;
+import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -31,6 +33,7 @@ import java.util.function.Function;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** The step-4 launch-flag injection: the bridge MCP + reply charter are appended to the argv. */
class ClaudeCodeLauncherTest {
@@ -1751,4 +1754,184 @@ class ClaudeCodeLauncherTest {
assertEquals(List.of("dev: sonnet #1", "[sonnet] dev 2"), tabLabels(herdr));
}
+
+ // ── fleetd #222: the charter file must not land under fleetd's own java.io.tmpdir when the ──
+ // ── member pane runs as a different OS user ─────────────────────────────────────────────────
+
+ /** A profile that mounts the bridge MCP (so a reply charter is always generated). */
+ private static FleetConfig.Profile charterCfg() {
+ return new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}",
+ "http://127.0.0.1:8765/mcp", null, null);
+ }
+
+ /** A config with {@code memberHerdrSocket:} set, and optionally {@code worktreeRoot:}/{@code worktreeGroup:}. */
+ private static FleetConfig configWithMemberHerdrSocket(String worktreeRoot, String worktreeGroup) {
+ return new FleetConfig(
+ null, // bind
+ null, // herdrSocket
+ "/tmp/other-user.sock", // memberHerdrSocket
+ Map.of(), // profiles
+ null, // guard
+ worktreeRoot, // worktreeRoot
+ null, // lifecycle
+ null, // spawnReadyTimeoutMs
+ null, // spawnReadyPollMs
+ null, // broker
+ null, // primary
+ null, // fleet
+ null, // leadHeartbeat
+ null, // health
+ null, // placement
+ null, // auth
+ null, // configReload
+ null, // quarantineCooldownSeconds
+ null, // memberCredentials
+ null, // coordinator
+ worktreeGroup, // worktreeGroup
+ null // memberLoginShell
+ ).withDefaults();
+ }
+
+ private static ClaudeCodeLauncher serviceWithConfig(FakeHerdr herdr, FleetConfig.Profile cfg,
+ Supplier
+ *
+ *
+ *