fix(devcontainer): drop single-file binds — fixes Codex batchWrite failed in TUI

On Docker Desktop Windows the named volumes are ext4 (`/dev/sdd`) while
single-file bind mounts from the Windows host land as 9p (drvfs).
Different filesystems → atomic config writes (write `foo.tmp`, then
rename onto `foo`) trip EXDEV `inter-device move failed` /
`Device or resource busy`.

Codex's TUI surfaces this as `config/batchWrite failed in TUI` when
saving model preference. Claude's writes to settings.json / .claude.json
fail the same way, silently.

Reproduction in container:
  $ echo x > /tmp/foo.toml; mv /tmp/foo.toml /home/node/.codex/config.toml
  mv: inter-device move failed: ... Device or resource busy

Fix: drop the three single-file bind mounts. Sync host's versions into
the named volume on container-create via `sync_from_host` (same pattern
already used for credentials). Atomic rename within the volume works
because everything is ext4.

Trade-off: container writes to these files no longer propagate to host;
they stay in the volume until next rebuild, which re-syncs from host.
Host is source of truth on rebuild — same model as credentials. Plugin/
skill/agent/memory/command DIRS still bind-mount bidirectionally (atomic
writes within a dir bind stay on one filesystem, no EXDEV).

Files affected:
- ~/.codex/config.toml
- ~/.claude/settings.json
- ~/.claude.json (HOME-level — added `/host/.claude.json` RO mount back
  for sync_from_host to read)
This commit is contained in:
Gergo Magyar 2026-05-28 19:09:42 +01:00
parent ed03ae3ea8
commit 2374e2c55b
2 changed files with 26 additions and 3 deletions

View file

@ -108,17 +108,30 @@
// `/Users/x/...` on macOS) so it CANNOT be shared as-is — stays in
// the named volume, generated by post-create.sh from host's versions
// with paths translated to the container's Linux paths.
// DIRECTORY binds — bidirectional, atomic writes inside the dir work
// fine (same filesystem). Sub-path writes from container go to host
// immediately; host changes are visible to container immediately.
"source=${localEnv:HOME}/.claude/plugins/marketplaces,target=/home/node/.claude/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.claude/plugins/cache,target=/home/node/.claude/plugins/cache,type=bind",
"source=${localEnv:HOME}/.claude/skills,target=/home/node/.claude/skills,type=bind",
"source=${localEnv:HOME}/.claude/agents,target=/home/node/.claude/agents,type=bind",
"source=${localEnv:HOME}/.claude/memory,target=/home/node/.claude/memory,type=bind",
"source=${localEnv:HOME}/.claude/commands,target=/home/node/.claude/commands,type=bind",
"source=${localEnv:HOME}/.claude/settings.json,target=/home/node/.claude/settings.json,type=bind",
"source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind",
"source=${localEnv:HOME}/.codex/config.toml,target=/home/node/.codex/config.toml,type=bind",
"source=${localEnv:HOME}/.codex/memories,target=/home/node/.codex/memories,type=bind",
"source=${localEnv:HOME}/.codex/skills,target=/home/node/.codex/skills,type=bind",
//
// SINGLE-FILE binds for settings.json / .claude.json / config.toml
// are deliberately ABSENT. On Docker Desktop Windows the named-volume
// is ext4 (/dev/sdd) and a single-file bind from host is 9p drvfs —
// different filesystems. Atomic config writes (write `foo.tmp` ->
// rename onto `foo`) trip EXDEV and fail with `Device or resource
// busy` / `inter-device move failed`. Codex's TUI hits this as
// "config/batchWrite failed in TUI"; Claude silently loses writes
// through the same mechanism. Instead: host stage at /host/.claude
// (RO) + post-create.sh copies these files into the named volume on
// every container-create. Host changes propagate on rebuild;
// container changes stay container-local until rebuild.
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly",
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",

View file

@ -73,6 +73,16 @@ sync_from_host \
sync_from_host \
/host/.claude/.claude.json /home/node/.claude/.claude.json 644
# State files that USED to be single-file bind mounts but couldn't be: on
# Docker Desktop Windows the named volume (ext4) and the host bind-mount
# (9p drvfs) are different filesystems, so atomic config writes
# (`tmp -> rename onto target`) trip EXDEV / Device-or-resource-busy.
# Copy host's version into the named volume on container-create; container
# can rewrite freely from there until next rebuild resyncs.
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
sync_from_host /host/.claude.json /home/node/.claude.json 644
sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# Plugin registry path translation. Claude writes absolute OS-native paths
# into known_marketplaces.json (`installLocation`), installed_plugins.json
# (`installPath`), and plugin-catalog-cache.json — `C:\Users\X\.claude\...`