A claude-code member spawned into a fresh worktree blocks forever on the workspace trust dialog #149

Closed
opened 2026-08-23 09:04:14 +02:00 by ltms · 3 comments
Owner

What happens

Claude Code asks "Is this a project you created or one you trust?" the first time it starts in a directory it has not seen. It is an interactive prompt with no timeout:

 Accessing workspace:
 /home/ltms/LTMS/fleetd
 Quick safety check: Is this a project you created or one you trust? ...
 ❯ 1. Yes, I trust this folder
   2. No, exit

Every claude-code member we spawn with worktree: true lands in a brand-new directory, so every one of them hits this. The member never reaches its first turn, never mounts the MCP, and never sends a fleet_reply. From the lead's side it looks like a member that spawned fine and then went silent — herdr reports agent_status: blocked, interactive_ready: true, which reads as healthy.

Observed live on fleet01 2026-08-23 on a lead launch into /home/ltms/LTMS/fleetd. It applies to any claude-code peer in an unseen directory, member or lead.

Why the obvious fixes are wrong

  • -p / non-interactive. claude --help says the trust dialog is skipped when stdout is not a TTY. Our peers are interactive TUI panes by design, so this does not apply to us.
  • --dangerously-skip-permissions. Suppresses the dialog, but it also turns off every permission check for the whole session. Far too broad for a fix aimed at one first-run prompt.
  • permissions.additionalDirectories. A common suggestion (see anthropics/claude-code#993). It grants tool access to extra directories; it is not the trust flag. Not the mechanism here.

The fix

Pre-seed the trust entry for the cwd we are about to launch into, in that profile's config dir, before starting the process. The flag Claude Code actually reads is per-project in .claude.json:

{"projects": {"<cwd>": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true}}}

ClaudeCodeLauncher already knows both halves — it resolves cwd and writes CLAUDE_CONFIG_DIR from profile.configDir — so this belongs next to the existing --mcp-config write. Where a profile sets no configDir, the target is the default ~/.claude.json.

Seeding trust is not a new grant. The operator already decided to trust this repo by configuring the profile against it, and the worktree is a checkout of that same repo.

Verified, not assumed

Measured on fleet01, same daemon, same launcher, only the seed differing:

cwd seeded? result
/home/ltms/LTMS/fleetd no agent_status: blocked — sat on the dialog indefinitely
/home/ltms/LTMS/alms-memory (fresh clone, never opened) yes agent_status: idle — started clean, no dialog

Acceptance criteria

  1. A claude-code peer spawned into a directory never seen before reaches idle without any prompt.
  2. The seed goes to the profile's own configDir when it sets one, and the default config otherwise.
  3. Seeding is additive — it must not drop or rewrite other keys in an existing .claude.json. That file also holds the operator's own project history.
  4. A test that starts the real launcher and asserts the entry exists for the cwd before the process is started. A test that only checks the JSON writer proves nothing about whether the launcher calls it — see the CB-586 / CB-611 pattern.
  5. opencode peers are unaffected and must stay unaffected.

Gotcha for whoever picks this up

.claude.json on a working host is large (44 KB on fleet01, 27 projects on the Mac) and Claude Code rewrites it while running. Read-modify-write it carefully, and do not assume it is small or static.

## What happens Claude Code asks *"Is this a project you created or one you trust?"* the first time it starts in a directory it has not seen. It is an interactive prompt with no timeout: ``` Accessing workspace: /home/ltms/LTMS/fleetd Quick safety check: Is this a project you created or one you trust? ... ❯ 1. Yes, I trust this folder 2. No, exit ``` Every claude-code member we spawn with `worktree: true` lands in a **brand-new directory**, so every one of them hits this. The member never reaches its first turn, never mounts the MCP, and never sends a `fleet_reply`. From the lead's side it looks like a member that spawned fine and then went silent — herdr reports `agent_status: blocked`, `interactive_ready: true`, which reads as healthy. Observed live on fleet01 2026-08-23 on a lead launch into `/home/ltms/LTMS/fleetd`. It applies to any claude-code peer in an unseen directory, member or lead. ## Why the obvious fixes are wrong - **`-p` / non-interactive.** `claude --help` says the trust dialog *is* skipped when stdout is not a TTY. Our peers are interactive TUI panes by design, so this does not apply to us. - **`--dangerously-skip-permissions`.** Suppresses the dialog, but it also turns off every permission check for the whole session. Far too broad for a fix aimed at one first-run prompt. - **`permissions.additionalDirectories`.** A common suggestion (see anthropics/claude-code#993). It grants tool *access* to extra directories; it is not the trust flag. Not the mechanism here. ## The fix Pre-seed the trust entry for the cwd we are about to launch into, in that profile's config dir, before starting the process. The flag Claude Code actually reads is per-project in `.claude.json`: ```json {"projects": {"<cwd>": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true}}} ``` `ClaudeCodeLauncher` already knows both halves — it resolves `cwd` and writes `CLAUDE_CONFIG_DIR` from `profile.configDir` — so this belongs next to the existing `--mcp-config` write. Where a profile sets no `configDir`, the target is the default `~/.claude.json`. Seeding trust is not a new grant. The operator already decided to trust this repo by configuring the profile against it, and the worktree is a checkout of that same repo. ## Verified, not assumed Measured on fleet01, same daemon, same launcher, only the seed differing: | cwd | seeded? | result | |---|---|---| | `/home/ltms/LTMS/fleetd` | no | `agent_status: blocked` — sat on the dialog indefinitely | | `/home/ltms/LTMS/alms-memory` (fresh clone, never opened) | yes | `agent_status: idle` — started clean, no dialog | ## Acceptance criteria 1. A claude-code peer spawned into a directory never seen before reaches `idle` without any prompt. 2. The seed goes to the profile's own `configDir` when it sets one, and the default config otherwise. 3. Seeding is additive — it must not drop or rewrite other keys in an existing `.claude.json`. That file also holds the operator's own project history. 4. A test that starts the real launcher and asserts the entry exists for the cwd before the process is started. A test that only checks the JSON writer proves nothing about whether the launcher calls it — see the CB-586 / CB-611 pattern. 5. opencode peers are unaffected and must stay unaffected. ## Gotcha for whoever picks this up `.claude.json` on a working host is large (44 KB on fleet01, 27 projects on the Mac) and Claude Code rewrites it while running. Read-modify-write it carefully, and do not assume it is small or static.
Author
Owner

Correction to the premise, measured on the Mac today (2026-09-01) while diagnosing #220. Not closing this — read on for why it may still be real elsewhere.

I hit the trust dialog by hand and then checked what makes it appear:

$ claude ... (in /private/tmp/.../scratchpad/argvtest)
Quick safety check: Is this a project you created or one you trust?
❯ No, exit
  Yes, I trust this folder

But the same command in a real worktree under /Users/dai.ha/LTMS/.bridged-worktrees/ started straight to the prompt box, no dialog.

The reason is in ${CLAUDE_CONFIG_DIR}/.claude.json: /Users/dai.ha itself carries hasTrustDialogAccepted, so every directory beneath it inherits trust. There are zero projects entries for any .bridged-worktrees path — a fresh worktree is never recorded and never needs to be.

So on this host a claude-code member in a fresh worktree does not meet the trust dialog, and has not for as long as that entry has existed. What I observed was /private/tmp — outside the trusted root.

Two things follow, and they pull in opposite directions:

  • The reported symptom here was probably #220, not the trust dialog. They are indistinguishable from the outside: pane never becomes injectable, tab already gone, opencode unaffected. #220 is now fixed and the timeout log carries the pane tail, so a real trust dialog would now be visible in the log as the dialog text rather than guessed at.
  • The underlying exposure is still real, just conditional. It depends entirely on one entry in one operator's config file. A member whose worktree root sits outside the trusted directory — fleet01, a different CLAUDE_CONFIG_DIR, a fresh machine, or an operator who never accepted trust at their home directory — will block exactly as described, forever, because nothing types a keypress into that pane.

Suggested reshape. Do not fix "the trust dialog appears"; it usually does not. Fix that fleetd depends on an unmanaged config entry it never checks and cannot see. Either pre-trust the worktree root, or detect the dialog in the readiness gate and fail with what the pane says instead of a timeout — the second is cheap now that the pane tail is logged.

Not verified: I did not test this on fleet01, where the home directory and config are different. That is where I would expect it to still bite.

**Correction to the premise, measured on the Mac today (2026-09-01) while diagnosing #220.** Not closing this — read on for why it may still be real elsewhere. I hit the trust dialog by hand and then checked what makes it appear: ``` $ claude ... (in /private/tmp/.../scratchpad/argvtest) Quick safety check: Is this a project you created or one you trust? ❯ No, exit Yes, I trust this folder ``` But the same command in a real worktree under `/Users/dai.ha/LTMS/.bridged-worktrees/` started straight to the prompt box, no dialog. The reason is in `${CLAUDE_CONFIG_DIR}/.claude.json`: **`/Users/dai.ha` itself carries `hasTrustDialogAccepted`**, so every directory beneath it inherits trust. There are **zero** `projects` entries for any `.bridged-worktrees` path — a fresh worktree is never recorded and never needs to be. So on this host a claude-code member in a fresh worktree does **not** meet the trust dialog, and has not for as long as that entry has existed. What I observed was `/private/tmp` — outside the trusted root. Two things follow, and they pull in opposite directions: - **The reported symptom here was probably #220, not the trust dialog.** They are indistinguishable from the outside: pane never becomes injectable, tab already gone, opencode unaffected. #220 is now fixed and the timeout log carries the pane tail, so a real trust dialog would now be visible in the log as the dialog text rather than guessed at. - **The underlying exposure is still real, just conditional.** It depends entirely on one entry in one operator's config file. A member whose worktree root sits outside the trusted directory — fleet01, a different `CLAUDE_CONFIG_DIR`, a fresh machine, or an operator who never accepted trust at their home directory — will block exactly as described, forever, because nothing types a keypress into that pane. **Suggested reshape.** Do not fix "the trust dialog appears"; it usually does not. Fix that fleetd depends on an unmanaged config entry it never checks and cannot see. Either pre-trust the worktree root, or detect the dialog in the readiness gate and fail with what the pane says instead of a timeout — the second is cheap now that the pane tail is logged. **Not verified:** I did not test this on fleet01, where the home directory and config are different. That is where I would expect it to still bite.
Author
Owner

Fixed and merged to main as 2e5b63f (PR #244).

ClaudeCodeLauncher now seeds projects.<cwd>.hasTrustDialogAccepted and hasCompletedProjectOnboarding in ~/.claude.json (or configDir/.claude.json) before the peer process starts. The write is additive, atomic (ATOMIC_MOVE from a sibling temp file), and guarded by a process-wide lock. It is gated on isProvisionedWorktree(cwd), so it only ever touches a worktree fleetd itself provisioned. Any failure is logged at debug and never blocks a spawn.

Checked by the lead with mutation testing: a truncating write turns writeAtomicallyNeverExposesATornFileToAConcurrentReader red; dropping the lock turns concurrentSeedsForDifferentCwdsBothSurvive red. Both with 0 compile errors. Build on main after the merge: 1182 tests, 0 failures.

Two things are not done, so I am leaving this open until they are:

  1. Criterion 1 is still unproven — "a claude-code peer spawned into a never-seen directory reaches idle with no prompt". A worker cannot test this. It needs a live spawn from the lead after the daemon is redeployed onto this jar. I will do that.
  2. An external writer can still lose a change — our lock cannot reach the operator's own running Claude Code. Filed as #247.

Also worth recording: while building this, the worker's own mutation test overwrote the operator's real ~/.claude.json (72581 bytes → 919). That is exactly the bug the isProvisionedWorktree gate now prevents, and it is why the gate is there rather than a plain non-blank-cwd check.

Fixed and merged to `main` as `2e5b63f` (PR #244). `ClaudeCodeLauncher` now seeds `projects.<cwd>.hasTrustDialogAccepted` and `hasCompletedProjectOnboarding` in `~/.claude.json` (or `configDir/.claude.json`) before the peer process starts. The write is additive, atomic (`ATOMIC_MOVE` from a sibling temp file), and guarded by a process-wide lock. It is gated on `isProvisionedWorktree(cwd)`, so it only ever touches a worktree fleetd itself provisioned. Any failure is logged at debug and never blocks a spawn. Checked by the lead with mutation testing: a truncating write turns `writeAtomicallyNeverExposesATornFileToAConcurrentReader` red; dropping the lock turns `concurrentSeedsForDifferentCwdsBothSurvive` red. Both with 0 compile errors. Build on `main` after the merge: 1182 tests, 0 failures. Two things are **not** done, so I am leaving this open until they are: 1. **Criterion 1 is still unproven** — "a claude-code peer spawned into a never-seen directory reaches `idle` with no prompt". A worker cannot test this. It needs a live spawn from the lead after the daemon is redeployed onto this jar. I will do that. 2. **An external writer can still lose a change** — our lock cannot reach the operator's own running Claude Code. Filed as #247. Also worth recording: while building this, the worker's own mutation test overwrote the operator's real `~/.claude.json` (72581 bytes → 919). That is exactly the bug the `isProvisionedWorktree` gate now prevents, and it is why the gate is there rather than a plain non-blank-`cwd` check.
Author
Owner

Criterion 1 is now proven live. Closing.

Redeployed onto the merged jar (pid 36650, jar b8af736f44fc), then spawned a real claude-code member into a fresh worktree:

  • worktree /Users/dai.ha/LTMS/.bridged-worktrees/a59c34-1, created by this jar minutes earlier
  • fleet_status → idle. No prompt, no readiness timeout.
  • 0 error lines in the daemon log since the restart.

Criterion 2 holds too, and I checked it the careful way. My first look was at ~/.claude.json, where the entry was absent — which would have looked like the fix failing. It is not: the sonnet profile sets configDir: /Users/dai.ha/.ccs/instances/ltms, so the seed correctly goes to <configDir>/.claude.json. In that file:

THIS worktree's entry present: True
  hasTrustDialogAccepted: True
mode: 0o600

Worth saying plainly: had I stopped at the first file I would have reported a working fix as broken.

One real finding — we write a key that does not survive, and does not matter

ClaudeCodeLauncher writes both flags (ClaudeCodeLauncher.java:561-562):

project.put("hasTrustDialogAccepted", true);
project.put("hasCompletedProjectOnboarding", true);

In the live file, across all 28 project entries:

has trust key:      28 of 28
has onboarding key:  0 of 28

Our entry was created by this jar minutes before I looked, so hasCompletedProjectOnboarding was written and is now gone. Claude Code rewrote the file and stripped that key — from every entry, not just ours.

Two consequences:

  1. The member reached idle anyway. hasTrustDialogAccepted alone is sufficient for the outcome this ticket is about. The second key buys nothing.
  2. It sharpens #247. I described that as a narrow race — a save landing between our read and our write. This is worse and different: Claude Code is not losing a race with us, it normalises the file on every save and drops keys it does not expect. A compare-and-swap retry, which was option 1 there, would not help at all — it would re-add a key that gets stripped again on the next save. I have added this to #247.

Neither changes the fix, so this ticket closes. Whoever takes #247 should consider simply not writing the second key, since we now know it is both ineffective and not retained.

## Criterion 1 is now proven live. Closing. Redeployed onto the merged jar (pid 36650, jar `b8af736f44fc`), then spawned a real `claude-code` member into a fresh worktree: - worktree `/Users/dai.ha/LTMS/.bridged-worktrees/a59c34-1`, created by this jar minutes earlier - **`fleet_status` → `idle`**. No prompt, no readiness timeout. - 0 error lines in the daemon log since the restart. Criterion 2 holds too, and I checked it the careful way. My first look was at `~/.claude.json`, where the entry was **absent** — which would have looked like the fix failing. It is not: the `sonnet` profile sets `configDir: /Users/dai.ha/.ccs/instances/ltms`, so the seed correctly goes to `<configDir>/.claude.json`. In that file: ``` THIS worktree's entry present: True hasTrustDialogAccepted: True mode: 0o600 ``` Worth saying plainly: had I stopped at the first file I would have reported a working fix as broken. ## One real finding — we write a key that does not survive, and does not matter `ClaudeCodeLauncher` writes **both** flags (`ClaudeCodeLauncher.java:561-562`): ```java project.put("hasTrustDialogAccepted", true); project.put("hasCompletedProjectOnboarding", true); ``` In the live file, across all 28 project entries: ``` has trust key: 28 of 28 has onboarding key: 0 of 28 ``` Our entry was created by this jar minutes before I looked, so `hasCompletedProjectOnboarding` **was** written and is now gone. Claude Code rewrote the file and stripped that key — from every entry, not just ours. Two consequences: 1. **The member reached `idle` anyway.** `hasTrustDialogAccepted` alone is sufficient for the outcome this ticket is about. The second key buys nothing. 2. **It sharpens #247.** I described that as a narrow race — a save landing between our read and our write. This is worse and different: Claude Code is not losing a race with us, it **normalises the file on every save** and drops keys it does not expect. A compare-and-swap retry, which was option 1 there, would not help at all — it would re-add a key that gets stripped again on the next save. I have added this to #247. Neither changes the fix, so this ticket closes. Whoever takes #247 should consider simply not writing the second key, since we now know it is both ineffective and not retained.
ltms closed this issue 2026-09-03 07:32:29 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#149