feat(devcontainer): Codex + Cursor plugin/config host parity with Claude

Codex plugins installed in the container never reached the Windows host
because, unlike Claude, the Codex plugin tree wasn't bind-mounted —
only memories/ and skills/ were. Verified via live /proc/mounts: Claude
binds 6 shareable dirs (incl. plugins/marketplaces + plugins/cache),
Codex bound 2. So `codex plugin add` wrote into the ext4 named volume
and stayed there.

Codex changes:
- Bind the WHOLE ~/.codex/plugins dir + ~/.codex/prompts (plus existing
  memories/skills). Strace of two real `codex plugin add` runs proved
  the installer stages INSIDE plugins/cache/<marketplace>/ and renames
  intra-dir, so a single 9p bind of plugins/ keeps the rename intra-fs —
  no EXDEV (the bug that broke single-file binds). .tmp/ stays on the
  volume (it's the cross-fs staging source). No path translation needed:
  Codex enablement lives in config.toml as git URLs, not FS paths.
- Verified live: `codex plugin add compound-engineering@...` now writes
  through to C:\Users\...\.codex\plugins\cache\ on the Windows host, and
  host-created files appear in the container (bidirectional).

Cursor changes (review found cursor-agent has a real plugin surface, not
editor-only — Cursor 2.5 Marketplace shared by IDE + CLI):
- Bind plugins/marketplaces, plugins/local, rules, commands, agents,
  skills (dir binds, EXDEV-safe).
- Copy-on-create mcp.json (single file → EXDEV-unsafe as bind), alongside
  the existing cli-config.json.
- Translate plugins/installed_plugins.json (carries absolute Windows
  paths like Claude's) — generalized the existing path-rewrite to run
  for both Claude and Cursor.
- hooks.json deliberately NOT shared (runs shell commands → supply-chain
  surface); documented as opt-in.

ensure-host-config-dirs.cjs pre-creates all new host bind sources.
post-create.sh defensive symlink cleanup extended to the new Codex/Cursor
paths. README updated with the accurate per-CLI share/sync/translate
matrix.

Design adversarially verified (straced installs, EXDEV primitive tests,
sqlite-under-bind check, path-encoding check) before implementing.
This commit is contained in:
Gergo Magyar 2026-05-28 19:50:47 +01:00
parent 18a78f5b99
commit 0d4a53e84d
4 changed files with 145 additions and 69 deletions

View file

@ -88,17 +88,21 @@ The three AI CLIs use a **hybrid mount topology**: shareable subdirs/files (plug
| Host Claude state, read-only stage | `$HOME/.claude` | `/host/.claude` | **read-only** | `post-create.sh` reads credentials + identity from here on container-create |
| Host Codex state, read-only stage | `$HOME/.codex` | `/host/.codex` | **read-only** | Same purpose for Codex |
| Host Cursor state, read-only stage | `$HOME/.cursor` | `/host/.cursor` | **read-only** | Same purpose for Cursor |
| **Claude shareable subdirs** (overlay on the volume) | `$HOME/.claude/{plugins,skills,agents,memory,commands}` | `/home/node/.claude/{plugins,skills,agents,memory,commands}` | rw | **Bidirectional** — install plugin on host or in container, both sides see it |
| **Claude shareable files** (overlay on the volume) | `$HOME/.claude/settings.json`, `$HOME/.claude.json` | `/home/node/.claude/settings.json`, `/home/node/.claude.json` | rw | Theme, enabled plugins, MCP user-scope, `hasCompletedOnboarding`, project trust — all shared |
| **Codex shareable** | `$HOME/.codex/{config.toml,memories,skills}` | `/home/node/.codex/{config.toml,memories,skills}` | rw | Symmetric with Claude's shareable surface |
| **Claude shareable subdirs** (overlay on the volume) | `$HOME/.claude/{plugins/marketplaces,plugins/cache,skills,agents,memory,commands}` | same under `/home/node/.claude/` | rw | **Bidirectional** dir binds — install plugin on host or in container, both sides see it |
| **Codex shareable subdirs** | `$HOME/.codex/{plugins,prompts,memories,skills}` | same under `/home/node/.codex/` | rw | **Bidirectional** — whole `plugins/` dir bound (no path-bearing registry inside it) |
| **Cursor shareable subdirs** | `$HOME/.cursor/{plugins/marketplaces,plugins/local,rules,commands,agents,skills}` | same under `/home/node/.cursor/` | rw | **Bidirectional** — cursor-agent shares the Cursor 2.5 plugin/rules/commands surface |
**What gets shared bidirectionally (RW bind from host):**
**What gets shared bidirectionally (RW dir bind from host):**
- **Claude**: `plugins/`, `skills/`, `agents/`, `memory/`, `commands/` (the user-installed surface), `settings.json` (theme + `enabledPlugins` + `extraKnownMarketplaces`), `$HOME/.claude.json` (`hasCompletedOnboarding` + MCP user-scope + per-project trust + activity counters)
- **Codex**: `config.toml` (prefs), `memories/` + `skills/` (user-installed surface)
- **Cursor**: nothing structural (Cursor doesn't expose plugin/skill/agent dirs — `cli-config.json` conflates auth+settings and stays per-container)
- **Claude**: `plugins/marketplaces`, `plugins/cache`, `skills/`, `agents/`, `memory/`, `commands/`
- **Codex**: `plugins/` (whole dir — installed plugins land on host), `prompts/`, `memories/`, `skills/`
- **Cursor**: `plugins/marketplaces`, `plugins/local`, `rules/`, `commands/`, `agents/`, `skills/`
Install a plugin on the host or inside the container — both sides see it immediately. Run `/plugin marketplace add` from inside the container and it lands in your host `~/.claude/plugins/marketplaces/`. Save a memory via the `/remember` skill from either side and it persists to the same host file.
Install a plugin on the host or inside the container — both sides see it immediately. `/plugin marketplace add` from inside the container lands in your host `~/.<cli>/plugins/`, and `codex plugin add` / `cursor-agent` plugin installs write straight through to Windows. Save a memory from either side and it persists to the same host file.
**Single config files are copied on container-create, not bind-mounted** — on Docker Desktop Windows a single-file bind is 9p while the named volume is ext4, and atomic config writes (`tmp` → rename onto target) trip EXDEV (this is what caused Codex's `config/batchWrite failed in TUI`). So these are synced from host on rebuild and the container rewrites its own copy until the next rebuild: `settings.json` + `$HOME/.claude.json` (Claude), `config.toml` (Codex), `cli-config.json` + `mcp.json` (Cursor). `hooks.json` (Cursor) is deliberately **not** synced — Cursor hooks execute shell commands, so sharing them would widen the supply-chain attack surface; add it yourself if you want it.
**Plugin registry files with absolute paths are translated, not shared** — Claude's `known_marketplaces.json` / `installed_plugins.json` / `plugin-catalog-cache.json` and Cursor's `installed_plugins.json` bake in `C:\Users\…` (Windows) or `/Users/…` (macOS) install paths. `post-create.sh` rewrites those to `/home/node/.<cli>/plugins/…` and writes the result into the named volume, so plugins resolve inside Linux instead of failing with `cache-miss`. Codex needs no translation — its enablement registry is `config.toml` (git URLs + logical keys, no filesystem paths), which is why its whole `plugins/` dir can be bound directly.
**What stays per-container (in the named volume) and is synced from host on container-create:**

View file

@ -111,15 +111,48 @@
// 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.
//
// Claude: plugins SOURCE dirs (marketplaces/ git clones + cache/
// extracted files) are path-independent; the path-bearing registry
// JSONs at plugins/ root stay in the volume and are translated by
// post-create.sh (see SINGLE-FILE note below).
"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",
//
// Codex: bind the WHOLE plugins/ dir (parent of cache/). `codex plugin
// add` stages installs INSIDE plugins/cache/<marketplace>/ and renames
// intra-dir (verified by strace), so under a single 9p bind of plugins/
// the rename stays intra-9p — no EXDEV. Unlike Claude, Codex has NO
// path-bearing registry file under plugins/ (enablement lives in
// config.toml at the .codex root as git URLs, not FS paths), so no
// translation is needed and the whole dir can be bound. `.tmp/` (the
// ext4 marketplace-clone staging area) is deliberately NOT bound — it
// must stay on the volume as the cross-fs source side.
"source=${localEnv:HOME}/.codex/plugins,target=/home/node/.codex/plugins,type=bind",
"source=${localEnv:HOME}/.codex/prompts,target=/home/node/.codex/prompts,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",
//
// Cursor: cursor-agent (CLI, not just the IDE) shares the Cursor 2.5
// plugin/rules/commands/agents/skills surface on disk. Bind the SOURCE
// dirs (path-independent). plugins/installed_plugins.json carries
// absolute Windows paths like Claude's registry, so it stays in the
// volume and is translated by post-create.sh — that's why plugins/
// sub-dirs are bound individually rather than the whole plugins/ dir.
// mcp.json + hooks.json are single files (EXDEV-unsafe as binds) and
// are handled as copy-on-create; hooks.json is intentionally NOT
// shared (it executes shell commands — supply-chain surface).
"source=${localEnv:HOME}/.cursor/plugins/marketplaces,target=/home/node/.cursor/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.cursor/plugins/local,target=/home/node/.cursor/plugins/local,type=bind",
"source=${localEnv:HOME}/.cursor/rules,target=/home/node/.cursor/rules,type=bind",
"source=${localEnv:HOME}/.cursor/commands,target=/home/node/.cursor/commands,type=bind",
"source=${localEnv:HOME}/.cursor/agents,target=/home/node/.cursor/agents,type=bind",
"source=${localEnv:HOME}/.cursor/skills,target=/home/node/.cursor/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 —

View file

@ -82,20 +82,36 @@ const home = os.homedir();
const dirs = [
'.claude',
path.join('.claude', 'plugins'),
// Plugin registry source dirs — content is path-independent so these
// get RW bind-mounted bidirectionally. The path-DEPENDENT registry
// JSONs (known_marketplaces.json, installed_plugins.json,
// plugin-catalog-cache.json) stay in the container's named volume.
// Claude plugin SOURCE dirs — content is path-independent so these get
// RW bind-mounted bidirectionally. The path-DEPENDENT registry JSONs
// (known_marketplaces.json, installed_plugins.json,
// plugin-catalog-cache.json) stay in the container's named volume,
// translated by post-create.sh.
path.join('.claude', 'plugins', 'marketplaces'),
path.join('.claude', 'plugins', 'cache'),
path.join('.claude', 'skills'),
path.join('.claude', 'agents'),
path.join('.claude', 'memory'),
path.join('.claude', 'commands'),
// Codex shareable surface. The whole plugins/ dir is bound (no
// path-bearing registry inside it — enablement is in config.toml at the
// root), plus prompts/ (saved prompt library), memories/, skills/.
'.codex',
path.join('.codex', 'plugins'),
path.join('.codex', 'prompts'),
path.join('.codex', 'memories'),
path.join('.codex', 'skills'),
// Cursor shareable surface (cursor-agent CLI shares the Cursor 2.5
// plugin/rules/commands/agents/skills dirs). plugins/ sub-dirs are
// bound individually because plugins/installed_plugins.json carries
// absolute paths and is translated (not bound) by post-create.sh.
'.cursor',
path.join('.cursor', 'plugins', 'marketplaces'),
path.join('.cursor', 'plugins', 'local'),
path.join('.cursor', 'rules'),
path.join('.cursor', 'commands'),
path.join('.cursor', 'agents'),
path.join('.cursor', 'skills'),
'.ssh',
'.docker',
'.aws',
@ -110,12 +126,16 @@ for (const dir of dirs) {
fs.mkdirSync(path.join(home, dir), { recursive: true });
}
// File bind-mount sources. devcontainer.json bind-mounts each file
// individually (so the host file IS the container file — bidirectional
// share). Touch-empty if absent so Docker doesn't reject the mount.
// `~/.claude.json` carries `hasCompletedOnboarding` + MCP user-scope +
// per-project trust; `~/.claude/settings.json` carries theme + enabled
// plugins; `~/.codex/config.toml` carries Codex user prefs.
// Single-file sources. `~/.claude.json` is bind-mounted READ-ONLY at
// /host/.claude.json, so it must exist or Docker rejects the mount —
// touch-empty if absent. The others are read by post-create.sh from the
// /host/.<cli> read-only dir stages and COPIED into the named volume
// (copy-on-create, never single-file-bound — that trips EXDEV on Docker
// Desktop Windows). Touching them is harmless and gives sync a source:
// `~/.claude/settings.json` (theme + enabled plugins), `~/.codex/config.toml`
// (Codex prefs + plugin enablement). `~/.cursor/{mcp.json,cli-config.json}`
// are left untouched — sync_from_host no-ops if the host never created
// them, which is the correct "not configured yet" state.
const files = [
'.claude.json',
path.join('.claude', 'settings.json'),

View file

@ -33,23 +33,31 @@ echo "[post-create] 2/2: sync AI CLI credentials + identity from host"
for p in plugins skills agents memory commands; do
[ -L "/home/node/.claude/$p" ] && rm "/home/node/.claude/$p"
done
for p in config.toml memories skills; do
for p in plugins prompts memories skills config.toml; do
[ -L "/home/node/.codex/$p" ] && rm "/home/node/.codex/$p"
done
mkdir -p /home/node/.claude/plugins
for p in plugins rules commands agents skills; do
[ -L "/home/node/.cursor/$p" ] && rm "/home/node/.cursor/$p"
done
mkdir -p /home/node/.claude/plugins /home/node/.cursor/plugins
# Plugins, skills, agents, memory, commands, settings.json, $HOME/.claude.json,
# Codex config.toml/memories/skills are all RW bind-mounted directly from
# host in devcontainer.json — they live on host and reads/writes go
# bidirectionally. Nothing for this script to do for those.
# Shareable content is RW bind-mounted directly from host in
# devcontainer.json — reads/writes go bidirectionally, nothing for this
# script to do for those:
# - Claude: plugins/{marketplaces,cache}, skills, agents, memory, commands
# - Codex: plugins (whole dir), prompts, memories, skills
# - Cursor: plugins/{marketplaces,local}, rules, commands, agents, skills
#
# What stays per-container (in the named volume) and gets SYNCED from
# host on container-create:
# - .credentials.json (Claude OAuth tokens)
# - .claude/.claude.json (Claude identity: userID, oauthAccount,
# migration tracking — different file from $HOME/.claude.json)
# - auth.json (Codex)
# - cli-config.json (Cursor — conflates auth + settings)
# - settings.json (Claude), config.toml (Codex), mcp.json (Cursor) —
# single config files that can't be bind-mounted (EXDEV on Windows)
# - auth.json (Codex), cli-config.json (Cursor — conflates auth+settings)
# - plugins registry JSONs with absolute paths (Claude + Cursor) —
# translated below
#
# Sync semantics: ALWAYS overwrite from host on container-create, so a
# fresh container starts logged in as host's user (if host had creds).
@ -83,61 +91,66 @@ 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\...`
# on Windows hosts, `/Users/X/.claude/...` on macOS — so the host versions
# can't be bind-mounted into the Linux container (Claude would fail with
# `cache-miss` trying to resolve a Windows path inside Linux). Read host's
# registry files, rewrite every absolute path that ends in
# `/.claude/plugins/<rest>` to `/home/node/.claude/plugins/<rest>`, and
# write the result into the container's named volume.
mkdir -p /home/node/.claude/plugins
# Plugin registry path translation (Claude + Cursor). Both write absolute
# OS-native install paths into their plugin registry JSONs —
# `C:\Users\X\.claude\plugins\...` on Windows, `/Users/X/.cursor/plugins/...`
# on macOS — so the host versions can't be bind-mounted into the Linux
# container (the CLI fails with `cache-miss` resolving a Windows path under
# Linux). For each CLI, read host's registry files, rewrite every absolute
# path ending in `/.<cli>/plugins/<rest>` to
# `/home/node/.<cli>/plugins/<rest>`, and write the result into the named
# volume. (Codex needs no translation — its enablement registry is
# config.toml with git URLs, not filesystem paths, so its whole plugins/
# dir is bind-mounted instead.)
node <<'NODE'
const fs = require("fs");
const path = require("path");
const HOST = "/host/.claude/plugins";
const CTR = "/home/node/.claude/plugins";
const buildRe = (cliName) =>
new RegExp(`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`);
// Match any absolute path (Windows `C:\Users\…\.claude\plugins\<rest>`
// or POSIX `/Users/…/.claude/plugins/<rest>` or
// `/home/…/.claude/plugins/<rest>`) and translate to the container path.
const rewrite = (s) => {
if (typeof s !== "string") return s;
return s.replace(
/^(?:[A-Za-z]:)?[\\/].*?[\\/]\.claude[\\/]plugins[\\/](.*)$/,
(_, rest) => `${CTR}/${rest.replace(/\\/g, "/")}`,
);
};
const rewriteDeep = (obj) => {
if (Array.isArray(obj)) return obj.map(rewriteDeep);
const rewriteDeep = (obj, re, ctr) => {
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
if (obj && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = typeof v === "string" ? rewrite(v) : rewriteDeep(v);
}
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
return out;
}
if (typeof obj === "string")
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, "/")}`);
return obj;
};
for (const name of [
"known_marketplaces.json",
"installed_plugins.json",
"plugin-catalog-cache.json",
]) {
const src = path.join(HOST, name);
const dst = path.join(CTR, name);
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
} catch {
continue;
const REGISTRIES = [
{
cli: "claude",
host: "/host/.claude/plugins",
ctr: "/home/node/.claude/plugins",
files: ["known_marketplaces.json", "installed_plugins.json", "plugin-catalog-cache.json"],
},
{
cli: "cursor",
host: "/host/.cursor/plugins",
ctr: "/home/node/.cursor/plugins",
files: ["installed_plugins.json"],
},
];
for (const reg of REGISTRIES) {
const re = buildRe(reg.cli);
fs.mkdirSync(reg.ctr, { recursive: true });
for (const name of reg.files) {
const src = path.join(reg.host, name);
const dst = path.join(reg.ctr, name);
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
} catch {
continue;
}
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));
}
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data), null, 2));
}
NODE
@ -151,8 +164,14 @@ sync_from_host \
# Cursor CLI — cli-config.json conflates auth + settings. Cursor has known
# upstream issues authenticating inside Docker even with correctly-copied
# config; if `cursor-agent` reports auth errors after copy, re-run
# `cursor-agent login` inside the container.
# `cursor-agent login` inside the container. mcp.json (Cursor's MCP server
# config) is a single file too — copy-on-create, not bind (EXDEV).
# hooks.json is deliberately NOT synced: Cursor hooks run shell commands,
# so sharing them would widen the supply-chain attack surface into the
# container. Add it yourself if you want host hooks inside the container.
sync_from_host \
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
sync_from_host \
/host/.cursor/mcp.json /home/node/.cursor/mcp.json 644
echo "[post-create] done"