shareGroupRunner) {
this.configuredRoot = configuredRoot;
+ this.group = (group == null || group.isBlank()) ? null : group;
this.afterWorktreeAdded = afterWorktreeAdded == null ? _ -> {} : afterWorktreeAdded;
+ this.shareGroupRunner = shareGroupRunner != null ? shareGroupRunner : this::exec;
}
@Override
@@ -607,6 +648,80 @@ public final class GitWorktrees implements Worktrees {
return deleted;
}
+ /**
+ * {@inheritDoc}
+ *
+ * fleetd #185 stage 3. No-op — no process spawned, nothing logged — when {@link #group} is
+ * null/blank. Otherwise:
+ *
+ * - {@code git -C repoRoot config core.sharedRepository group} so every future write by
+ * either uid stays group-writable;
+ * - a one-time {@code chgrp}/{@code chmod g+rwX} fix-up over the worktree directory, the
+ * repo's {@code .git/objects}, {@code refs}, {@code logs}, {@code worktrees}, and (when
+ * present) {@code packed-refs}, with setgid ({@code chmod g+s}) applied only to the
+ * directories among them so files created later inherit the group;
+ * - one INFO line naming the group and the paths touched.
+ *
+ *
+ * This only fixes up file ownership/permissions on the operator's shared repo so a
+ * different-uid member can write to it — it isolates credentials, not the repository. A member
+ * in the group can still write the operator's git objects and refs.
+ *
+ *
Fails loudly: a missing group, or a {@code chgrp}/{@code chmod} refused because the
+ * operator is not a member of it, becomes a {@link WorktreeException} naming the group — never
+ * a silent skip that leaves a member unable to work with nothing in the log to explain why.
+ */
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ if (group == null) {
+ return;
+ }
+ List touched = new ArrayList<>();
+ try {
+ shareGroupRunner.apply(new String[]{"git", "-C", repoRoot, "config", "core.sharedRepository", "group"});
+ for (String dir : List.of(worktreePath, repoRoot + "/.git/objects", repoRoot + "/.git/refs",
+ repoRoot + "/.git/logs", repoRoot + "/.git/worktrees")) {
+ shareGroupPath(dir, true);
+ touched.add(dir);
+ }
+ String packedRefs = repoRoot + "/.git/packed-refs";
+ if (Files.exists(Path.of(packedRefs))) {
+ shareGroupPath(packedRefs, false);
+ touched.add(packedRefs);
+ }
+ } catch (WorktreeException e) {
+ throw new WorktreeException("cannot share worktree with group '" + group + "': "
+ + e.getMessage() + " — the group must exist, and the fleetd operator ("
+ + System.getProperty("user.name") + ") must be a member of it", e);
+ }
+ log.info("worktreeGroup={} shared repoRoot={} worktreePath={} paths={}",
+ group, repoRoot, worktreePath, touched);
+ }
+
+ /**
+ * {@code chgrp}/{@code chmod g+rwX} {@code path} to {@link #group}. When {@code recursive},
+ * also walks the directories under {@code path} (including {@code path} itself, when it is a
+ * directory) and sets setgid on each — directories only, per the javadoc on
+ * {@link #shareWithGroup}.
+ */
+ private void shareGroupPath(String path, boolean recursive) {
+ List chgrp = new ArrayList<>(List.of("chgrp"));
+ if (recursive) chgrp.add("-R");
+ chgrp.add(group);
+ chgrp.add(path);
+ shareGroupRunner.apply(chgrp.toArray(new String[0]));
+
+ List chmod = new ArrayList<>(List.of("chmod"));
+ if (recursive) chmod.add("-R");
+ chmod.add("g+rwX");
+ chmod.add(path);
+ shareGroupRunner.apply(chmod.toArray(new String[0]));
+
+ if (recursive) {
+ shareGroupRunner.apply(new String[]{"find", path, "-type", "d", "-exec", "chmod", "g+s", "{}", "+"});
+ }
+ }
+
/**
* Every {@code refs/wip/*} ref (see {@link WipRef}). The committer date is read as a unix
* count of seconds and converted to millis. {@code %00} (NUL) separates the fields because a
diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
index c502dbe..e135bb7 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
@@ -472,6 +472,10 @@ public final class SessionManager implements TurnListener {
try {
path = worktrees.add(repoRoot, branch, wt.baseRef());
worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(preResolvedProfile));
+ // fleetd #185 stage 3: MUST run after overlayParity, not folded into add() — overlayParity
+ // copies more files into the worktree after add() returns, so sharing the group any earlier
+ // leaves those overlay files operator-owned and read-only for a different-uid member.
+ worktrees.shareWithGroup(repoRoot, path);
handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd, sessionName, resumeSessionId, memberRole));
} catch (RuntimeException e) {
log.warn("spawn failed for profile={} role={} branch={} path={}: {}",
diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java b/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
index e49e928..2005f3c 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
@@ -98,4 +98,21 @@ public interface Worktrees {
/** CB-586: the operator-visible census of {@code refs/wip/*} in one repository. */
record WipRefStats(int count, long costBytes) {
}
+
+ /**
+ * Make {@code repoRoot}'s git store and {@code worktreePath} writable by the configured group
+ * (fleetd #185 stage 3), so a member spawned as a different OS user (see
+ * {@code memberHerdrSocket}) can write its own worktree, its per-worktree git metadata, and
+ * its own commit objects. No-op when no group is configured.
+ *
+ * This isolates credentials, not the repository. A member in the group can
+ * still write the operator's git objects and refs in the shared repo — this only fixes file
+ * ownership/permissions so a different-uid member can work at all, it grants no narrower access
+ * than that.
+ *
+ * @param repoRoot the repository whose git store ({@code .git/objects}, {@code refs},
+ * {@code logs}, {@code worktrees}, {@code packed-refs}) needs sharing
+ * @param worktreePath the linked worktree's own directory
+ */
+ void shareWithGroup(String repoRoot, String worktreePath);
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index 7b90c71..7a4f09f 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -1036,6 +1036,35 @@ class FleetConfigTest {
assertEquals(LeadMailbox.DEFAULT_PREFETCH, noEnv.prefetchOrDefault());
}
+ @Test
+ void absentWorktreeGroupLeavesItNull(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("no-worktree-group.yaml");
+ Files.writeString(f, "bind:\n port: 8080\n");
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertNull(cfg.worktreeGroup(), "no worktreeGroup: key → null → GitWorktrees.shareWithGroup is a no-op");
+ }
+
+ @Test
+ void worktreeGroupKeyParses(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("worktree-group.yaml");
+ Files.writeString(f, "bind:\n port: 8080\nworktreeGroup: fleet-workers\n");
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertEquals("fleet-workers", cfg.worktreeGroup());
+ }
+
+ @Test
+ void absentWorktreeGroupSurvivesTheBackCompatConstructorChain() {
+ // fleetd #185 stage 3: withDefaults() (and every pre-existing call site) must not silently
+ // drop a live worktreeGroup by routing through a back-compat constructor that defaults it
+ // to null.
+ FleetConfig cfg = new FleetConfig(null, null, null, Map.of(), null, null, null, null, null,
+ null, null, null, null, null, null, null, null, null, null, null, "fleet-workers");
+ assertEquals("fleet-workers", cfg.withDefaults().worktreeGroup(),
+ "withDefaults() must carry a configured worktreeGroup through unchanged");
+ }
+
@Test
void absentPrimaryBlockLeavesPrimaryNull(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-primary.yaml");
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
index 3f1ac5a..847514f 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
@@ -30,12 +30,19 @@ public final class FakeWorktrees implements Worktrees {
public record PruneCall(String repoRoot, long minAgeMillis) {
}
+ public record ShareCall(String repoRoot, String worktreePath) {
+ }
+
private final List addCalls = new CopyOnWriteArrayList<>();
private final List removeCalls = new CopyOnWriteArrayList<>();
private final List overlayCalls = new CopyOnWriteArrayList<>();
private final List repoRootCalls = new CopyOnWriteArrayList<>();
private final List snapshotCalls = new CopyOnWriteArrayList<>();
private final List pruneCalls = new CopyOnWriteArrayList<>();
+ private final List shareCalls = new CopyOnWriteArrayList<>();
+ /** Tags every {@code overlayParity}/{@code shareWithGroup} call in call order, so a test can
+ * pin that sharing runs after the overlay copy (fleetd #185 stage 3). */
+ private final List overlayShareOrder = new CopyOnWriteArrayList<>();
private final Set existingPaths = ConcurrentHashMap.newKeySet();
private final Set trackedPaths = ConcurrentHashMap.newKeySet();
private final AtomicLong snapshotSeq = new AtomicLong();
@@ -136,6 +143,13 @@ public final class FakeWorktrees implements Worktrees {
}
overlayCalls.add(new OverlayCall(repoRoot, worktreePath, List.copyOf(overlay),
List.copyOf(copied), List.copyOf(skipped)));
+ overlayShareOrder.add("overlay:" + worktreePath);
+ }
+
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ shareCalls.add(new ShareCall(repoRoot, worktreePath));
+ overlayShareOrder.add("share:" + worktreePath);
}
@Override
@@ -203,4 +217,17 @@ public final class FakeWorktrees implements Worktrees {
public SnapshotCall lastSnapshot() {
return snapshotCalls.isEmpty() ? null : snapshotCalls.getLast();
}
+
+ public List shareCalls() {
+ return List.copyOf(shareCalls);
+ }
+
+ public ShareCall lastShare() {
+ return shareCalls.isEmpty() ? null : shareCalls.getLast();
+ }
+
+ /** Call-order tags ({@code "overlay:"}/{@code "share:"}) — see field javadoc. */
+ public List overlayShareOrder() {
+ return List.copyOf(overlayShareOrder);
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
index 74e99eb..c104a46 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
@@ -924,4 +924,103 @@ class GitWorktreesTest {
assertEquals(2, stats.count(), "two snapshot refs are reported");
assertTrue(stats.costBytes() > 0, "the cost of the snapshots is a positive byte count");
}
+
+ /**
+ * fleetd #185 stage 3: a recording {@link java.util.function.Function} test seam stands in for
+ * every process {@link GitWorktrees#shareWithGroup} would run — no real second OS user/group
+ * exists on this host, so these are unit tests against that seam, not a live-group integration
+ * test (out of scope per the ticket).
+ */
+ private static List joined(String[] command) {
+ return List.of(command);
+ }
+
+ /** {@code worktreeGroup} absent ⇒ zero processes spawned and no git config written. */
+ @Test
+ void shareWithGroupIsNoopWhenNoGroupConfigured(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return "";
+ };
+ GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString(), null, _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repo.toString(), repo.resolve("some-worktree").toString());
+
+ assertTrue(recorded.isEmpty(), "no group configured must spawn no process at all: " + recorded);
+ }
+
+ /** A configured group runs {@code git config core.sharedRepository group} first, then
+ * chgrp/chmod/setgid over every path {@link GitWorktrees#shareWithGroup} documents. */
+ @Test
+ void shareWithGroupRunsConfigThenChgrpChmodSetgidPerPath(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ String repoRoot = repo.toString();
+ String worktreePath = repo.resolve("some-worktree").toString();
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repoRoot, worktreePath);
+
+ assertEquals(List.of("git", "-C", repoRoot, "config", "core.sharedRepository", "group"), recorded.get(0),
+ "core.sharedRepository must be set first, so it keeps working after the one-time fix-up");
+
+ for (String dir : List.of(worktreePath, repoRoot + "/.git/objects", repoRoot + "/.git/refs",
+ repoRoot + "/.git/logs", repoRoot + "/.git/worktrees")) {
+ assertTrue(recorded.contains(List.of("chgrp", "-R", "devteam", dir)), "missing chgrp -R for " + dir);
+ assertTrue(recorded.contains(List.of("chmod", "-R", "g+rwX", dir)), "missing chmod -R for " + dir);
+ assertTrue(recorded.contains(List.of("find", dir, "-type", "d", "-exec", "chmod", "g+s", "{}", "+")),
+ "missing setgid find pass for " + dir);
+ }
+ // packed-refs does not exist in a freshly-init'd repo (only git gc / pack-refs creates it) —
+ // tolerated absence, so it must not appear at all: no recursive/-R treatment for a plain file.
+ String packedRefs = repoRoot + "/.git/packed-refs";
+ assertTrue(recorded.stream().noneMatch(c -> c.contains(packedRefs)),
+ "packed-refs is absent here and must be skipped, not chgrp'd: " + recorded);
+ }
+
+ /** {@code packed-refs}, when present, is chgrp/chmod'd but never setgid'd (it is a file, not a dir). */
+ @Test
+ void shareWithGroupIncludesPackedRefsWhenPresent(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ String repoRoot = repo.toString();
+ Path packedRefsPath = repo.resolve(".git/packed-refs");
+ Files.writeString(packedRefsPath, "");
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repoRoot, repo.resolve("some-worktree").toString());
+
+ String packedRefs = packedRefsPath.toString();
+ assertTrue(recorded.contains(List.of("chgrp", "devteam", packedRefs)),
+ "packed-refs must be chgrp'd non-recursively when present: " + recorded);
+ assertTrue(recorded.contains(List.of("chmod", "g+rwX", packedRefs)),
+ "packed-refs must be chmod'd non-recursively when present: " + recorded);
+ assertTrue(recorded.stream().noneMatch(c -> c.contains("find") && c.contains(packedRefs)),
+ "packed-refs (a file) must never get the recursive setgid pass: " + recorded);
+ }
+
+ /** A group that does not exist (or that the operator is not a member of) fails loudly, naming it. */
+ @Test
+ void shareWithGroupThrowsNamingTheGroupWhenChgrpFails(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString(), "cb185-nonexistent-group-zz");
+ String wt = new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-185-share", "HEAD");
+
+ WorktreeException e = assertThrows(WorktreeException.class,
+ () -> gitWorktrees.shareWithGroup(repo.toString(), wt));
+ assertTrue(e.getMessage().contains("cb185-nonexistent-group-zz"),
+ "exception must name the missing/refused group: " + e.getMessage());
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
index 89bfc87..08f44b6 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
@@ -148,6 +148,10 @@ class SessionManagerTest {
return 0;
}
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ }
+
List removeCalls() {
return List.copyOf(removeCalls);
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
index 5e1b0eb..b6656e2 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
@@ -163,6 +163,37 @@ class WorktreeSessionManagerTest {
"tracked copied paths are --skip-worktree'd");
}
+ /**
+ * fleetd #185 stage 3, THE TRAP: {@code overlayParity} copies more files into the worktree
+ * AFTER {@code add} returns, so {@code shareWithGroup} must run after it, not folded into
+ * {@code add()} — otherwise every overlay file lands operator-owned and unwritable for a
+ * different-uid member, with a green test suite hiding it.
+ */
+ @Test
+ void shareWithGroupRunsAfterOverlayParityNotBeforeIt() {
+ FakeHerdr herdr = new FakeHerdr();
+ FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt")
+ .track(".envrc");
+ SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
+
+ MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
+ new WorktreeRequest("cb-185", null));
+
+ assertEquals(1, worktrees.overlayCalls().size(), "overlayParity ran exactly once");
+ assertEquals(1, worktrees.shareCalls().size(), "shareWithGroup ran exactly once");
+ FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay();
+ FakeWorktrees.ShareCall share = worktrees.lastShare();
+ assertEquals(s.worktree(), overlay.worktreePath());
+ assertEquals(s.worktree(), share.worktreePath());
+
+ List order = worktrees.overlayShareOrder();
+ int overlayIndex = order.indexOf("overlay:" + s.worktree());
+ int shareIndex = order.indexOf("share:" + s.worktree());
+ assertTrue(overlayIndex >= 0 && shareIndex >= 0, "both calls must be recorded: " + order);
+ assertTrue(overlayIndex < shareIndex,
+ "shareWithGroup MUST run after overlayParity, not before/inside add(): " + order);
+ }
+
@Test
void releaseRemovesWorktreeButDoesNotDeleteBranch() {
FakeHerdr herdr = new FakeHerdr();