Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/box/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
60 changes: 55 additions & 5 deletions apps/cli/__tests__/preview-expo-web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
function project(installed: string[], pins?: Record<string, string>, 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: [
Expand All @@ -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',
'[email protected]',
'@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();
Expand Down
33 changes: 33 additions & 0 deletions apps/cli/__tests__/preview-npm-cache-override.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>) => (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({});
});
});
52 changes: 48 additions & 4 deletions apps/cli/src/services/preview/expo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
'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<Record<string, Readonly<Record<string, string>>>> = {
'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
Expand All @@ -143,19 +180,26 @@ 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<string, unknown>;
if (raw && typeof raw === 'object' && !Array.isArray(raw))
pinned = raw as Record<string, unknown>;
} 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;
});
}

/**
* The install that turns the web preview on, or `null` when nothing is missing. `--no-save`:
* 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',
Expand Down
46 changes: 45 additions & 1 deletion apps/cli/src/services/preview/run-setup.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, string> {
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[],
Expand All @@ -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'],
});

Expand Down
20 changes: 16 additions & 4 deletions apps/cli/src/services/preview/start-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,15 @@ async function runExpoBringUp(ctx: StageCtx): Promise<void> {
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`);
}
Expand Down Expand Up @@ -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
Expand Down
Loading