fleetd#222: put the claude-code charter file where the member can read it #229

Merged
ltms merged 2 commits from worker/cb222-charter-tmpdir-17f013-1 into main 2026-09-02 02:56:36 +02:00
Member

Fixes fleetd#222.

The defect

ClaudeCodeLauncher#writeCharterFile called Files.createTempFile("fleetd-role-charter-", ".md")
with no directory argument, which resolves against fleetd's own java.io.tmpdir (on macOS: the
per-user $TMPDIR under /var/folders/..., mode 0700, and the file itself 0600). Under
memberHerdrSocket: the member pane runs as a DIFFERENT OS user and cannot read that directory —
and since #220 the charter file is the ONLY delivery path for --append-system-prompt-file, always,
not merely the fallback it used to be. So every claude-code member spawned under
memberHerdrSocket: would have been handed a charter path it could not read.

The fix

Reused the exact mechanism #219 built for OpenCodeLauncher#configParentDir() — no new abstraction:

  • HerdrPeerLauncher#memberHerdrSocketConfigured() gates the branch.
  • memberScrubParentDir() / memberGroup() resolve worktreeRoot: / worktreeGroup:.
  • EnvAllowListScrub.shareWithGroup(dir, group) (the #213/#219 mechanism) shares a fresh per-spawn
    directory read-only with the member's group.

writeCharterFile now branches:

  • memberHerdrSocket absent (today's only live mode): byte-identical to before —
    Files.createTempFile("fleetd-role-charter-", ".md") with no directory argument, still resolved
    against java.io.tmpdir.
  • memberHerdrSocket present: a fresh per-spawn directory is created under worktreeRoot (never
    java.io.tmpdir), the charter file goes inside it, then the whole directory is shared with
    worktreeGroup via shareWithGroup.

Followed #219's REFUSAL decision, not #213's degrade decision: a charter is not a credential
control that can degrade gracefully, it is the member's turn contract (the rule that ends every turn
with fleet_reply). So when memberHerdrSocket is configured but worktreeRoot and/or
worktreeGroup is missing, the spawn is refused with an IllegalStateException naming the missing
key — never a member launched without a readable charter.

Criterion 3 — how I convinced myself the "absent" path is byte-identical

The absent branch is the literal original code, unchanged: Files.createTempFile("fleetd-role-charter-", ".md") with no directory argument. I did not introduce a stand-in directory or reroute it through a
newly-computed "default" path — I kept the exact original call in its own if branch so there is no
behavioral difference to introduce. I also added a regression test
(memberHerdrSocketAbsentStaysUnderJavaIoTmpdirEvenWithALiveConfigSupplier) that spawns with a
live, non-null config supplier (not just config == null, which every pre-existing test already
exercises) and asserts the resulting charter file still lands directly under java.io.tmpdir with
the same fleetd-role-charter-* naming (no wrapping directory).

Tests added (all watched RED before the fix, GREEN after)

In ClaudeCodeLauncherTest:

  1. memberHerdrSocketWithWorktreeRootAndGroupPutsCharterUnderWorktreeRootAndSharesIt — charter
    directory lands under worktreeRoot, permissions are rwxr-x---/rw-r-----.
  2. memberHerdrSocketWithoutWorktreeRootOrGroupRefusesTheSpawn — refuses, names worktreeRoot,
    nothing started (agent.start never called).
  3. memberHerdrSocketWithWorktreeRootButNoGroupRefusesTheSpawn — worktreeRoot alone insufficient,
    names worktreeGroup.
  4. memberHerdrSocketAbsentStaysUnderJavaIoTmpdirEvenWithALiveConfigSupplier — criterion 3 above.

I proved this by stashing the production change, running these 4 tests against the pre-fix
ClaudeCodeLauncher.java, and confirming 3 of 4 failed (test 4, the "absent" case, correctly passed
even pre-fix, since that behavior was never meant to change). Then I restored the fix and reran —
all 4 green.

Measured: what claude-code does with an unreadable --append-system-prompt-file

Ran the real binary (claude 2.1.258) directly, in -p/print mode with a bogus ANTHROPIC_API_KEY
so any success would require it to get past this check first:

  • Unreadable file (chmod 000, still owner-unreadable — a reasonable stand-in for
    "different OS user"): Error reading append system prompt file: EACCES: permission denied, open '<path>', exit code 1, immediately — no network call, no hang.
  • Nonexistent file: Error: Append system prompt file not found: <path>, exit code 1,
    immediately.

Conclusion: it is the LOUD failure, not the silent one. claude-code checks the file before
touching auth or network, so the pre-fix bug would make the herdr pane exit immediately — which the
CB-306 spawn-readiness gate would surface as "did not reach injectable state" (the same
unexplained-timeout shape fleetd #220 already describes), not a member that silently sits idle and
never calls fleet_reply. Still a real defect (every claude-code member under memberHerdrSocket:
would fail to spawn), just not the worse, undetectable failure mode. Documented this in the
writeCharterFile javadoc.

Review follow-up: currentUserGroup() was itself a copy of fleetd#225

My first version of ClaudeCodeLauncherTest#currentUserGroup() was copied from
OpenCodeLauncherTest, which turned out to be a THIRD copy of an open bug (fleetd#225): it read the
group that owns the current working directory (wherever Maven was started from — staff in a
home checkout, wheel under /private/tmp on macOS), not the process's real primary group. Those
only coincide by accident, and the assumeTrue guard only detected "no POSIX groups at all," never
"a resolvable group I'm not a member of" — so a test run from /private/tmp hit a real chgrp
failure instead of a clean skip.

Fixed to resolve the real primary group via id -gn, matching the shape that landed on PR #230 for
the other two copies (OpenCodeLauncherTest, HerdrPeerLauncherAllowListWiringTest) — same
approach, same skip wording.

Verified from both locations, per review request:

  1. Home checkout (/Users/dai.ha/LTMS/.bridged-worktrees/b86c6b-1/fleetd) — id -gn resolves
    to staff, which the operator is a member of:

    [INFO] Tests run: 1093, Failures: 0, Errors: 0, Skipped: 0
    [INFO] BUILD SUCCESS
    
  2. cp -R under /private/tmp (owning group wheel, which the operator is NOT a member of —
    id -gn still correctly resolves to staff): ClaudeCodeLauncherTest alone —

    [INFO] Tests run: 86, Failures: 0, Errors: 0, Skipped: 0 -- in dev.ltms.fleet.member.ClaudeCodeLauncherTest
    [INFO] BUILD SUCCESS
    

    This is the case that would have failed with the old (copied) helper — chgrp ... wheel refused
    for an operator not in wheel, exactly the failure mode the review reported.

    The full mvn clean install from /private/tmp is NOT fully green on this branch:
    OpenCodeLauncherTest and HerdrPeerLauncherAllowListWiringTest still carry the OLD,
    directory-group-reading currentUserGroup() and fail there with cannot share generated directory ... with group 'wheel'. That is fleetd#225's other two copies, already fixed on PR
    #230 (not yet merged to main, so absent from this branch) — out of this PR's scope, and not
    something I touched. ClaudeCodeLauncherTest itself, and every other test class, is unaffected
    and green in both locations.

Build

mvn clean install, full unpiped output, from fleetd/ in the worktree (home checkout):

[INFO] Tests run: 1093, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Scope

Touched only ClaudeCodeLauncher.java and ClaudeCodeLauncherTest.java, reusing existing
HerdrPeerLauncher/EnvAllowListScrub helpers from #213/#219 — no changes to GitWorktrees (#224,
in progress by another worker), OpenCodeLauncher, or the other two currentUserGroup() copies
(#230's scope).

Caveat for review

writeCharterFile had to become an instance method (was static) since it now calls
memberHerdrSocketConfigured() / memberScrubParentDir() / memberGroup() on this — its only
caller (argvWithFleet) was already an instance method, so this is mechanical, not a design change.

Fixes fleetd#222. ## The defect `ClaudeCodeLauncher#writeCharterFile` called `Files.createTempFile("fleetd-role-charter-", ".md")` with no directory argument, which resolves against fleetd's own `java.io.tmpdir` (on macOS: the per-user `$TMPDIR` under `/var/folders/...`, mode `0700`, and the file itself `0600`). Under `memberHerdrSocket:` the member pane runs as a DIFFERENT OS user and cannot read that directory — and since #220 the charter file is the ONLY delivery path for `--append-system-prompt-file`, always, not merely the fallback it used to be. So every claude-code member spawned under `memberHerdrSocket:` would have been handed a charter path it could not read. ## The fix Reused the exact mechanism #219 built for `OpenCodeLauncher#configParentDir()` — no new abstraction: - `HerdrPeerLauncher#memberHerdrSocketConfigured()` gates the branch. - `memberScrubParentDir()` / `memberGroup()` resolve `worktreeRoot:` / `worktreeGroup:`. - `EnvAllowListScrub.shareWithGroup(dir, group)` (the #213/#219 mechanism) shares a fresh per-spawn directory read-only with the member's group. `writeCharterFile` now branches: - **`memberHerdrSocket` absent** (today's only live mode): byte-identical to before — `Files.createTempFile("fleetd-role-charter-", ".md")` with no directory argument, still resolved against `java.io.tmpdir`. - **`memberHerdrSocket` present**: a fresh per-spawn directory is created under `worktreeRoot` (never `java.io.tmpdir`), the charter file goes inside it, then the whole directory is shared with `worktreeGroup` via `shareWithGroup`. Followed **#219's REFUSAL decision, not #213's degrade decision**: a charter is not a credential control that can degrade gracefully, it is the member's turn contract (the rule that ends every turn with `fleet_reply`). So when `memberHerdrSocket` is configured but `worktreeRoot` and/or `worktreeGroup` is missing, the spawn is refused with an `IllegalStateException` naming the missing key — never a member launched without a readable charter. ## Criterion 3 — how I convinced myself the "absent" path is byte-identical The absent branch is the literal original code, unchanged: `Files.createTempFile("fleetd-role-charter-", ".md")` with no directory argument. I did not introduce a stand-in directory or reroute it through a newly-computed "default" path — I kept the exact original call in its own `if` branch so there is no behavioral difference to introduce. I also added a regression test (`memberHerdrSocketAbsentStaysUnderJavaIoTmpdirEvenWithALiveConfigSupplier`) that spawns with a live, non-null `config` supplier (not just `config == null`, which every pre-existing test already exercises) and asserts the resulting charter file still lands directly under `java.io.tmpdir` with the same `fleetd-role-charter-*` naming (no wrapping directory). ## Tests added (all watched RED before the fix, GREEN after) In `ClaudeCodeLauncherTest`: 1. `memberHerdrSocketWithWorktreeRootAndGroupPutsCharterUnderWorktreeRootAndSharesIt` — charter directory lands under `worktreeRoot`, permissions are `rwxr-x---`/`rw-r-----`. 2. `memberHerdrSocketWithoutWorktreeRootOrGroupRefusesTheSpawn` — refuses, names `worktreeRoot`, nothing started (`agent.start` never called). 3. `memberHerdrSocketWithWorktreeRootButNoGroupRefusesTheSpawn` — `worktreeRoot` alone insufficient, names `worktreeGroup`. 4. `memberHerdrSocketAbsentStaysUnderJavaIoTmpdirEvenWithALiveConfigSupplier` — criterion 3 above. I proved this by stashing the production change, running these 4 tests against the pre-fix `ClaudeCodeLauncher.java`, and confirming 3 of 4 failed (test 4, the "absent" case, correctly passed even pre-fix, since that behavior was never meant to change). Then I restored the fix and reran — all 4 green. ## Measured: what claude-code does with an unreadable `--append-system-prompt-file` Ran the real binary (`claude 2.1.258`) directly, in `-p`/print mode with a bogus `ANTHROPIC_API_KEY` so any success would require it to get past this check first: - **Unreadable file** (`chmod 000`, still owner-unreadable — a reasonable stand-in for "different OS user"): `Error reading append system prompt file: EACCES: permission denied, open '<path>'`, exit code 1, immediately — no network call, no hang. - **Nonexistent file**: `Error: Append system prompt file not found: <path>`, exit code 1, immediately. **Conclusion: it is the LOUD failure**, not the silent one. claude-code checks the file before touching auth or network, so the pre-fix bug would make the herdr pane exit immediately — which the CB-306 spawn-readiness gate would surface as "did not reach injectable state" (the same unexplained-timeout shape fleetd #220 already describes), not a member that silently sits idle and never calls `fleet_reply`. Still a real defect (every claude-code member under `memberHerdrSocket:` would fail to spawn), just not the worse, undetectable failure mode. Documented this in the `writeCharterFile` javadoc. ## Review follow-up: `currentUserGroup()` was itself a copy of fleetd#225 My first version of `ClaudeCodeLauncherTest#currentUserGroup()` was copied from `OpenCodeLauncherTest`, which turned out to be a THIRD copy of an open bug (fleetd#225): it read the group that owns the **current working directory** (wherever Maven was started from — `staff` in a home checkout, `wheel` under `/private/tmp` on macOS), not the process's real primary group. Those only coincide by accident, and the `assumeTrue` guard only detected "no POSIX groups at all," never "a resolvable group I'm not a member of" — so a test run from `/private/tmp` hit a real `chgrp` failure instead of a clean skip. Fixed to resolve the real primary group via `id -gn`, matching the shape that landed on PR #230 for the other two copies (`OpenCodeLauncherTest`, `HerdrPeerLauncherAllowListWiringTest`) — same approach, same skip wording. **Verified from both locations, per review request:** 1. **Home checkout** (`/Users/dai.ha/LTMS/.bridged-worktrees/b86c6b-1/fleetd`) — `id -gn` resolves to `staff`, which the operator is a member of: ``` [INFO] Tests run: 1093, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` 2. **`cp -R` under `/private/tmp`** (owning group `wheel`, which the operator is NOT a member of — `id -gn` still correctly resolves to `staff`): `ClaudeCodeLauncherTest` alone — ``` [INFO] Tests run: 86, Failures: 0, Errors: 0, Skipped: 0 -- in dev.ltms.fleet.member.ClaudeCodeLauncherTest [INFO] BUILD SUCCESS ``` This is the case that would have failed with the old (copied) helper — `chgrp ... wheel` refused for an operator not in `wheel`, exactly the failure mode the review reported. The **full** `mvn clean install` from `/private/tmp` is NOT fully green on this branch: `OpenCodeLauncherTest` and `HerdrPeerLauncherAllowListWiringTest` still carry the OLD, directory-group-reading `currentUserGroup()` and fail there with `cannot share generated directory ... with group 'wheel'`. That is fleetd#225's other two copies, already fixed on PR #230 (not yet merged to `main`, so absent from this branch) — out of this PR's scope, and not something I touched. `ClaudeCodeLauncherTest` itself, and every other test class, is unaffected and green in both locations. ## Build `mvn clean install`, full unpiped output, from `fleetd/` in the worktree (home checkout): ``` [INFO] Tests run: 1093, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ## Scope Touched only `ClaudeCodeLauncher.java` and `ClaudeCodeLauncherTest.java`, reusing existing `HerdrPeerLauncher`/`EnvAllowListScrub` helpers from #213/#219 — no changes to `GitWorktrees` (#224, in progress by another worker), `OpenCodeLauncher`, or the other two `currentUserGroup()` copies (#230's scope). ## Caveat for review `writeCharterFile` had to become an instance method (was `static`) since it now calls `memberHerdrSocketConfigured()` / `memberScrubParentDir()` / `memberGroup()` on `this` — its only caller (`argvWithFleet`) was already an instance method, so this is mechanical, not a design change.
agent added 1 commit 2026-09-02 02:45:41 +02:00
fleetd#222: put the claude-code charter file where the member can read it
CI / contract (pull_request) Successful in 44s
CI / build (pull_request) Successful in 1m16s
748367b7d6
ClaudeCodeLauncher#writeCharterFile used Files.createTempFile with no
directory argument, which resolves against fleetd's own java.io.tmpdir
(macOS: the per-user $TMPDIR, mode 0700). Under memberHerdrSocket: the
member pane runs as a different OS user and cannot read that directory,
and since #220 the charter file is the ONLY delivery path for
--append-system-prompt-file. Following #219's refusal decision (a
charter is the member's turn contract, not a degradable control): with
memberHerdrSocket configured, the charter now goes into a fresh
per-spawn directory under worktreeRoot, shared read-only via
EnvAllowListScrub.shareWithGroup (reusing #213/#219's mechanism); a
missing worktreeRoot/worktreeGroup refuses the spawn by name instead of
writing an unreadable file. With memberHerdrSocket absent the path is
unchanged.
agent added 1 commit 2026-09-02 02:54:19 +02:00
fleetd#222: fix currentUserGroup() to resolve the real primary group (fleetd#225)
CI / contract (pull_request) Successful in 52s
CI / build (pull_request) Successful in 1m43s
6417b0edd9
The helper I copied from OpenCodeLauncherTest read the CWD's owning
group instead of the process's real primary group, so it silently
picked up whatever group owns the directory Maven was started from
(staff in a home checkout, wheel under /private/tmp on macOS) rather
than a group the operator is actually in. Replaced with the id -gn
based resolution that landed on #230 for the other two copies of this
helper, same shape and skip wording.
ltms merged commit 2a434ced2f into main 2026-09-02 02:56:36 +02:00
Sign in to join this conversation.