91 lines
6.3 KiB
Markdown
91 lines
6.3 KiB
Markdown
# 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 in
|
|
`member/`, which I reviewed in full instead.
|
|
- Worktree-provisioning permissions (whether `worktreeGroup` sharing actually covers `cwd`) live
|
|
outside this package and were not verified.
|