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