From 6e5aa6784a00d46d9fb4cd4be6ace90edbbbc8ec Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Sat, 31 Oct 2026 09:34:16 -0300 Subject: [PATCH] fix(box): the Expo bake never writes root-owned entries into the box's npm cache; the CLI routes npm around a foreign cache and installs expo itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.76.41's image ran 'npm install -g expo' as root with npm_config_cache=/opt/npm-cache, so every 'npm install' of the box user failed EACCES on '_cacache/index-v5/…' (unfixable inside a --cap-drop ALL container): the preview's dependency installs died on every new Box. The bake now uses its own cache, chowns the box's and the build fails on any entry the box does not own. npmCacheOverride (run-setup.ts) points npm at ~/.npm when the configured cache holds another user's entries — boxes created with the bad image heal on the next install. The Expo bring-up also installs 'expo' itself (SDK from the project's react-native) when a project lacks it: a Remix MOBILE project's export declared only expo-* modules, and 'expo start' needs expo. Co-Authored-By: Claude Fable 5.1 --- apps/box/Dockerfile | 11 +++- apps/cli/__tests__/preview-expo-web.test.ts | 60 +++++++++++++++++-- .../preview-npm-cache-override.test.ts | 33 ++++++++++ apps/cli/src/services/preview/expo.ts | 52 ++++++++++++++-- apps/cli/src/services/preview/run-setup.ts | 46 +++++++++++++- .../services/preview/start-orchestrator.ts | 20 +++++-- 6 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 apps/cli/__tests__/preview-npm-cache-override.test.ts diff --git a/apps/box/Dockerfile b/apps/box/Dockerfile index c8271c23..40c87ffc 100644 --- a/apps/box/Dockerfile +++ b/apps/box/Dockerfile @@ -323,16 +323,25 @@ RUN install -d -o box -g box /opt/npm-cache /opt/uv-cache /opt/uv-tools \ # react-native-web, @expo/metro-runtime, expo-status-bar — resolved by `expo install`, so the # versions match the SDK), so the project's `npm install` reads tarballs from disk. Same # never-fails-the-build contract as mcp-warm: a MISS means a slower first preview, not a broken box. -RUN npm install -g expo \ +# ⚠️ Root's npm runs use THEIR OWN cache: `npm_config_cache` (above) points at /opt/npm-cache, the +# box user's cache, and a root-owned entry in it breaks EVERY later `npm install` of the box +# (EACCES on `_cacache/index-v5/…`, unfixable inside a `--cap-drop ALL` container — the +# 2026-10-31 v2.76.41 image: the Expo bake ran `npm install -g expo` as root into it). +RUN npm_config_cache=/tmp/npm-build-cache npm install -g expo \ + && rm -rf /tmp/npm-build-cache \ && chown -R box:box /usr/local/lib/node_modules /usr/local/bin \ && su box -s /bin/sh -c 'set -e; HOME=/home/box; export HOME; d=$(mktemp -d); cd "$d"; \ printf "{\"name\":\"expo-prewarm\",\"private\":true}" > package.json; \ npm install --no-audit --no-fund --legacy-peer-deps expo@latest; \ npx --no-install expo install react react-native react-dom react-native-web @expo/metro-runtime expo-status-bar; \ cd /; rm -rf "$d"' \ + && chown -R box:box /opt/npm-cache \ && echo "expo: OK $(expo --version)" \ || echo "expo: MISS (the first Expo preview downloads it)" +# The box's npm cache belongs to the box, every entry of it — the build fails otherwise (above). +RUN test -z "$(find /opt/npm-cache ! -user box -print -quit)" + # ── PATH: expose the curl-installer bin dirs under the runtime HOME=/home/box ─ ENV PATH="/opt/agents/.local/bin:/opt/agents/.kimi-code/bin:/opt/agents/bin:${PATH}" diff --git a/apps/cli/__tests__/preview-expo-web.test.ts b/apps/cli/__tests__/preview-expo-web.test.ts index 5a497c5f..01000318 100644 --- a/apps/cli/__tests__/preview-expo-web.test.ts +++ b/apps/cli/__tests__/preview-expo-web.test.ts @@ -3,25 +3,39 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + expoSdkOf, expoWebDepsInstall, expoWebInstallSpecs, + missingExpoPackages, missingExpoWebPackages, } from '../src/services/preview/expo'; -function project(installed: string[], pins?: Record): string { +function project(installed: string[], pins?: Record, withExpo = true): string { const dir = mkdtempSync(join(tmpdir(), 'codeam-expo-web-')); - for (const name of ['expo', ...installed]) { + for (const name of [...(withExpo ? ['expo'] : []), ...installed]) { mkdirSync(join(dir, 'node_modules', name), { recursive: true }); writeFileSync(join(dir, 'node_modules', name, 'package.json'), '{}'); } - if (pins) writeFileSync(join(dir, 'node_modules', 'expo', 'bundledNativeModules.json'), JSON.stringify(pins)); + if (pins) + writeFileSync( + join(dir, 'node_modules', 'expo', 'bundledNativeModules.json'), + JSON.stringify(pins), + ); return dir; } describe('the Expo WEB preview installs what Metro needs to serve a browser (owner 2026-10-31)', () => { it('a project without the web packages gets them at the SDK’s versions, --no-save', () => { - const dir = project([], { 'react-native-web': '~0.21.0', 'react-dom': '19.1.0', '@expo/metro-runtime': '~6.1.2' }); - expect(missingExpoWebPackages(dir)).toEqual(['react-native-web', 'react-dom', '@expo/metro-runtime']); + const dir = project([], { + 'react-native-web': '~0.21.0', + 'react-dom': '19.1.0', + '@expo/metro-runtime': '~6.1.2', + }); + expect(missingExpoWebPackages(dir)).toEqual([ + 'react-native-web', + 'react-dom', + '@expo/metro-runtime', + ]); expect(expoWebDepsInstall(dir)).toEqual({ cmd: 'npm', args: [ @@ -43,6 +57,42 @@ describe('the Expo WEB preview installs what Metro needs to serve a browser (own expect(expoWebInstallSpecs(dir, ['react-native-web'])).toEqual(['react-native-web']); }); + it('a Remix MOBILE export without `expo` itself (react-native 0.81): expo at SDK 54 + the web packages, --no-save', () => { + const dir = project(['react-native'], undefined, false); + writeFileSync( + join(dir, 'node_modules', 'react-native', 'package.json'), + '{"version":"0.81.4"}', + ); + expect(missingExpoPackages(dir)).toEqual([ + 'expo', + 'react-native-web', + 'react-dom', + '@expo/metro-runtime', + ]); + expect(expoSdkOf(dir)).toBe('54'); + expect(expoWebDepsInstall(dir)?.args.slice(5)).toEqual([ + 'expo@~54.0.0', + 'react-native-web@~0.21.0', + 'react-dom@19.1.0', + '@expo/metro-runtime@~6.1.2', + ]); + }); + + it('an unknown react-native: `expo` bare, the web packages bare', () => { + const dir = project(['react-native'], undefined, false); + writeFileSync( + join(dir, 'node_modules', 'react-native', 'package.json'), + '{"version":"0.99.0"}', + ); + expect(expoSdkOf(dir)).toBeNull(); + expect(expoWebDepsInstall(dir)?.args.slice(5)).toEqual([ + 'expo', + 'react-native-web', + 'react-dom', + '@expo/metro-runtime', + ]); + }); + it('everything present: no install', () => { const dir = project(['react-native-web', 'react-dom', '@expo/metro-runtime']); expect(expoWebDepsInstall(dir)).toBeNull(); diff --git a/apps/cli/__tests__/preview-npm-cache-override.test.ts b/apps/cli/__tests__/preview-npm-cache-override.test.ts new file mode 100644 index 00000000..5bcc5cec --- /dev/null +++ b/apps/cli/__tests__/preview-npm-cache-override.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { npmCacheOverride } from '../src/services/preview/run-setup'; + +describe('npmCacheOverride — a Box whose npm cache holds root-owned entries (2026-10-31)', () => { + const owners = (map: Record) => (p: string) => (p in map ? map[p] : null); + + it('points npm at ~/.npm when an index entry belongs to another user', () => { + expect( + npmCacheOverride( + { npm_config_cache: '/opt/npm-cache' }, + '/home/box', + owners({ + '/opt/npm-cache': 1001, + '/opt/npm-cache/_cacache': 1001, + '/opt/npm-cache/_cacache/index-v5': 0, + }), + 1001, + ), + ).toEqual({ npm_config_cache: '/home/box/.npm' }); + }); + + it('leaves a cache the box owns alone, and does nothing without a configured cache', () => { + expect( + npmCacheOverride( + { npm_config_cache: '/opt/npm-cache' }, + '/home/box', + owners({ '/opt/npm-cache': 1001, '/opt/npm-cache/_cacache': 1001 }), + 1001, + ), + ).toEqual({}); + expect(npmCacheOverride({}, '/home/box', owners({}), 1001)).toEqual({}); + }); +}); diff --git a/apps/cli/src/services/preview/expo.ts b/apps/cli/src/services/preview/expo.ts index 3dc73832..d1d8dd94 100644 --- a/apps/cli/src/services/preview/expo.ts +++ b/apps/cli/src/services/preview/expo.ts @@ -132,6 +132,43 @@ export function missingExpoWebPackages(cwd: string): string[] { ); } +/** + * `expo` itself, when the project doesn't have it, then the web packages. A project without + * `expo` is a Remix MOBILE project's export (its package.json declared only `expo-*` modules + * until 2026-10-31) or an app a model wrote: `expo start` can't even read the SDK without it + * ("Cannot determine the project's Expo SDK version because the module `expo` is not installed"). + */ +export function missingExpoPackages(cwd: string): string[] { + const core = fs.existsSync(path.join(cwd, 'node_modules', 'expo', 'package.json')) + ? [] + : ['expo']; + return [...core, ...missingExpoWebPackages(cwd)]; +} + +/** Expo SDK by the react-native the project has — what `bundledNativeModules.json` says once `expo` is in. */ +const EXPO_SDK_BY_REACT_NATIVE: Readonly> = { + '0.76': '52', + '0.79': '53', + '0.81': '54', +}; +/** The web packages of the SDKs we ship apps on (SDK 54's `bundledNativeModules.json`). */ +const WEB_PACKAGES_BY_SDK: Readonly>>> = { + '54': { 'react-native-web': '~0.21.0', 'react-dom': '19.1.0', '@expo/metro-runtime': '~6.1.2' }, +}; + +/** The project's Expo SDK major from its installed react-native, or null when unknown. */ +export function expoSdkOf(cwd: string): string | null { + try { + const raw = JSON.parse( + fs.readFileSync(path.join(cwd, 'node_modules', 'react-native', 'package.json'), 'utf8'), + ) as { version?: unknown }; + const m = typeof raw.version === 'string' ? /^(\d+\.\d+)\./.exec(raw.version) : null; + return m ? (EXPO_SDK_BY_REACT_NATIVE[m[1]] ?? null) : null; + } catch { + return null; + } +} + /** * `pkg@version` from the installed SDK's `bundledNativeModules.json` — the versions * `expo install` would pick, so react-dom matches the project's react. A missing or unreadable @@ -143,11 +180,18 @@ export function expoWebInstallSpecs(cwd: string, missing: readonly string[]): st const raw = JSON.parse( fs.readFileSync(path.join(cwd, 'node_modules', 'expo', 'bundledNativeModules.json'), 'utf8'), ) as unknown; - if (raw && typeof raw === 'object' && !Array.isArray(raw)) pinned = raw as Record; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) + pinned = raw as Record; } catch { - /* no SDK pin: bare names */ + /* no `expo` yet: the SDK comes from react-native, below */ } - return missing.map((p) => (typeof pinned[p] === 'string' ? `${p}@${String(pinned[p])}` : p)); + const sdk = expoSdkOf(cwd); + const web = sdk ? (WEB_PACKAGES_BY_SDK[sdk] ?? {}) : {}; + return missing.map((p) => { + if (typeof pinned[p] === 'string') return `${p}@${String(pinned[p])}`; + if (p === 'expo') return sdk ? `expo@~${sdk}.0.0` : 'expo'; + return web[p] ? `${p}@${web[p]}` : p; + }); } /** @@ -155,7 +199,7 @@ export function expoWebInstallSpecs(cwd: string, missing: readonly string[]): st * the project's package.json and lockfile are the user's — the preview never edits them. */ export function expoWebDepsInstall(cwd: string): { cmd: string; args: string[] } | null { - const missing = missingExpoWebPackages(cwd); + const missing = missingExpoPackages(cwd); if (missing.length === 0) return null; return { cmd: 'npm', diff --git a/apps/cli/src/services/preview/run-setup.ts b/apps/cli/src/services/preview/run-setup.ts index 45278dcb..e5cf7957 100644 --- a/apps/cli/src/services/preview/run-setup.ts +++ b/apps/cli/src/services/preview/run-setup.ts @@ -1,4 +1,7 @@ import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { log } from '../logger'; import { killQuiet } from '../../lib/quiet'; @@ -25,6 +28,47 @@ export type SetupRunResult = * forever. Bounding it lets the caller surface a `preview_error` * instead of hanging — we kill the child and report `timeout`. */ +/** + * npm's cache, when the configured one (`npm_config_cache`, the Box image's /opt/npm-cache) holds + * entries another user owns: a cache of our own under HOME. A root-owned entry makes npm fail + * EVERY install with EACCES, and a `--cap-drop ALL` container can't chown it away (the 2026-10-31 + * v2.76.41 Box image: its Expo bake ran npm as root into the box's cache). `{}` = nothing to change. + */ +export function npmCacheOverride( + env: NodeJS.ProcessEnv = process.env, + home: string = os.homedir(), + ownerUid: (p: string) => number | null = (p) => { + try { + return fs.statSync(p).uid; + } catch { + return null; + } + }, + uid: number | undefined = process.getuid?.(), +): Record { + const cache = env.npm_config_cache; + if (!cache || uid === undefined) return {}; + const foreign = (p: string): boolean => { + const owner = ownerUid(p); + return owner !== null && owner !== uid; + }; + const roots = [cache, path.join(cache, '_cacache'), path.join(cache, '_logs')]; + const indexes = [ + path.join(cache, '_cacache', 'index-v5'), + path.join(cache, '_cacache', 'content-v2', 'sha512'), + ]; + const entries = indexes.flatMap((d) => { + try { + return fs.readdirSync(d).map((e) => path.join(d, e)); + } catch { + return []; + } + }); + return [...roots, ...indexes, ...entries].some(foreign) + ? { npm_config_cache: path.join(home, '.npm') } + : {}; +} + export function runSetupCommand( cmd: string, args: string[], @@ -35,7 +79,7 @@ export function runSetupCommand( return new Promise((resolve) => { const child = spawn(cmd, args, { cwd, - env: { ...process.env, ...(env ?? {}) }, + env: { ...process.env, ...npmCacheOverride(), ...(env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/apps/cli/src/services/preview/start-orchestrator.ts b/apps/cli/src/services/preview/start-orchestrator.ts index f6d3e6eb..38a133f0 100644 --- a/apps/cli/src/services/preview/start-orchestrator.ts +++ b/apps/cli/src/services/preview/start-orchestrator.ts @@ -398,9 +398,15 @@ async function runExpoBringUp(ctx: StageCtx): Promise { let web = webInstall === null; if (webInstall) { emitProgress('SETUP_RUN', `${webInstall.cmd} ${webInstall.args.join(' ')} (Expo web preview)`); - const r = await previewSvc.runSetupCommand(webInstall.cmd, webInstall.args, ctx.cwd, detection.env, { - timeoutMs: INSTALL_TIMEOUT_MS, - }); + const r = await previewSvc.runSetupCommand( + webInstall.cmd, + webInstall.args, + ctx.cwd, + detection.env, + { + timeoutMs: INSTALL_TIMEOUT_MS, + }, + ); web = r.status === 'ok'; if (!web) emitProgress('SETUP_RUN', `web packages not installed (${r.status}) — Expo Go only`); } @@ -833,7 +839,13 @@ async function startDevServer( // never shows its "Serving HTTP on … (http://0.0.0.0:8000/)" line while it // already serves — every such preview ended in a 120 s ready_timeout // (codeagent-nczt.9). First, so the project's own env can still override it. - env: { PYTHONUNBUFFERED: '1', ...process.env, ...(spawnable.env ?? {}), ...extraEnv }, + env: { + PYTHONUNBUFFERED: '1', + ...process.env, + ...previewSvc.npmCacheOverride(), + ...(spawnable.env ?? {}), + ...extraEnv, + }, stdio: ['ignore', 'pipe', 'pipe'], // POSIX: lead a new process group so teardown can SIGTERM the whole // tree. Dev servers fork worker children that bind the port; killing