diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java b/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java index 6480720..b60e1a7 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java @@ -143,17 +143,24 @@ public final class EnvAllowListScrub { } /** - * chgrp/chmod-equivalent over the freshly generated directory and the startup files already - * written into it: owner keeps full access, {@code group} gets traverse+read on the directory - * ({@code rwxr-x---}, so a login shell under that group can find and source the files) and - * read-only on each file ({@code rw-r-----}) — deliberately no group WRITE anywhere, since a - * member never needs to add or change fleetd's own generated scrub. (The scrub script's own - * report write inside the pane consequently fails closed rather than open — see {@code - * scrub.zsh}'s trailing {@code 2>/dev/null} — which {@link + * chgrp/chmod-equivalent over a freshly generated directory and the flat files already written + * into it: owner keeps full access, {@code group} gets traverse+read on the directory ({@code + * rwxr-x---}, so a member process — a login shell reading it via {@code ZDOTDIR}, or another + * process simply opening a file under it — running under that group can find and read the + * files) and read-only on each file ({@code rw-r-----}) — deliberately no group WRITE anywhere, + * since a member never needs to add or change what fleetd generated. (For the ZDOTDIR scrub + * specifically, this also means the scrub script's own report write inside the pane fails + * closed rather than open — see {@code scrub.zsh}'s trailing {@code 2>/dev/null} — which {@link * dev.ltms.fleet.member.HerdrPeerLauncher#releaseZdotdir} already treats as "cannot be * confirmed to have run" rather than success.) + * + *
Package-private and named generically on purpose: fleetd #213 built this for the ZDOTDIR + * scrub directory, and fleetd #219 reuses it verbatim for {@link + * dev.ltms.fleet.member.OpenCodeLauncher}'s ephemeral {@code opencode.json} directory — both are + * "a fleetd-generated directory of flat files that a different-uid member process must read but + * never write," so the sharing mechanism is shared rather than copied a second time. */ - private static void shareWithGroup(Path dir, String group) { + static void shareWithGroup(Path dir, String group) { try { GroupPrincipal principal = dir.getFileSystem().getUserPrincipalLookupService() .lookupPrincipalByGroupName(group); @@ -164,11 +171,11 @@ public final class EnvAllowListScrub { } } } catch (IOException e) { - throw new UncheckedIOException("cannot share generated ZDOTDIR " + dir + " with group '" + throw new UncheckedIOException("cannot share generated directory " + dir + " with group '" + group + "' — the group must exist, and the fleetd operator (" + System.getProperty("user.name") + ") must be a member of it", e); } catch (UnsupportedOperationException e) { - throw new UncheckedIOException("cannot share generated ZDOTDIR " + dir + " with group '" + throw new UncheckedIOException("cannot share generated directory " + dir + " with group '" + group + "' — this filesystem does not support POSIX group ownership", new IOException(e)); } diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index 88cbcf6..4f4f4ee 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -1275,8 +1275,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * provisioning a worktree that will exist regardless, whereas an unconfigured value here means * fleetd has no operator-endorsed location to put a credential-bearing directory a different OS * user must reach, so falling back to the overlay is the honest answer, not a guess. + * + *
Package-private (fleetd #219) so {@link OpenCodeLauncher} can reuse the exact same + * "different OS user, put it under worktreeRoot instead of java.io.tmpdir" resolution for its + * own ephemeral {@code opencode.json} directory, rather than re-reading {@code config} a second + * time with a second copy of this null/blank handling. */ - private Path memberScrubParentDir() { + Path memberScrubParentDir() { FleetConfig cfg = config == null ? null : config.get(); if (cfg == null || cfg.worktreeRoot() == null || cfg.worktreeRoot().isBlank()) { return null; @@ -1289,8 +1294,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * the group {@link dev.ltms.fleet.session.Worktrees#shareWithGroup} already establishes for * provisioned worktrees, rather than a second group key — see {@link * #applyEnvironmentAllowListPolicy}. + * + *
Package-private (fleetd #219) — reused by {@link OpenCodeLauncher} alongside {@link + * #memberScrubParentDir()}; see that method's javadoc. */ - private String memberGroup() { + String memberGroup() { FleetConfig cfg = config == null ? null : config.get(); if (cfg == null || cfg.worktreeGroup() == null || cfg.worktreeGroup().isBlank()) { return null; @@ -1463,8 +1471,12 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * through (every production {@code HerdrPeerLauncher} does; a handful of older tests do not) — * treated the same as "not configured", which is the correct, permissive default: it is exactly * today's single-daemon behaviour. + * + *
Package-private (fleetd #219) — {@link OpenCodeLauncher} reuses this same gate to decide + * where its own ephemeral {@code opencode.json} directory (site 1) and its opencode session + * discovery (site 2) may run, rather than re-deriving "is this a multi-uid fleet" a second way. */ - private boolean memberHerdrSocketConfigured() { + boolean memberHerdrSocketConfigured() { if (config == null) { return false; } diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index 7f75538..7cc8187 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -20,6 +20,8 @@ import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; import java.util.function.Function; import java.util.function.LongSupplier; import java.util.function.Supplier; @@ -215,7 +217,43 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { return Path.of(System.getProperty("java.io.tmpdir")); } - /** The default opencode storage root: {@code ~/.local/share/opencode} (the XDG data dir). */ + /** + * The default opencode storage root: {@code ~/.local/share/opencode} (the XDG data dir) — + * always FLEETD's OWN {@code user.home}, whichever OS user runs the daemon. + * + *
fleetd #219 site 2 — a decision, not a patch. Under {@code memberHerdrSocket:} the + * member pane runs as a different OS user, and opencode writes {@code opencode.db} + * under that user's {@code $HOME}, not fleetd's. Scanning fleetd's own {@code + * user.home} is therefore looking in the wrong place — a wrong-LOCATION failure, not a + * wrong-PERMISSION one like site 1, and it fails quietly: {@link + * SessionAwareHandle#agentSessionId()} would keep returning {@code null} forever, which reads + * as "opencode does not support resume" rather than "fleetd looked in the wrong home." fleetd + * #209 is the reason that silence is unacceptable. + * + *
Three ways to close the gap were weighed: + *
Option 3 is taken — the one this ticket says to default to when unsure. {@link + * OpenCodeLauncher#spawn} routes {@link SessionAwareHandle#agentSessionId()} through {@link + * HerdrPeerLauncher#memberHerdrSocketConfigured()} before ever calling {@link + * OpenCodeSessionDiscovery#sessionIdForDirectory}, so under {@code memberHerdrSocket} the + * database at this root is never even opened, and one WARN per launcher instance names the gap + * instead of the {@code null} return reading as "unsupported." Capability advertising is + * unaffected: {@link #capabilities()} always includes {@code SESSION_RESUME}, since {@code + * memberHerdrSocket} absent (today's only live mode) is unchanged by this decision. + */ private static Path defaultDiscoveryRoot() { return Path.of(System.getProperty("user.home"), ".local", "share", "opencode"); } @@ -331,7 +369,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { */ private Path writeConfig(FleetConfig.Profile cfg, String charterText, String cwd) { try { - Path dir = Files.createTempDirectory(configRoot, "fleetd-opencode-"); + Path dir = Files.createTempDirectory(configParentDir(), "fleetd-opencode-"); dir.toFile().deleteOnExit(); ObjectNode root = JSON.createObjectNode(); @@ -402,6 +440,14 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { // carries operator-supplied values (URL, model id, api key), so escaping must be real. Files.writeString(cfgFile, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root)); cfgFile.toFile().deleteOnExit(); + if (memberHerdrSocketConfigured()) { + // fleetd #219: the same "different OS user" gap fleetd #213 closed for the ZDOTDIR + // scrub — share read-only with worktreeGroup rather than leaving the directory under + // fleetd's own 0700 java.io.tmpdir, where the member's OS user could not even + // traverse it. memberGroup() cannot be null here: configParentDir() above already + // refused this spawn if either worktreeRoot or worktreeGroup was missing. + EnvAllowListScrub.shareWithGroup(dir, memberGroup()); + } return cfgFile; } catch (IOException e) { throw new UncheckedIOException( @@ -409,6 +455,57 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { } } + /** + * fleetd #219 site 1: where {@link #writeConfig} creates its per-spawn directory. + * + *
Unlike the ZDOTDIR scrub, a missing {@code worktreeRoot}/{@code worktreeGroup} here
+ * REFUSES the spawn instead of degrading. The ZDOTDIR scrub is a credential CONTROL: a
+ * degraded control (CB-596's sentinel overlay) is still worth having. This config file is not a
+ * control — it is the ONLY way the member learns where the bridge MCP lives. Writing it
+ * somewhere the member cannot read would not degrade anything; it would spawn a member that
+ * occupies a pane and never becomes deliverable, since {@code fleet_send} waits ~60s on the
+ * readiness gate and then fails with nothing pointing at a temp directory as the cause. Refusing
+ * up front, with a message that names the missing config key, is the honest failure — an
+ * undeliverable member is not a working spawn either way, so nothing is lost by refusing loudly
+ * instead of failing silently later.
+ *
+ * @throws IllegalStateException when {@code memberHerdrSocket} is configured but {@code
+ * worktreeRoot} and/or {@code worktreeGroup} is not
+ */
+ private Path configParentDir() {
+ if (!memberHerdrSocketConfigured()) {
+ return configRoot;
+ }
+ Path root = memberScrubParentDir();
+ String group = memberGroup();
+ if (root == null || group == null) {
+ throw new IllegalStateException("memberHerdrSocket is configured, so opencode's config "
+ + "directory (opencode.json + member charter) must be placed where the member's "
+ + "OS user can read it — worktreeRoot, shared via worktreeGroup — but "
+ + (root == null ? "worktreeRoot" : "worktreeGroup") + " is not configured. "
+ + "Refusing to spawn rather than write a config the member cannot read: that "
+ + "member would occupy a pane and never become deliverable, with nothing "
+ + "pointing at the real cause. Configure both worktreeRoot and worktreeGroup to "
+ + "enable opencode member spawns under memberHerdrSocket.");
+ }
+ return root;
+ }
+
/**
* Declare a custom OpenAI-compatible provider so the worker talks to a pinned endpoint (a local
* vLLM, say) instead of opencode's default gateway (CB-508).
@@ -517,11 +614,16 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
return afterScheme.contains("/") ? trimmed : trimmed + "/v1";
}
+ /** One WARN per launcher instance for the fleetd #219 site-2 discovery-unavailable gap. */
+ private final AtomicBoolean discoveryUnavailableWarned =
+ new AtomicBoolean();
+
/** Add lazy on-disk session discovery to the base handle. */
@Override
public PeerHandle spawn(SpawnRequest req) {
PeerHandle inner = super.spawn(req);
- return new SessionAwareHandle(inner, discovery, effectiveCwd(req));
+ return new SessionAwareHandle(inner, discovery, effectiveCwd(req),
+ this::memberHerdrSocketConfigured, discoveryUnavailableWarned);
}
/**
@@ -536,11 +638,17 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
private final PeerHandle delegate;
private final OpenCodeSessionDiscovery discovery;
private final String cwd;
+ private final BooleanSupplier discoveryUnavailable;
+ private final AtomicBoolean discoveryUnavailableWarned;
- SessionAwareHandle(PeerHandle delegate, OpenCodeSessionDiscovery discovery, String cwd) {
+ SessionAwareHandle(PeerHandle delegate, OpenCodeSessionDiscovery discovery, String cwd,
+ BooleanSupplier discoveryUnavailable,
+ AtomicBoolean discoveryUnavailableWarned) {
this.delegate = delegate;
this.discovery = discovery;
this.cwd = cwd;
+ this.discoveryUnavailable = discoveryUnavailable;
+ this.discoveryUnavailableWarned = discoveryUnavailableWarned;
}
@Override
@@ -565,6 +673,22 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
@Override
public String agentSessionId() {
+ // fleetd #219 site 2: under memberHerdrSocket the member pane runs as a different OS
+ // user, so opencode.db lives under THAT user's $HOME, not the one discoveryRoot was
+ // built from (see OpenCodeLauncher#defaultDiscoveryRoot's javadoc for the full
+ // reasoning). Scanning fleetd's own $HOME under that config would only ever find "no
+ // row" and read as "resume unsupported" — declare it unavailable instead, once, loudly.
+ if (discoveryUnavailable.getAsBoolean()) {
+ if (discoveryUnavailableWarned.compareAndSet(false, true)) {
+ log.warn("opencode session discovery unavailable: memberHerdrSocket is "
+ + "configured, so opencode's on-disk session database lives under the "
+ + "MEMBER's own $HOME, not fleetd's ({}) — agentSessionId will stay null "
+ + "for every opencode member under this config, and SESSION_RESUME "
+ + "cannot be honored (fleetd #209/#219).",
+ System.getProperty("user.home"));
+ }
+ return null;
+ }
// Lazy + retried, never a spawn-time blocker: opencode writes the session record only
// when the session is first persisted, so null here is the correct interim answer and
// the caller re-calls later (each call re-scans, picking up a record that has since
diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
index 29878f6..d1028fe 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
@@ -1,5 +1,9 @@
package dev.ltms.fleet.member;
+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;
@@ -14,9 +18,13 @@ import dev.ltms.fleet.peer.PeerUnreachableException;
import dev.ltms.fleet.peer.SpawnRequest;
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.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFilePermissions;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
@@ -25,6 +33,7 @@ import java.util.concurrent.Future;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* The opencode adapter's launch build: a file-based MCP mount + reply-charter instructions (no
@@ -610,4 +619,196 @@ class OpenCodeLauncherTest {
assertTrue(json.path("mcp").path("intellij").isMissingNode(),
"no IDE server when ideMcpUrl is unset");
}
+
+ // --- fleetd #219: config root + discovery root under memberHerdrSocket ------------------------
+
+ /** 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 OpenCodeLauncher serviceWithConfig(FakeHerdr herdr, Path configRoot, Path discoveryRoot,
+ FleetConfig.Profile cfg, Supplier