diff --git a/.devcontainer/README.md b/.devcontainer/README.md index fb1a6da6b..ab2eec227 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -174,7 +174,7 @@ That means: - **`gh` auth is shared.** `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. - **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do. -The bind mount source directories are guaranteed to exist by the `initializeCommand` (`mkdir -p $HOME/.claude $HOME/.codex $HOME/.cursor $HOME/.config/gh`), which runs on the host shell before container create. +The bind mount source directories are guaranteed to exist by the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows `cmd.exe` and POSIX shells, and it creates the full set of bind-mount source dirs — all the `~/.claude`, `~/.codex`, `~/.cursor` shareable subdirs plus `~/.ssh`, `~/.docker`, `~/.aws`, `~/.azure`, `~/.config/{gh,git}` — not just the top-level CLI dirs. ### Trust boundary, concretely diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh index 22d6cc416..46c419711 100644 --- a/.devcontainer/install-deps.sh +++ b/.devcontainer/install-deps.sh @@ -19,17 +19,20 @@ echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points" # owned by the stale UID — npm install can't write. Re-chown # post-realignment; idempotent on subsequent runs. # -# `find -xdev -exec chown` (same idiom as post-create.sh) rather than a bare -# `chown -R`: -xdev keeps each chown ON its own volume filesystem, and `find` -# does not follow symlinks during traversal — so a symlink committed in the -# workspace tree (or dropped by a dependency postinstall on a rerun) can't -# redirect the chown onto a host path outside the volume. +# `find -xdev -exec chown -h` (same idiom as post-create.sh) rather than a bare +# `chown -R`. Two distinct guards: `-xdev` bounds find's DESCENT to each +# volume's own filesystem (it won't recurse into a sub-mounted host bind), and +# `-h` makes chown act on a symlink ITSELF rather than dereferencing it. Without +# `-h`, a symlink inside the tree (a dep postinstall dropping one, or a dangling +# node_modules/.bin link) would either redirect the chown onto its cross-fs +# target or abort provisioning under `set -e` with a dereference error. `-h` is +# a no-op for regular files/dirs, so the intended ownership fix is unchanged. for d in /workspace/node_modules \ /workspace/gitnexus/node_modules \ /workspace/gitnexus-web/node_modules \ /workspace/gitnexus-shared/node_modules \ /home/node/.npm; do - sudo find "$d" -xdev -exec chown node:node {} + + sudo find "$d" -xdev -exec chown -h node:node {} + done echo "[install-deps] 2/4: clear stale .husky/_ runtime cache" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index d7440d292..302283e48 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -18,15 +18,18 @@ echo "[post-create] 1/2: chown AI CLI named-volume mount points" # install-deps.sh handles the workspace-side chown; this script handles # the AI CLI side so each lifecycle hook owns its own concern. # -# `-xdev` keeps chown ON THE VOLUME filesystem and stops it descending into -# the RW host bind mounts overlaid at sub-paths (plugins/marketplaces, -# plugins/cache, skills, agents, memory, commands, codex/plugins, cursor/*). -# Those binds are a DIFFERENT filesystem (9p/virtiofs/bind); recursing into -# them would rewrite host file ownership on a non-UID-aligned Linux host and, -# worse, an EPERM there would abort provisioning before credentials sync. +# Two distinct guards. `-xdev` bounds find's DESCENT to the volume filesystem +# so it won't recurse into the RW host bind mounts overlaid at sub-paths +# (plugins/marketplaces, plugins/cache, skills, agents, memory, commands, +# codex/plugins, cursor/*). Those binds are a DIFFERENT filesystem +# (9p/virtiofs/bind); recursing into them would rewrite host file ownership on +# a non-UID-aligned Linux host and, worse, an EPERM there would abort +# provisioning before credentials sync. `-h` makes chown act on a symlink +# ITSELF, never dereferencing it onto a cross-fs target and never aborting on a +# dangling link under `set -e` (it's a no-op for regular files/dirs). for d in /home/node/.claude /home/node/.codex /home/node/.cursor \ /home/node/.local /commandhistory; do - sudo find "$d" -xdev -exec chown node:node {} + + sudo find "$d" -xdev -exec chown -h node:node {} + done echo "[post-create] 2/2: sync AI CLI credentials + identity from host" @@ -105,8 +108,8 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644 # host's `installMethod` (e.g. "native") makes Claude probe ~/.local/bin/claude # and fail "claude command not found at /home/node/.local/bin/claude". The # transform (strip machine fields + force hasCompletedOnboarding, guarding a -# non-object host file) lives in seed-claude-config.cjs so it is lintable and -# unit-tested (translate-plugin-registries.test.cjs). +# non-object host file) lives in seed-claude-config.cjs so it is unit-tested +# and prettier-checked (translate-plugin-registries.test.cjs). node "$SCRIPT_DIR/seed-claude-config.cjs" # Plugin registry path translation (Claude + Cursor). Both bake absolute @@ -115,7 +118,7 @@ node "$SCRIPT_DIR/seed-claude-config.cjs" # 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). The translation (regex + deep rewrite + the REGISTRIES table) lives -# in translate-plugin-registries.cjs so it is lintable and unit-tested. Codex +# in translate-plugin-registries.cjs so it is unit-tested and prettier-checked. 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 "$SCRIPT_DIR/translate-plugin-registries.cjs" diff --git a/.devcontainer/seed-claude-config.cjs b/.devcontainer/seed-claude-config.cjs index d9e488731..ee998a4c3 100644 --- a/.devcontainer/seed-claude-config.cjs +++ b/.devcontainer/seed-claude-config.cjs @@ -9,8 +9,8 @@ // its own method, and force hasCompletedOnboarding so the wizard is skipped // even on a first-time host. // -// Extracted from a post-create.sh heredoc so the pure transform is lintable -// and unit-tested (see seed-claude-config.test via translate-plugin-registries +// Extracted from a post-create.sh heredoc so the pure transform is unit-tested +// and prettier-checked (see seed-claude-config.test via translate-plugin-registries // test harness). DISABLE_AUTOUPDATER=1 (containerEnv) already neutralizes // runtime updates; this purely silences the doctor mismatch + native probe. diff --git a/.devcontainer/translate-plugin-registries.cjs b/.devcontainer/translate-plugin-registries.cjs index 2425635d6..0d3998298 100644 --- a/.devcontainer/translate-plugin-registries.cjs +++ b/.devcontainer/translate-plugin-registries.cjs @@ -11,7 +11,7 @@ // so its whole plugins/ dir is bind-mounted instead.) // // Extracted from a post-create.sh heredoc so the regex + deep rewrite are -// lintable and unit-tested (the regex has had path-handling bugs before). +// unit-tested and prettier-checked (the regex has had path-handling bugs before). 'use strict'; diff --git a/.devcontainer/translate-plugin-registries.test.cjs b/.devcontainer/translate-plugin-registries.test.cjs index 01041fa77..6b31b9d46 100644 --- a/.devcontainer/translate-plugin-registries.test.cjs +++ b/.devcontainer/translate-plugin-registries.test.cjs @@ -305,6 +305,30 @@ test('seed main: missing host file still writes a valid onboarding-bearing file' } }); +test('seed main: chmodSync widens a pre-existing restrictive dst to 0o644', () => { + // POSIX mode bits only. Under the CI default umask (022) a plain writeFileSync + // already yields 0o644, so asserting 0o644 after a fresh write does NOT prove + // the explicit chmodSync did anything. Pre-create dst at 0o600 first: a 'w' + // write truncates content but PRESERVES an existing file's mode, so the file + // can only reach 0o644 via seed-claude-config.cjs's chmodSync. This isolates + // the chmod from the umask-default path (delete the chmodSync line and this + // test fails, where the other seed test would still pass). + if (process.platform === 'win32') return; + const dir = tmp(); + try { + const src = path.join(dir, 'host.claude.json'); + const dst = path.join(dir, 'out.claude.json'); + fs.writeFileSync(src, JSON.stringify({ userID: 'u' })); + fs.writeFileSync(dst, '{}'); + fs.chmodSync(dst, 0o600); + execFileSync(process.execPath, [SEED_SCRIPT, src, dst]); + assert.equal(fs.statSync(dst).mode & 0o777, 0o644); + assert.equal(JSON.parse(fs.readFileSync(dst, 'utf8')).userID, 'u'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + // --- ensurePaths: host bind-source bootstrap -------------------------------- test('ensurePaths: creates every DIR and FILE under a temp home, idempotently', () => { diff --git a/.github/workflows/ci-devcontainer.yml b/.github/workflows/ci-devcontainer.yml index 8a6496c6a..3984ef17f 100644 --- a/.github/workflows/ci-devcontainer.yml +++ b/.github/workflows/ci-devcontainer.yml @@ -65,6 +65,15 @@ jobs: # This is the smoke that catches Dockerfile regressions + drift from the # canonical version pins. Lifecycle hooks (post-create.sh) are not run # here — they need the host config mounts, which CI has none of. + # + # ARCH COVERAGE: this runs on an x64 runner with no --platform/QEMU, so it + # exercises the amd64 Cursor branch (CURSOR_SHA256_X64) only. The arm64 + # branch (CURSOR_SHA256_ARM64 + the arm64 tarball URL) is pinned by sha256 + # verified against the published artifact, but is not BUILT here. Cursor's + # extract+symlink step is arch-independent, so the residual gap is a stale + # arm64 URL/hash; add a linux/arm64 matrix leg (docker/setup-qemu-action + + # `--platform`) if that becomes a concern. + # # Version-pinned: bare `npx --yes @devcontainers/cli` resolves @latest at # run time, so a breaking or malicious publish could change CI behavior # (or how devcontainer.json is interpreted) with no diff. Bump