6.3 KiB
Spawn-boundary audit — member/ launchers
Scope: fleetd/src/main/java/dev/ltms/fleet/member/ (ClaudeCodeLauncher, OpenCodeLauncher,
HerdrPeerLauncher, CompositePeerLauncher, EnvAllowListScrub, MemberEnvAllowList,
MemberCredentialPolicyView, OpenCodeSessionDiscovery). No worker/ package exists — the
launcher classes live entirely under member/, so I read that whole directory instead.
Main finding
1. fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java:548-632 (seedTrustDialog)
Issue. seedTrustDialog writes the workspace-trust seed to configDir/.claude.json, or to
~/.claude.json (fleetd's own user.home) when configDir is unset. It gates only on
isProvisionedWorktree(cwd) (line 549). It never checks memberHerdrSocketConfigured() and,
being a static method, cannot reach that instance state even if it wanted to.
Compare this to its sibling in the very same class, writeCharterFile (line 788, an instance
method): that one explicitly checks memberHerdrSocketConfigured() (line 790) and, when true,
routes the file under memberScrubParentDir() (worktreeRoot) and calls
EnvAllowListScrub.shareWithGroup(dir, group) (line 813) — or refuses the spawn outright when
worktreeRoot/worktreeGroup isn't configured (lines 798-807), rather than hand the member a
path its OS user cannot read. OpenCodeLauncher.writeConfig/configParentDir (lines 422-508,
542-559) do the identical thing for opencode.json. seedTrustDialog is the one cross-process,
member-facing file write in this class that skips that treatment entirely.
It also makes the miss worse on its own: writeAtomically (line 696) calls
copyPosixPermissionsIfPresent (line 710), which deliberately preserves the existing file's
POSIX permissions — and Claude Code ships .claude.json at 0600 (see the javadoc at line 681).
So even where configDir happens to point at an already-shared directory, the seeded file itself
is written owner-only, unreadable by a different OS user.
Spawn path that reaches it. Any claude profile that (a) sets memberHerdrSocket: (real,
validated config — see memberHerdrSocketConfigured() at HerdrPeerLauncher.java:1667) and
(b) does not set configDir:, spawned with worktree:true (so isProvisionedWorktree(cwd)
is true and the method proceeds). buildLaunch calls seedTrustDialog(cfg.configDir(), spec.cwd())
unconditionally at line 290, before the pane is ever created.
What goes wrong. Under that config, seedTrustDialog writes the trust entry into fleetd's
own ~/.claude.json — the daemon's OS user's home. But the actual Claude Code process starts
under the member's different OS user (that's the whole point of memberHerdrSocket), with its
own $HOME, and (with configDir unset) reads ~/.claude.json relative to that home — a file
this method never touches. The seed lands nowhere the spawned process will ever look. The result
is exactly the fleetd #149 incident this method exists to prevent: the member sits on Claude
Code's interactive, un-timed "Is this a project you trust?" dialog forever, never mounts the
bridge MCP, and never calls fleet_reply; the CB-306 spawn-readiness gate eventually times it
out as an unexplained "did not reach injectable state."
Confidence. High that the code path is exactly as described — I traced buildLaunch →
seedTrustDialog → writeAtomically/copyPosixPermissionsIfPresent directly, and confirmed
writeCharterFile's contrasting memberHerdrSocketConfigured() gate at the same class's
line 790. Medium on operational reachability today: several javadocs elsewhere in
HerdrPeerLauncher describe "memberHerdrSocket ABSENT" as "today's only live mode," so this
gap may not be hit by the currently-deployed fleet — but it is a real, config-reachable hole in a
feature the codebase otherwise treats as first-class (four other defects fixed for exactly this
seam: fleetd #213, #219, #222 in this same file/its sibling). Direction of harm: an operator who
turns memberHerdrSocket on gets a silently-hung claude member instead of a working one — no
data loss, no credential leak, but a resource stuck occupying a pane with no diagnosis pointing
at the real cause (this is the "silent" failure mode the class's other methods were deliberately
rewritten to avoid).
Secondary findings
2. fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java:788-818 (writeCharterFile) and OpenCodeLauncher.java:422-508 (writeConfig)
Both create a per-spawn temp file/directory (role+reply charter file; opencode.json +
member-charter + IDE-rules files) and rely only on deleteOnExit for cleanup. Unlike the CB-633
ZDOTDIR directory, which spawnInternal (HerdrPeerLauncher.java:519-528) explicitly deletes
with EnvAllowListScrub.deleteRecursively(zdotdir) when spawnInTab/spawnAsPane throws after
buildLaunch succeeded, nothing tears down the charter file or the opencode config directory on
that same failure path — they leak until JVM exit, which for a long-running daemon can be
effectively never. Symmetric between the two launchers (not an asymmetry), so it is secondary, but
it is the same "created at spawn, not cleaned up if spawn then fails" shape the brief called out.
Confidence: high that the code omits it (read both methods and the spawnInternal catch block);
low-medium severity — disk clutter in a temp/worktree-scrub directory, not a security or
correctness issue on the member itself.
3. Shape noted, not investigated. ClaudeCodeLauncher.writeIdeOverlay (line 438) also skips
memberHerdrSocketConfigured(), but unlike seedTrustDialog it writes into cwd itself (the
worktree root), which — if worktree provisioning already shares the worktree with the member's OS
user under worktreeGroup (plausible, but I did not verify the worktree-provisioning code, which
is outside this scope) — would already be readable/writable by the member without any extra
routing. Flagging the shape rather than a finding since I have not confirmed worktree provisioning
outside member/.
Out of scope, noted only
- The
worker/directory named in the brief does not exist; all launcher classes live inmember/, which I reviewed in full instead. - Worktree-provisioning permissions (whether
worktreeGroupsharing actually coverscwd) live outside this package and were not verified.