Thomas Wolf Claude Opus 5 (1M context) commited on
Commit
4782e4c
Β·
unverified Β·
1 Parent(s): b8df024

entrypoint: keep bucket-backed dirs alive, and occupy config paths (#7)

Browse files

Two failures on Spaces with a storage bucket at /data, both from the same
mismatch: object storage has no real directories, and hf-mount papers over
that in ways POSIX code doesn't expect.

1. Empty directories are not persisted -- there is no backing object key, so a
dir created empty is gone after a restart. entrypoint.sh created
$CODEX_DURABLE/sessions empty and then symlinked $CODEX_HOME/sessions at
it. Once the directory evaporated the symlink dangled, and mkdir onto a
dangling symlink reports EEXIST, so codex failed every rollout write with

thread-store internal error: File exists (os error 17)

and then could not branch a conversation, because thread/read had nothing
persisted to read. Silent, total transcript loss: the TUI stays responsive
because the live thread is in memory. db-backups and db-backups/am-quarantine
had vanished the same way. New keepdir() drops a .keep marker so the path
survives; used for those three and for the opencode/hermes durable dirs.

2. The bucket tree API matches raw object-key prefixes with no path-component
boundary, and hf-mount reads a non-empty listing as proof a path is a
DIRECTORY. So any path that is a string prefix of a real key materializes as
a phantom directory. Gemini writes its registry atomically; when the rename
fails it leaves projects.json.<uuid>.tmp behind and no projects.json, after
which projects.json becomes a directory and the CLI dies with

EISDIR: illegal operation on a directory, read

New occupy_file() clears a directory-shaped entry and writes a default, so
the HEAD succeeds and the synthesizing fallback never runs. Same guard
pattern as opencode.json in server/src/runner.js. Fixed upstream in
huggingface/hf-mount#207, still unmerged as of v0.9.0.

docs/fuse-phantom-directories.md has the full diagnosis, a five-second repro,
the two hf-mount code paths, and the local workarounds for anyone who hits this
before #207 ships.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. docs/fuse-phantom-directories.md +262 -0
  2. entrypoint.sh +42 -3
docs/fuse-phantom-directories.md ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phantom directories on the `/data` bucket mount
2
+
3
+ **Status:** root cause identified, reproduced on demand, **local repairs applied 2026-07-28**.
4
+ Upstream already has a fix in **open, unmerged** [`hf-mount` PR #207](https://github.com/huggingface/hf-mount/pull/207).
5
+ **Sources:** the `session-sharing` Claude session (transcript `edbfc11f-…`, lines 682–720,
6
+ 751–776), plus `docs/fuse-phantom-directories-review.md` β€” a second-opinion review that
7
+ found PR #207 and diagnosed the Codex thread-store failure. Its corrections are folded in here.
8
+
9
+ ## TL;DR
10
+
11
+ Any path on `/data` that is a **strict string prefix of a real bucket key** materializes as
12
+ a directory containing one child named after the first segment of that key (`home` or
13
+ `workspaces`). The path itself does not exist.
14
+
15
+ ```
16
+ .git/hooks/pre-commit/workspaces ← because .git/hooks/pre-commit.sample exists
17
+ home/.gemini/projects.json/home ← because projects.json.<uuid>.tmp exists
18
+ home/.cache/claude/home ← because .cache/claude-cli-nodejs exists
19
+ ```
20
+
21
+ A negative lookup that should return `ENOENT` becomes a directory inode. This broke
22
+ `git commit` (git *skips* a missing hook but **execs** a directory β€” fatal) and the Gemini
23
+ CLI (`EISDIR` reading its registry). Both are repaired on this Space; see
24
+ [What was applied](#what-was-applied-2026-07-28).
25
+
26
+ It is **not** "FUSE turns an atomic write into a directory", which is what
27
+ `docs/session-sharing.md` Β§12 still claims. See [Corrections](#corrections).
28
+
29
+ ## Environment
30
+
31
+ - `/data` is `hf-mount` FUSE, backing bucket **`thomwolf/agent-manager-data`**.
32
+ - Upstream source inspected at **v0.9.0 / `ff83aca`** (HEAD, 2026-07-28). Both faulty code
33
+ paths are present there; the fix exists only on the unmerged PR #207 branch.
34
+
35
+ ## Root cause: an API-contract mismatch
36
+
37
+ Framing matters here, because it decides where the durable fix belongs.
38
+
39
+ > **`hf-mount` consumes a raw object-key prefix API as though it guaranteed filesystem
40
+ > path-component boundaries.**
41
+
42
+ The bucket API's prefix behaviour is *not itself a defect* β€” filtering by raw object-key
43
+ prefix is ordinary object-storage semantics, and the Hub
44
+ [documents bucket listing as prefix filtering](https://huggingface.co/docs/huggingface_hub/guides/buckets#list-files).
45
+ The defect is that hf-mount never converts that response into strict filesystem
46
+ descendants before its virtual filesystem consumes it.
47
+
48
+ ### Layer 1 β€” the API matches by string prefix
49
+
50
+ ```
51
+ GET /api/buckets/<id>/tree/home/.gemini/projects.json
52
+ 200 β†’ home/.gemini/projects.json.9e7b39d7-…-.tmp
53
+ home/.gemini/projects.json.bf9667fc-…-.tmp
54
+ ```
55
+
56
+ `home/.gemini/projects.json` is not a key. A genuinely unmatched path correctly returns `[]`:
57
+
58
+ ```
59
+ tree/workspaces/Agent-manager/.git/hooks/never-existed-aaa β†’ entries=0
60
+ tree/totally/bogus/path/xyz β†’ entries=0
61
+ ```
62
+
63
+ ### Layer 2 β€” hf-mount reads "non-empty listing" as "directory"
64
+
65
+ `src/virtual_fs/mod.rs:1373-1379`:
66
+
67
+ ```rust
68
+ // The resolve endpoint returns 404 for directories, so a HEAD
69
+ // miss could still be a remotely-added dir. Targeted listing
70
+ // catches that; non-empty result means the dir exists.
71
+ if let Ok(entries) = self.hub_client.list_tree(&full_path).await
72
+ && !entries.is_empty()
73
+ {
74
+ return self.insert_dir(parent, name, &full_path);
75
+ }
76
+ ```
77
+
78
+ Note the necessary boundary test is **strict descendants only**: at least one entry
79
+ beginning with `full_path + "/"`. An entry *equal to* `prefix` must not count as proof β€”
80
+ a key and a directory of the same name are different things in an object store.
81
+
82
+ ### Layer 3 β€” the child name comes from a failed `strip_prefix`
83
+
84
+ `src/virtual_fs/mod.rs:826-842`, in `ensure_children_loaded`:
85
+
86
+ ```rust
87
+ entry.path
88
+ .strip_prefix(&prefix)
89
+ .and_then(|p| p.strip_prefix('/'))
90
+ .unwrap_or(&entry.path) // ← keeps the FULL bucket key
91
+ ```
92
+
93
+ Prefix-matched siblings never start with `prefix + "/"`, so both strips fail, the whole
94
+ bucket key survives, and the next line takes its first path segment as a subdirectory
95
+ name β€” `home` under `/data/home/…`, `workspaces` under `/data/workspaces/…`.
96
+
97
+ Upstream's own test mock modelled *path-component* matching rather than the real API's raw
98
+ prefix behaviour, which is why the tests never caught this.
99
+
100
+ ## Upstream status
101
+
102
+ Reported and fixed three weeks before this investigation, by an HF engineer:
103
+
104
+ - [PR #207 β€” *fix: support object_store-based writers (Lance, Delta) on mounted buckets*](https://github.com/huggingface/hf-mount/pull/207)
105
+ β€” **OPEN**, base `main`, created 2026-07-06, last updated 2026-07-07, not merged, not released.
106
+ - [`4f9b2a3`](https://github.com/huggingface/hf-mount/commit/4f9b2a307d71217ba812fec57836434a0d812589)
107
+ *treat raw-prefix tree matches as non-children* β€” the targeted lookup only infers a
108
+ directory from a strict descendant; `ensure_children_loaded` skips raw-prefix siblings.
109
+ - [`9f11e0d`](https://github.com/huggingface/hf-mount/commit/9f11e0d4adec7d045d42ac5bab2cbbc8b8dec662)
110
+ *apply review cleanups* β€” moves the filter into `HubApiClient::list_tree()`, the boundary
111
+ where S3 prefix semantics originate, so every consumer (including the previously
112
+ unguarded `poll.rs`) inherits the strict-descendant contract.
113
+
114
+ The centralized approach in `9f11e0d` is the right shape. Until PR #207 merges and ships,
115
+ v0.9.0 and this mount remain affected.
116
+
117
+ **Note for searchers:** `gh search --repo … is:issue` will *not* find this β€” it excludes
118
+ pull requests. Search `is:pr` too.
119
+
120
+ ## Reproduction
121
+
122
+ Deterministic enough to demo, read-only, ~5 seconds:
123
+
124
+ ```sh
125
+ ls -la /data/home/.cach # β†’ DIRECTORY containing `home` (prefix of home/.cache/…)
126
+ ls -la /data/sessions.jso # β†’ DIRECTORY (prefix of sessions.json)
127
+ ls -la /data/sessions.jsoZZ # β†’ ENOENT, correct (prefix of nothing)
128
+ ```
129
+
130
+ Timing is **not** fully deterministic: whether the targeted `list_tree` fallback runs at
131
+ all depends on parent-listing state, kernel caching, and hf-mount's negative cache. A path
132
+ that is a prefix of a real key is *eligible*; it does not always materialize immediately.
133
+
134
+ The phantoms are **synthesized inode entries, not stored objects** β€” consistent with
135
+ "empty directories are not persisted" on this bucket. Consequences:
136
+
137
+ - They contain no files, only the one synthesized child directory.
138
+ - `rmdir` clears them, but **only until the next lookup**, which re-synthesizes them β€”
139
+ unless the triggering key is gone.
140
+ - They also disappear on their own when the inode cache evicts them.
141
+ - Inodes are small sequential counters (`/data`=1, `home`=3, `workspaces`=10), so these are
142
+ genuine new nodes, not alias/hash collisions.
143
+
144
+ ## Why this is high-severity, not cosmetic
145
+
146
+ Ordinary tooling generates vulnerable name pairs constantly:
147
+
148
+ - git ships `pre-commit.sample` while probing `pre-commit`
149
+ - atomic writers create `file.<uuid>.tmp` while readers probe `file`
150
+ - Node/TypeScript probe extensionless names before `.js` / `.d.ts`
151
+ - object-store writers (Lance, Delta β€” PR #207's motivating case) stage `1.manifest#1`
152
+ while probing `1.manifest`
153
+
154
+ The evidence establishes application breakage and incorrect filesystem semantics. It does
155
+ **not** establish stored-object corruption, data loss, or a security vulnerability.
156
+
157
+ ## Known sites on this Space
158
+
159
+ | Phantom path | Key it prefix-matches | Impact | Now |
160
+ |---|---|---|---|
161
+ | `.git/hooks/pre-commit` | `pre-commit.sample` | `git commit` fatal | **fixed** |
162
+ | `.git/hooks/prepare-commit-msg` | `prepare-commit-msg.sample` | same | **fixed** |
163
+ | `home/.gemini/projects.json` | `projects.json.<uuid>.tmp` Γ—2 | Gemini CLI dead | **fixed** |
164
+ | `home/.cache/claude` | `claude-cli-nodejs` | harmless | left alone |
165
+ | `home/.config/google-chrome` | `google-chrome-for-testing` | harmless | left alone |
166
+ | `node_modules/react/jsx-runtime` | `jsx-runtime.js` | transient | left alone |
167
+ | `node_modules/react-dom/client` | `client.js` | transient | left alone |
168
+ | `node_modules/@types/react-dom/client` | `client.d.ts` | transient | left alone |
169
+ | `node_modules/node` | `node-*` packages | transient | left alone |
170
+
171
+ ## What was applied (2026-07-28)
172
+
173
+ These are **local workarounds**, not root-cause fixes β€” the root cause is upstream. Each
174
+ works by removing the triggering key or occupying the path so the listing fallback never runs.
175
+
176
+ 1. **git** β€” deleted all 13 `.git/hooks/*.sample`, then `rmdir`'d the two cached phantoms.
177
+ *Verified:* `git hook run pre-commit` β†’ "cannot find a hook named pre-commit"; a real
178
+ `git commit --allow-empty` succeeded and was rolled back.
179
+ 2. **Gemini** β€” moved the two Jul-6 orphans to `~/.gemini/am-quarantine/` (both were just
180
+ `{"projects": {}}`, nothing lost) and wrote a real `projects.json`.
181
+ *Verified:* `gemini -p …` now reaches the auth check instead of dying on `EISDIR`.
182
+ 3. **Codex thread store** β€” created `/data/state/codex/{sessions,db-backups,db-backups/am-quarantine}`
183
+ each with a `.keep` file. *Verified:* `$CODEX_HOME/sessions` resolves, and `mkdir -p`
184
+ of a dated rollout path (the exact failing operation) succeeds.
185
+ 4. **`entrypoint.sh`** β€” so the above survives a restart:
186
+ - new `keepdir()` β€” mkdir plus a `.keep` marker; used for the codex `sessions` /
187
+ `db-backups` / `am-quarantine` dirs and the opencode/hermes durable dirs.
188
+ - new `occupy_file()` β€” clears a directory-shaped entry and writes a default file;
189
+ used for `~/.gemini/projects.json`.
190
+ - `sh -n` clean; both helpers unit-tested in isolation (replaces a phantom dir, leaves
191
+ an existing file untouched).
192
+
193
+ ## The Codex thread-store failure (same family, different bug)
194
+
195
+ Full diagnosis in `fuse-phantom-directories-review.md`. Confirmed independently here:
196
+
197
+ ```
198
+ dangling sessions symlink β†’ rollout creation fails EEXIST β†’ thread absent from
199
+ persistent storage β†’ thread/read fails β†’ branch-before-selected-prompt fails
200
+ ```
201
+
202
+ `entrypoint.sh` created `$CODEX_DURABLE/sessions` **empty** and symlinked
203
+ `$CODEX_HOME/sessions` at it. Empty directories have no backing object key, so the
204
+ directory vanished and the symlink dangled; `mkdir` onto a dangling symlink reports
205
+ `File exists (os error 17)` β€” the opaque error in
206
+ [openai/codex#3733](https://github.com/openai/codex/issues/3733).
207
+
208
+ This is **not** the prefix bug. It shares only the root theme: *a bucket mount does not
209
+ preserve empty-directory state like a conventional filesystem.* The review missed that
210
+ `db-backups` and `db-backups/am-quarantine` had vanished for the same reason; all three
211
+ are repaired.
212
+
213
+ Transcripts written while the link was dangling were never persisted and are not
214
+ recoverable. A more robust design β€” keep all of `$CODEX_HOME` on local disk and copy
215
+ *completed* rollouts to the bucket, rather than exposing the live writer through a FUSE
216
+ symlink β€” is worth considering but is a bigger change than the `.keep` fix.
217
+
218
+ ## Corrections
219
+
220
+ Recorded because they were stated confidently and are wrong.
221
+
222
+ 1. **`docs/session-sharing.md` Β§12** β€” "the FUSE bucket turning an atomic write into a
223
+ directory" reverses the causality. The orphaned temp files are the trigger; the
224
+ directory is synthesized later by a prefix-matching lookup. *(Still unfixed in Β§12.)*
225
+ 2. **"The mount strips exec bits."** It does not. A fresh file keeps `0755`, and `chmod +x`
226
+ on an existing hook persists across a cold re-listing. The LFS hooks are `0644` because
227
+ of how the repo was materialized β€” every tracked file shares the `Jul 26 19:10` timestamp.
228
+ 3. **"`rmdir` fixes it."** Only until the next lookup, unless the triggering key is removed.
229
+ 4. **"Not yet reported upstream."** Wrong β€” PR #207 predates this work by three weeks. The
230
+ `is:issue` search filter hid it.
231
+ 5. **"The Hub API is buggy."** Reframed: raw-prefix matching is the documented object-store
232
+ contract; the defect is hf-mount not translating it into filesystem semantics.
233
+ 6. **"Any strict string prefix materializes deterministically."** Too strong β€” see
234
+ [Reproduction](#reproduction).
235
+ 7. **"They are always empty."** They contain the synthesized `home`/`workspaces` child.
236
+
237
+ ## Open items
238
+
239
+ - [ ] **LFS is silently inert.** `.gitattributes` has 36 LFS patterns; `filter.lfs.*`
240
+ clean/smudge are active, but all four hooks are `0644` and git skips them silently β€”
241
+ it now says so out loud: *"the '.git/hooks/post-commit' hook was ignored because it's
242
+ not set as executable."* LFS **upload** happens in `pre-push`, so a push may land
243
+ pointers whose blobs were never uploaded. **Not tested.** Fix:
244
+ `chmod +x .git/hooks/{pre-push,post-commit,post-checkout,post-merge}`.
245
+ - [ ] Comment on PR #207 with this independent repro β€” it broadens the impact beyond
246
+ Lance/Delta to git hooks and CLI config files, which may help it get merged.
247
+ - [ ] Fix `session-sharing.md` Β§12 (correction 1).
248
+ - [ ] Sweep other agent home dirs for `foo` / `foo.<ext>` pairs before they bite a
249
+ different CLI.
250
+ - [ ] Consider moving Codex `sessions` fully to local disk with completed-rollout sync.
251
+
252
+ ## Related, separately confirmed
253
+
254
+ - **Stale directory listings.** `server/src/share.js` was written, then `ls`, `find` and
255
+ `node --check` all reported it missing; `git ls-files` showed 13 files in `server/src`
256
+ while `ls` showed 10, `git status` clean throughout. Nothing lost β€” the readdir cache was
257
+ stale and resolved seconds later. Hit again during today's repairs: a just-written
258
+ `projects.json` was absent from `ls` but `stat`'d fine. **Rule: on this mount a "missing
259
+ file" needs a retry before you believe it, and a clean `git status` is not proof the tree
260
+ matches the index.** Matches [#160](https://github.com/huggingface/hf-mount/issues/160).
261
+ - **SQLite on the bucket.** [#103](https://github.com/huggingface/hf-mount/issues/103);
262
+ already handled by `1dfb753` and the local-disk `$HOME` relocations in `entrypoint.sh`.
entrypoint.sh CHANGED
@@ -10,12 +10,47 @@ if ! mkdir -p "$DATA_DIR/workspaces" 2>/dev/null; then
10
  fi
11
  export DATA_DIR
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # Put HOME on the durable bucket so EVERY agent's logins/config persist across
14
  # restarts (gemini ~/.gemini, etc.). Agents whose SQLite state can't live on the
15
  # FUSE bucket (codex, openclaw, opencode, hermes) are relocated to local disk
16
  # below, each with its own durable copy on the bucket.
17
  export HOME="$DATA_DIR/home"
18
  mkdir -p "$HOME"
 
 
 
 
 
 
19
  # Claude keeps its established dir (so existing logins keep working). Codex's
20
  # home moves to local disk below (its SQLite databases corrupt on the bucket).
21
  export CLAUDE_CONFIG_DIR="$DATA_DIR/state/claude"
@@ -51,9 +86,12 @@ AM_MAIN_PID=$$
51
  # the disk resets.
52
  CODEX_DURABLE="$DATA_DIR/state/codex"
53
  export CODEX_HOME="$AM_LOCAL/codex-home"
54
- mkdir -p "$CODEX_HOME" "$CODEX_DURABLE/sessions" "$CODEX_DURABLE/db-backups"
 
 
 
55
  # quarantine sqlite remnants on the bucket (incl. the earlier symlink attempt)
56
- mkdir -p "$CODEX_DURABLE/db-backups/am-quarantine"
57
  for f in "$CODEX_DURABLE"/logs_2.sqlite* "$CODEX_DURABLE"/goals_1.sqlite* "$CODEX_DURABLE"/memories_1.sqlite*; do
58
  [ -e "$f" ] || [ -L "$f" ] && mv "$f" "$CODEX_DURABLE/db-backups/am-quarantine/" 2>/dev/null || true
59
  done
@@ -118,7 +156,8 @@ cp "$HOME/.gitconfig" "$OPENCLAW_HOME/.gitconfig" 2>/dev/null || true
118
  # of chat history.
119
  OC_LIVE="$AM_LOCAL/opencode-share"; OC_DURABLE="$DATA_DIR/state/opencode"; OC_LINK="$HOME/.local/share/opencode"
120
  HERMES_LIVE="$AM_LOCAL/hermes"; HERMES_DURABLE="$DATA_DIR/state/hermes"; HERMES_LINK="$HOME/.hermes"
121
- mkdir -p "$OC_LIVE" "$OC_DURABLE" "$(dirname "$OC_LINK")" "$HERMES_LIVE" "$HERMES_DURABLE"
 
122
  # One-time migration: existing history is a REAL dir at the well-known path on
123
  # the bucket. Fold it into the durable store BEFORE the path becomes a symlink,
124
  # so no conversation is stranded on the bucket or lost.
 
10
  fi
11
  export DATA_DIR
12
 
13
+ # Empty directories do NOT persist on the bucket β€” there is no backing object
14
+ # key, so a dir created empty is gone after a restart, and anything pointing at
15
+ # it (a symlink, a config path) breaks. `keepdir` occupies the path with a real
16
+ # file so it survives. Use it for every bucket-backed dir created empty.
17
+ # Seen in the wild: $CODEX_DURABLE/sessions vanished, leaving
18
+ # $CODEX_HOME/sessions dangling -> codex "thread-store internal error:
19
+ # File exists (os error 17)" and every transcript lost.
20
+ keepdir() {
21
+ for d in "$@"; do
22
+ mkdir -p "$d" 2>/dev/null || continue
23
+ [ -e "$d/.keep" ] || echo "keep marker: empty dirs are not persisted on the /data bucket" > "$d/.keep" 2>/dev/null || true
24
+ done
25
+ }
26
+
27
+ # Occupy a config path with a real file when nothing lives there. Two bugs need
28
+ # this. (1) A tool that writes atomically (temp + rename) can leave the temp
29
+ # behind and never land the target. (2) The bucket tree API then matches
30
+ # `<path>` against the leftover `<path>.<uuid>.tmp` by RAW STRING PREFIX, and
31
+ # hf-mount reads a non-empty listing as proof the path is a DIRECTORY β€” so the
32
+ # missing file materializes as a phantom dir and readers die with EISDIR.
33
+ # A real file short-circuits it: the HEAD succeeds, so the listing fallback that
34
+ # synthesizes the directory never runs. See docs/fuse-phantom-directories.md.
35
+ occupy_file() {
36
+ path="$1"; default="$2"
37
+ mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
38
+ [ -d "$path" ] && rm -rf "$path" 2>/dev/null
39
+ [ -e "$path" ] || printf '%s\n' "$default" > "$path" 2>/dev/null || true
40
+ }
41
+
42
  # Put HOME on the durable bucket so EVERY agent's logins/config persist across
43
  # restarts (gemini ~/.gemini, etc.). Agents whose SQLite state can't live on the
44
  # FUSE bucket (codex, openclaw, opencode, hermes) are relocated to local disk
45
  # below, each with its own durable copy on the bucket.
46
  export HOME="$DATA_DIR/home"
47
  mkdir -p "$HOME"
48
+ # Gemini writes its project registry atomically and the rename can fail on the
49
+ # bucket, leaving projects.json.<uuid>.tmp orphans and no projects.json β€” after
50
+ # which the prefix bug above turns projects.json into a directory and the CLI
51
+ # dies with `EISDIR: illegal operation on a directory, read`. Keep a real file
52
+ # there. Same class as the opencode.json guard in server/src/runner.js.
53
+ occupy_file "$HOME/.gemini/projects.json" '{"projects": {}}'
54
  # Claude keeps its established dir (so existing logins keep working). Codex's
55
  # home moves to local disk below (its SQLite databases corrupt on the bucket).
56
  export CLAUDE_CONFIG_DIR="$DATA_DIR/state/claude"
 
86
  # the disk resets.
87
  CODEX_DURABLE="$DATA_DIR/state/codex"
88
  export CODEX_HOME="$AM_LOCAL/codex-home"
89
+ mkdir -p "$CODEX_HOME"
90
+ # keepdir, not mkdir: `sessions` is the symlink target below, and an empty dir
91
+ # on the bucket disappears β€” which is exactly how codex lost its thread store.
92
+ keepdir "$CODEX_DURABLE/sessions" "$CODEX_DURABLE/db-backups"
93
  # quarantine sqlite remnants on the bucket (incl. the earlier symlink attempt)
94
+ keepdir "$CODEX_DURABLE/db-backups/am-quarantine"
95
  for f in "$CODEX_DURABLE"/logs_2.sqlite* "$CODEX_DURABLE"/goals_1.sqlite* "$CODEX_DURABLE"/memories_1.sqlite*; do
96
  [ -e "$f" ] || [ -L "$f" ] && mv "$f" "$CODEX_DURABLE/db-backups/am-quarantine/" 2>/dev/null || true
97
  done
 
156
  # of chat history.
157
  OC_LIVE="$AM_LOCAL/opencode-share"; OC_DURABLE="$DATA_DIR/state/opencode"; OC_LINK="$HOME/.local/share/opencode"
158
  HERMES_LIVE="$AM_LOCAL/hermes"; HERMES_DURABLE="$DATA_DIR/state/hermes"; HERMES_LINK="$HOME/.hermes"
159
+ mkdir -p "$OC_LIVE" "$HERMES_LIVE" "$(dirname "$OC_LINK")"
160
+ keepdir "$OC_DURABLE" "$HERMES_DURABLE"
161
  # One-time migration: existing history is a REAL dir at the well-known path on
162
  # the bucket. Fold it into the durable store BEFORE the path becomes a symlink,
163
  # so no conversation is stranded on the bucket or lost.