diff --git a/.devcontainer/README.md b/.devcontainer/README.md index d2e89ff29..4d575df5b 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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 `~/./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/./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:** diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3b3009dc7..9ff293537 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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// 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 — diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs index f81bda462..d2392fa93 100644 --- a/.devcontainer/ensure-host-config-dirs.cjs +++ b/.devcontainer/ensure-host-config-dirs.cjs @@ -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/. 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'), diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 533ebae6f..5839a1040 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -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/` to `/home/node/.claude/plugins/`, 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 `/./plugins/` to +# `/home/node/./plugins/`, 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\` -// or POSIX `/Users/…/.claude/plugins/` or -// `/home/…/.claude/plugins/`) 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"