Skip to content

Commit 1690d50

Browse files
committed
Plan 00088: fix claude_state_dir resolving to /root/.claude, not the user's
play-claude-code.yml computed claude_state_dir from ansible_facts['env']['HOME'], which ansible.cfg's gathering=smart + persistent fact_caching=jsonfile lets an EARLIER play's privilege context poison: play-AA-preflight-sanity.yml (play #1 in playbook-main.yml) runs become: true at the play level, elevating its own fact-gathering, so HOME is cached as /root for the rest of the run regardless of this play's own become: false. Every other user-home lookup in this same file already avoids ansible_facts['env']['HOME'] for exactly this reason (/home/{{ user_login }}/... at five other call sites) — bring this one in line. Found live, downstream, by lts-infra Plan 00045's proof of RUN_BASH_GITHUB_ACCOUNTS=none: the first run in that harness able to actually exercise Plan 00086's kernel-modules fix got past it cleanly and then hit this, a sixth and unrelated defect in the same proof chain.
1 parent ce165c5 commit 1690d50

4 files changed

Lines changed: 167 additions & 1 deletion

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Plan 00088 — Journal 26-08-24
2+
3+
> **Append-only activity log** for plan 00088. One file per day
4+
> (`00088-Journal-YY-MM-DD.md`). `PLAN.md` tracks the plan (what/why,
5+
> current state, tasks); this journal tracks what actually *happened*
6+
> findings, decisions, dead-ends, hand-off state — the linear lifecycle a plan
7+
> document structurally cannot carry.
8+
>
9+
> **Entry grammar** — append new entries at the BOTTOM; NEVER edit earlier
10+
> entries (corrections are new entries):
11+
>
12+
> ```
13+
> ## HH:MM · category · REF — optional short title
14+
> ```
15+
>
16+
> - `HH:MM` local 24h (the date is in the filename); times increase down the file.
17+
> - `category` ∈ `action` | `finding` | `decision` | `thought` | `blocker` | `handoff`
18+
> - `REF` = optional task/phase ref (`T1.2`, `P1`) or `—`.
19+
> - Bodies may embed fenced logs/diffs/snippets — no size limit — with a
20+
> one-line takeaway above the fence.
21+
> - End a working session with a `handoff` entry so the next agent's entry
22+
> point is the last entry of the newest day-file.
23+
24+
## 17:24 · action · — — plan scaffolded
25+
26+
Plan 00088 created via `mkplan.bash`; `JOURNAL/` initialised. Next:
27+
fill in `PLAN.md`, then log progress here as it happens.
28+
29+
## 17:26 · finding · T1.1 — root cause confirmed by reading source, not the log alone
30+
31+
lts-infra's Plan 00045 live-proof run (the first genuinely able to exercise Plan
32+
00086's kernel-modules fix) got past kernel-modules cleanly (`half_installed_kernels`
33+
correctly resolved to `[]`) and then hard-failed much later, deep into
34+
`playbook-main.yml`, at `play-claude-code.yml:143` ("Check Whether the Claude
35+
Code State Directory Exists"): `Permission denied: [Errno 13] Permission denied:
36+
b'/root/.claude'`.
37+
38+
Traced to source rather than assumed from the log:
39+
40+
1. `play-claude-code.yml` line 18: `claude_state_dir: "{{ ansible_facts['env']
41+
['HOME'] }}/.claude"`. The play itself runs `become: false`.
42+
2. `ansible.cfg`: `gathering = smart` + `fact_caching=jsonfile` — facts are
43+
gathered once per host per run (or persisted across runs) and reused by
44+
every subsequent play targeting that host, regardless of that later play's
45+
own `become` setting.
46+
3. `playbooks/playbook-main.yml` import order: `play-AA-preflight-sanity.yml`
47+
is play #1, and runs with `become: true` at the PLAY level — which elevates
48+
its own implicit `setup` fact-gathering task too, so `ansible_facts['env']
49+
['HOME']` is captured as `/root` (via `ansible.cfg`'s `sudo_flags=-HE`) at
50+
the very start of the run.
51+
4. `play-claude-code.yml` runs tens of plays later in the same run and, under
52+
`gathering=smart`, inherits that same stale-for-its-own-context cached fact
53+
instead of re-gathering fresh as the connecting (non-root) user.
54+
55+
This is deterministic — it would reproduce on any fresh guest, not just this
56+
one — and confirmed unrelated to anything already fixed in this proof chain
57+
(PR #33/#34/#35/#36/#37).
58+
59+
The fix is obvious once traced: the REST of `play-claude-code.yml` already
60+
knows not to trust `ansible_facts['env']['HOME']` for exactly this reason — five
61+
other places in the same file (lines 50, 61, 82, 99, 230, 259) build the real
62+
user's home explicitly as `/home/{{ user_login }}` rather than reading it from
63+
gathered facts. `claude_state_dir` is the one place that never got the same
64+
treatment. Fixing it to match the file's own established pattern.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Plan 00088: claude code state dir stale home fact
2+
3+
**Status**: Not Started
4+
**Created**: 2026-08-24
5+
**Owner**: joseph
6+
**Priority**: Medium
7+
8+
## Overview
9+
10+
A downstream deployment repo's live proof of `playbook-main.yml` on a real guest
11+
(the first run in that harness able to actually exercise Plan 00086's
12+
kernel-modules fix — see that plan) got past every prior defect in this proof
13+
chain and then failed deep in `play-claude-code.yml`: "Check Whether the Claude
14+
Code State Directory Exists" hard-failed with `Permission denied: [Errno 13] Permission denied: b'/root/.claude'`.
15+
16+
Root cause, confirmed by reading the actual source rather than guessed from the
17+
log: `play-claude-code.yml` computes `claude_state_dir: "{{ ansible_facts['env']['HOME'] }}/.claude"` (line 18) as a play-level var. This
18+
play runs with `become: false` — its own tasks should run as the real login user
19+
— but `ansible.cfg` sets `gathering = smart` with a **persistent** on-disk fact
20+
cache (`fact_caching=jsonfile`). The very FIRST play in `playbook-main.yml`,
21+
`play-AA-preflight-sanity.yml`, runs with `become: true` at the PLAY level, which
22+
elevates its implicit `setup` fact-gathering task too — so `ansible_facts['env'] ['HOME']` is captured as `/root` (root's home, via `sudo_flags=-HE`'s `-H`) at
23+
the very start of the run. Every later play targeting the same host — including
24+
`play-claude-code.yml`, run tens of plays later — inherits that SAME cached,
25+
stale-for-its-own-context fact under `gathering=smart`, rather than re-gathering
26+
fresh as the connecting user. This is deterministic and would reproduce on any
27+
fresh host, not an artefact of this guest's history: confirmed by reading
28+
`ansible.cfg` (`gathering = smart`, `fact_caching=jsonfile`) and
29+
`playbook-main.yml`'s import order (preflight-sanity is play #1, `become: true`).
30+
31+
The rest of `play-claude-code.yml` already knows not to trust
32+
`ansible_facts['env']['HOME']` for this exact reason: five other places in the
33+
SAME file (lines 50, 61, 82, 99, 230, 259) build the real user's home explicitly
34+
as `/home/{{ user_login }}` rather than reading it from gathered facts.
35+
`claude_state_dir` is the one place in the file that never got the same
36+
treatment.
37+
38+
This is a sixth, unrelated defect surfaced downstream while proving PR #33
39+
(`RUN_BASH_GITHUB_ACCOUNTS=none`) end-to-end — none of the mechanisms proven so
40+
far (PR #33/#34/#35/#36/#37) are implicated.
41+
42+
## Goals
43+
44+
- Make `claude_state_dir` resolve to the real target user's home reliably,
45+
independent of which play in `playbook-main.yml` happened to gather facts
46+
first and under what privilege context.
47+
- Bring `claude_state_dir` in line with the rest of `play-claude-code.yml`'s
48+
own established convention (`/home/{{ user_login }}/...`), not invent a new
49+
pattern.
50+
51+
## Non-Goals
52+
53+
- Not changing `ansible.cfg`'s `gathering`/`fact_caching` settings, and not
54+
changing `play-AA-preflight-sanity.yml`'s `become: true` — both are
55+
legitimate on their own terms; the fix is to stop this one play depending on
56+
a fact that another play's privilege context can poison.
57+
- Not auditing every other play for the same `ansible_facts['env']['HOME']`
58+
pattern — out of scope for this plan; flagged as a possible follow-up in the
59+
journal, not actioned here.
60+
61+
## Tasks
62+
63+
### Phase 1: fix
64+
65+
- [ ]**Task 1.1**: change `claude_state_dir` in `play-claude-code.yml` from
66+
`"{{ ansible_facts['env']['HOME'] }}/.claude"` to
67+
`"/home/{{ user_login }}/.claude"`, matching the file's own established
68+
pattern for resolving the real target user's home.
69+
- [ ]**Task 1.2**: `ansible-playbook --syntax-check` clean;
70+
`./scripts/qa-all.bash` passes.
71+
- [ ]**Task 1.3**: commit, push on a branch, open PR against `F44`.
72+
- [ ]**Task 1.4**: independent review (peer review agent).
73+
- [ ]**Task 1.5**: merge once PASS/PASS WITH NITS.
74+
- [ ]**Task 1.6**: re-pin lts-infra's live-proof harness at this merged
75+
commit and re-run it — see lts-infra Plan 00045.
76+
77+
## Success Criteria
78+
79+
- [ ] A downstream live proof re-run gets past "Check Whether the Claude Code
80+
State Directory Exists" without a hard failure, and `claude_state_dir`
81+
resolves to the real login user's home (`/home/{{ user_login }}/.claude` on
82+
the proof guest), not `/root/.claude`. **Not yet run** — pending Task 1.6.
83+
84+
## Delivery & Milestones
85+
86+
<!-- Curated milestones + delivery commit hashes only (git is the SSoT for
87+
"when" — do not add dates). The blow-by-blow activity log lives in
88+
JOURNAL/00088-Journal-YY-MM-DD.md — see CLAUDE/PlanJournalling.md. -->
89+
90+
- Recovery cron: 28837729 (shared session-wide failsafe, already running).

CLAUDE/Plan/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ Use these Unicode icons in plan documents:
134134

135135
- [00086-kernel-modules-absent-enumeration](00086-kernel-modules-absent-enumeration/) - A downstream live proof of `play-AB-dnf-upgrade.yml` on a guest lacking the `kernel-modules` package (present only `kernel-core`/`kernel-modules-core`) found the half-installed-kernel enumeration hard-failed instead of treating "not installed" as zero versions. Fixed with a probe-then-fail `assert`, not a blanket `failed_when: false`.
136136

137+
- [00088-claude-code-state-dir-stale-home-fact](00088-claude-code-state-dir-stale-home-fact/) - The same downstream live proof, past PR #36, hard-failed in `play-claude-code.yml` on `Permission denied: /root/.claude`: `claude_state_dir` trusted `ansible_facts['env']['HOME']`, which `gathering=smart` + a play-level `become: true` on the FIRST play in `playbook-main.yml` poisons to `/root` for every later play. Fixed to match the file's own `/home/{{ user_login }}` convention used everywhere else in it.
138+
137139
## Completed Plans
138140

139141
- [00085-headless-path-local-bin](Completed/00085-headless-path-local-bin/) - A downstream live proof of the composed PR #33/#34 headless mechanisms found a third, unrelated blocker: `ansible-galaxy: command not found` under a non-interactive `sudo -u` invocation, since pipx's `~/.local/bin` shims are never put on PATH there. Exports PATH right after the pipx install block. Merged (`dac4f7c`).

playbooks/imports/play-claude-code.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@
1515
claude_code_version: "latest"
1616
# Desktop Claude Code's plaintext session store. Container (ccy) sessions
1717
# keep their state per-project instead, so ccy's launcher repairs those.
18-
claude_state_dir: "{{ ansible_facts['env']['HOME'] }}/.claude"
18+
#
19+
# NOT ansible_facts['env']['HOME']: this play runs become: false, but
20+
# ansible.cfg's `gathering = smart` + persistent `fact_caching=jsonfile`
21+
# mean facts gathered by an EARLIER play in playbook-main.yml are reused
22+
# here rather than re-gathered. play-AA-preflight-sanity.yml (play #1)
23+
# runs become: true at the PLAY level, which elevates its own fact-
24+
# gathering too, so ansible_facts['env']['HOME'] is '/root' for the whole
25+
# run — see CLAUDE/Plan/00088-claude-code-state-dir-stale-home-fact. The
26+
# rest of this file already avoids this trap (lines below use
27+
# /home/{{ user_login }}/... directly); do the same here.
28+
claude_state_dir: "/home/{{ user_login }}/.claude"
1929
tasks:
2030
# cc wrapper sources ccy's lib at runtime. play-claude-yolo MUST run
2131
# first (see playbook-main.yml import order). Fail at deploy time

0 commit comments

Comments
 (0)