From 2374e2c55bd0cca925e6e00f3a279da4a1a7067c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 19:09:42 +0100 Subject: [PATCH] =?UTF-8?q?fix(devcontainer):=20drop=20single-file=20binds?= =?UTF-8?q?=20=E2=80=94=20fixes=20Codex=20`batchWrite=20failed=20in=20TUI`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .devcontainer/devcontainer.json | 19 ++++++++++++++++--- .devcontainer/post-create.sh | 10 ++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5d308a169..3b3009dc7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 892e1ffbe..533ebae6f 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -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\...`