#285: seedTrustDialog refuses under memberHerdrSocket instead of seeding fleetd's own home #291

Closed
agent wants to merge 0 commits from worker/fix-285-trust-seed-8f3565-10 into main
Member

Investigation (step 1) — the combination IS reachable

Confirmed the config side, not the deployed fleetd.yaml (I cannot read it):

  • memberHerdrSocket is a fleet-level field (FleetConfig); configDir is a
    per-profile field (FleetConfig.Profile). Nothing in FleetConfig.load's
    validation chain (rejectUnknown*, rejectMalformedProfilePatterns, etc.) ties the
    two together, and configDir's own javadoc explicitly documents it as nullable with
    no constraint tied to memberHerdrSocket.
  • seedTrustDialog was private static, called unconditionally at buildLaunch:290
    — before argvWithFleet/writeCharterFile (which DOES check
    memberHerdrSocketConfigured()) ever runs at :299. So even in the case where
    writeCharterFile would separately refuse the spawn (missing worktreeRoot/
    worktreeGroup), seedTrustDialog has already written to fleetd's own home by
    the time that refusal fires.

So: a claude profile with memberHerdrSocket: set (fleet-level) and configDir:
unset (that profile) is not blocked anywhere in the config-load or spawn path. Whether
it's live on the deployed fleet today, I can't say — the ticket's own citation
("memberHerdrSocket ABSENT (today's only live mode)" in other javadoc) suggests it
may not be exercised in production right now — but it is reachable in code, which is
what step 1 asked me to establish.

Fix (step 2)

seedTrustDialog (ClaudeCodeLauncher.java) is no longer static, so it can call
memberHerdrSocketConfigured()/memberGroup() (inherited from HerdrPeerLauncher,
package-private, same as writeCharterFile already uses).

Under memberHerdrSocket, before touching any file:

  • if configDir is unset, or worktreeGroup is unset, refuse the spawn
    (IllegalStateException) naming exactly which one is missing — mirrors
    writeCharterFile's "refuse, don't degrade" decision and message shape.
  • otherwise, write <configDir>/.claude.json exactly as before, then chgrp+chmod the
    file group-readable (rw-r-----) via a new shareTrustJsonWithGroup, mirroring
    EnvAllowListScrub.shareWithGroup's per-file mode. Only the FILE is touched, never
    configDir itself — that directory isn't fleetd-managed (unlike worktreeRoot), so
    its own traversal permissions stay the operator's setup, same as today.
  • the share step is deliberately outside the existing best-effort
    catch (Exception e) swallow: a group that fails to resolve after a successful
    write must fail loudly (same as EnvAllowListScrub.shareWithGroup already does for
    the charter file), not silently leave an unreadable file behind.

With memberHerdrSocket absent (today's only live mode), every branch is
byte-identical to before this fix — proven by a dedicated regression test (see below).

Step 3 — red/green proof

  1. Committed the fix + tests, ran mvn clean install → green (see below).

  2. Reverted only ClaudeCodeLauncher.java (git diff > patch + git checkout --,
    never git stash), kept the test file, reran
    mvn test -Dtest=ClaudeCodeLauncherTest:

    Tests run: 109, Failures: 3, Errors: 0, Skipped: 0
    

    Exact failures against the unpatched code:

    • seedTrustDialogUnderMemberHerdrSocketSharesTheFileWithTheConfiguredGroup:
      expected: <rw-r-----> but was: <rw-------> — the file is written but stays
      owner-only, unreadable by the member's OS user.
    • seedTrustDialogUnderMemberHerdrSocketRefusesWhenWorktreeGroupUnset:
      nothing may be written when the file cannot be shared with the member's group ==> expected: <false> but was: <true> — the file gets silently written even
      with no way to ever share it.
    • seedTrustDialogUnderMemberHerdrSocketRefusesWhenConfigDirUnset: the refusal
      assertion failed because the unpatched seedTrustDialog doesn't refuse at all —
      the spawn instead failed later, for an unrelated reason
      (writeCharterFile's pre-existing worktreeRoot refusal), proving the trust
      seed itself wrote silently to the (redirected) fake home first.
  3. Restored the fix (git apply the saved patch). Reran mvn clean install:

    Tests run: 1287, Failures: 0, Errors: 0, Skipped: 0
    BUILD SUCCESS
    

Out of scope — report only (per the ticket)

  • deleteOnExit-only cleanup (writeCharterFile/OpenCodeLauncher.writeConfig
    vs. spawnInternal's explicit ZDOTDIR delete on spawn failure): confirmed —
    deleteOnExit only fires at JVM shutdown, so for a long-running daemon these
    per-spawn temp dirs/files are not cleaned up until the daemon restarts; neither
    method has a synchronous teardown path the way the ZDOTDIR scrub does.
  • writeIdeOverlay skipping memberHerdrSocketConfigured(): safe — confirmed
    GitWorktrees.shareWithGroup already chgrp's/chmods the provisioned worktree
    itself (cwd) at creation time, independent of this check, so the overlay file
    written into cwd inherits that sharing regardless.

Build

cd fleetd && mvn clean install (unpiped): Tests run: 1287, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS.

Files changed

  • fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java
  • fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
## Investigation (step 1) — the combination IS reachable Confirmed the config side, not the deployed `fleetd.yaml` (I cannot read it): - `memberHerdrSocket` is a **fleet-level** field (`FleetConfig`); `configDir` is a **per-profile** field (`FleetConfig.Profile`). Nothing in `FleetConfig.load`'s validation chain (`rejectUnknown*`, `rejectMalformedProfilePatterns`, etc.) ties the two together, and `configDir`'s own javadoc explicitly documents it as nullable with no constraint tied to `memberHerdrSocket`. - `seedTrustDialog` was `private static`, called unconditionally at `buildLaunch:290` — before `argvWithFleet`/`writeCharterFile` (which DOES check `memberHerdrSocketConfigured()`) ever runs at `:299`. So even in the case where `writeCharterFile` would separately refuse the spawn (missing `worktreeRoot`/ `worktreeGroup`), `seedTrustDialog` has *already* written to fleetd's own home by the time that refusal fires. So: a `claude` profile with `memberHerdrSocket:` set (fleet-level) and `configDir:` unset (that profile) is not blocked anywhere in the config-load or spawn path. Whether it's live on the deployed fleet today, I can't say — the ticket's own citation ("`memberHerdrSocket` ABSENT (today's only live mode)" in other javadoc) suggests it may not be exercised in production right now — but it is reachable in code, which is what step 1 asked me to establish. ## Fix (step 2) `seedTrustDialog` (`ClaudeCodeLauncher.java`) is no longer `static`, so it can call `memberHerdrSocketConfigured()`/`memberGroup()` (inherited from `HerdrPeerLauncher`, package-private, same as `writeCharterFile` already uses). Under `memberHerdrSocket`, before touching any file: - if `configDir` is unset, or `worktreeGroup` is unset, **refuse the spawn** (`IllegalStateException`) naming exactly which one is missing — mirrors `writeCharterFile`'s "refuse, don't degrade" decision and message shape. - otherwise, write `<configDir>/.claude.json` exactly as before, then chgrp+chmod the file group-readable (`rw-r-----`) via a new `shareTrustJsonWithGroup`, mirroring `EnvAllowListScrub.shareWithGroup`'s per-file mode. Only the FILE is touched, never `configDir` itself — that directory isn't fleetd-managed (unlike `worktreeRoot`), so its own traversal permissions stay the operator's setup, same as today. - the share step is deliberately **outside** the existing best-effort `catch (Exception e)` swallow: a group that fails to resolve after a successful write must fail loudly (same as `EnvAllowListScrub.shareWithGroup` already does for the charter file), not silently leave an unreadable file behind. With `memberHerdrSocket` **absent** (today's only live mode), every branch is byte-identical to before this fix — proven by a dedicated regression test (see below). ## Step 3 — red/green proof 1. Committed the fix + tests, ran `mvn clean install` → green (see below). 2. Reverted *only* `ClaudeCodeLauncher.java` (`git diff > patch` + `git checkout --`, never `git stash`), kept the test file, reran `mvn test -Dtest=ClaudeCodeLauncherTest`: ``` Tests run: 109, Failures: 3, Errors: 0, Skipped: 0 ``` Exact failures against the unpatched code: - `seedTrustDialogUnderMemberHerdrSocketSharesTheFileWithTheConfiguredGroup`: `expected: <rw-r-----> but was: <rw------->` — the file is written but stays owner-only, unreadable by the member's OS user. - `seedTrustDialogUnderMemberHerdrSocketRefusesWhenWorktreeGroupUnset`: `nothing may be written when the file cannot be shared with the member's group ==> expected: <false> but was: <true>` — the file gets silently written even with no way to ever share it. - `seedTrustDialogUnderMemberHerdrSocketRefusesWhenConfigDirUnset`: the refusal assertion failed because the unpatched `seedTrustDialog` doesn't refuse at all — the spawn instead failed later, for an unrelated reason (`writeCharterFile`'s pre-existing `worktreeRoot` refusal), proving the trust seed itself wrote silently to the (redirected) fake home first. 3. Restored the fix (`git apply` the saved patch). Reran `mvn clean install`: ``` Tests run: 1287, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` ## Out of scope — report only (per the ticket) - **`deleteOnExit`-only cleanup** (`writeCharterFile`/`OpenCodeLauncher.writeConfig` vs. `spawnInternal`'s explicit ZDOTDIR delete on spawn failure): confirmed — `deleteOnExit` only fires at JVM shutdown, so for a long-running daemon these per-spawn temp dirs/files are not cleaned up until the daemon restarts; neither method has a synchronous teardown path the way the ZDOTDIR scrub does. - **`writeIdeOverlay` skipping `memberHerdrSocketConfigured()`**: safe — confirmed `GitWorktrees.shareWithGroup` already chgrp's/chmods the provisioned worktree itself (`cwd`) at creation time, independent of this check, so the overlay file written into `cwd` inherits that sharing regardless. ## Build `cd fleetd && mvn clean install` (unpiped): `Tests run: 1287, Failures: 0, Errors: 0, Skipped: 0` — `BUILD SUCCESS`. ## Files changed - `fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java` - `fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java`
agent added 1 commit 2026-09-04 05:56:19 +02:00
#285: seedTrustDialog refuses under memberHerdrSocket instead of seeding fleetd's own home
CI / contract (pull_request) Successful in 49s
CI / build (pull_request) Successful in 3m35s
7e49c6eca2
seedTrustDialog gated only on isProvisionedWorktree(cwd) and, being static, could not
see memberHerdrSocketConfigured() — unlike its sibling writeCharterFile, which already
refuses the spawn when it cannot place a file where a different-uid member can read it.
Under memberHerdrSocket + configDir unset, seedTrustDialog wrote fleetd's OWN
~/.claude.json while believing it was seeding the member's, reintroducing the fleetd
#149 failure (interactive trust dialog, no fleet_reply, silent readiness timeout) for
this one config combination.

Makes seedTrustDialog an instance method so it can see memberHerdrSocketConfigured()
and memberGroup(), and applies writeCharterFile's "refuse, don't degrade" rule: under
memberHerdrSocket it now requires both configDir and worktreeGroup before touching any
file, naming exactly which is missing, and shares the written .claude.json group-
readable (rw-r-----) via a new shareTrustJsonWithGroup so the member's OS user can
actually open it. The memberHerdrSocket-absent path (today's only live mode) is
unchanged.
Owner

Merged locally. One change on top of the branch: the new shareTrustJsonWithGroup helper moved into EnvAllowListScrub as shareFileWithGroup(Path, String). Reasoning is on #285.

Merged locally. One change on top of the branch: the new `shareTrustJsonWithGroup` helper moved into `EnvAllowListScrub` as `shareFileWithGroup(Path, String)`. Reasoning is on #285.
ltms closed this pull request 2026-09-04 06:14:13 +02:00
Some checks are pending
CI / contract (pull_request) Successful in 49s
CI / build (pull_request) Successful in 3m35s

Pull request closed

Sign in to join this conversation.