Skip to content

Commit f2942dd

Browse files
siwachabhiAbhimanyu Siwach
andauthored
feat: add npm cache ownership preflight check (#462)
Detect root-owned files in ~/.npm before running npm install. A previous `sudo npm install` leaves root-owned cache files that cause EACCES permission errors on subsequent installs. The check runs in both the create and deploy preflight paths, and gives users the exact fix command (sudo chown -R $(whoami) ~/.npm). Skipped on Windows. Also fixes rollup high severity vulnerability (GHSA-mw96-cpmx-2vgc). Co-authored-by: Abhimanyu Siwach <siwabhi@amazon.com>
1 parent 3cf1342 commit f2942dd

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

src/cli/external-requirements/__tests__/checks-extended.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../sch
22
import {
33
checkDependencyVersions,
44
checkNodeVersion,
5+
checkNpmCacheOwnership,
6+
formatNpmCacheError,
57
formatVersionError,
68
requiresContainerRuntime,
79
requiresUv,
@@ -174,6 +176,44 @@ describe('checkNodeVersion', () => {
174176
});
175177
});
176178

179+
describe('checkNpmCacheOwnership', () => {
180+
it('returns a result with expected structure', async () => {
181+
const result = await checkNpmCacheOwnership();
182+
expect(result).toHaveProperty('satisfied');
183+
expect(result).toHaveProperty('owner');
184+
expect(result).toHaveProperty('cacheDir');
185+
expect(result.cacheDir).toContain('.npm');
186+
});
187+
188+
it('is satisfied when cache is owned by current user', async () => {
189+
// In a normal test environment, ~/.npm should be owned by the current user
190+
const result = await checkNpmCacheOwnership();
191+
expect(result.satisfied).toBe(true);
192+
});
193+
});
194+
195+
describe('formatNpmCacheError', () => {
196+
it('includes cache directory path', () => {
197+
const msg = formatNpmCacheError({ satisfied: false, owner: 'root', cacheDir: '/home/user/.npm' });
198+
expect(msg).toContain('/home/user/.npm');
199+
});
200+
201+
it('includes the wrong owner name', () => {
202+
const msg = formatNpmCacheError({ satisfied: false, owner: 'root', cacheDir: '/home/user/.npm' });
203+
expect(msg).toContain('root');
204+
});
205+
206+
it('includes the fix command', () => {
207+
const msg = formatNpmCacheError({ satisfied: false, owner: 'root', cacheDir: '/home/user/.npm' });
208+
expect(msg).toContain('sudo chown -R $(whoami)');
209+
});
210+
211+
it('mentions sudo npm install as likely cause', () => {
212+
const msg = formatNpmCacheError({ satisfied: false, owner: 'root', cacheDir: '/home/user/.npm' });
213+
expect(msg).toContain('sudo npm install');
214+
});
215+
});
216+
177217
describe('checkDependencyVersions', () => {
178218
it('passes when node meets requirements and no uv needed', async () => {
179219
const project: AgentCoreProjectSpec = {
@@ -190,6 +230,20 @@ describe('checkDependencyVersions', () => {
190230
expect(result.uvCheck).toBeNull();
191231
});
192232

233+
it('includes npmCacheCheck in result', async () => {
234+
const project: AgentCoreProjectSpec = {
235+
name: 'Test',
236+
version: 1,
237+
agents: [],
238+
memories: [],
239+
credentials: [],
240+
};
241+
242+
const result = await checkDependencyVersions(project);
243+
expect(result.npmCacheCheck).toBeDefined();
244+
expect(result.npmCacheCheck.cacheDir).toContain('.npm');
245+
});
246+
193247
it('checks uv when project has CodeZip agents', async () => {
194248
const project: AgentCoreProjectSpec = {
195249
name: 'Test',

src/cli/external-requirements/checks.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { checkSubprocess, isWindows, runSubprocessCapture } from '../../lib';
55
import type { AgentCoreProjectSpec, TargetLanguage } from '../../schema';
66
import { detectContainerRuntime } from './detect';
77
import { AWS_CLI_MIN_VERSION, NODE_MIN_VERSION, formatSemVer, parseSemVer, semVerGte } from './versions';
8+
import { stat } from 'node:fs/promises';
9+
import { homedir } from 'node:os';
10+
import { join } from 'node:path';
811

912
/**
1013
* Result of a version check.
@@ -150,6 +153,66 @@ export function formatVersionError(result: VersionCheckResult): string {
150153
return `${result.binary} ${result.current} is below minimum required version ${result.required}`;
151154
}
152155

156+
/**
157+
* Result of checking npm cache directory ownership.
158+
*/
159+
export interface NpmCacheCheckResult {
160+
/** true when the cache dir doesn't exist or is owned by the current user. */
161+
satisfied: boolean;
162+
/** Owner of ~/.npm, or null if the directory doesn't exist. */
163+
owner: string | null;
164+
/** The cache directory path that was checked. */
165+
cacheDir: string;
166+
}
167+
168+
/**
169+
* Check that the npm cache directory (~/.npm) is owned by the current user.
170+
*
171+
* A previous `sudo npm install` can leave root-owned files in the cache,
172+
* causing EACCES errors on subsequent `npm install` runs.
173+
* Skipped on Windows where file ownership semantics differ.
174+
*/
175+
export async function checkNpmCacheOwnership(): Promise<NpmCacheCheckResult> {
176+
const cacheDir = join(homedir(), '.npm');
177+
178+
// Skip on Windows - file ownership model is different
179+
if (isWindows) {
180+
return { satisfied: true, owner: null, cacheDir };
181+
}
182+
183+
try {
184+
const stats = await stat(cacheDir);
185+
const currentUid = process.getuid?.();
186+
if (currentUid === undefined) {
187+
// getuid not available (e.g. some non-POSIX runtimes) — skip check
188+
return { satisfied: true, owner: null, cacheDir };
189+
}
190+
191+
if (stats.uid !== currentUid) {
192+
// Resolve owner name for the error message
193+
const ownerResult = await runSubprocessCapture('id', ['-un', String(stats.uid)]);
194+
const owner = ownerResult.code === 0 ? ownerResult.stdout.trim() : `uid=${stats.uid}`;
195+
return { satisfied: false, owner, cacheDir };
196+
}
197+
198+
return { satisfied: true, owner: null, cacheDir };
199+
} catch {
200+
// Directory doesn't exist yet — not a problem
201+
return { satisfied: true, owner: null, cacheDir };
202+
}
203+
}
204+
205+
/**
206+
* Format an npm cache ownership failure as a user-friendly error message.
207+
*/
208+
export function formatNpmCacheError(result: NpmCacheCheckResult): string {
209+
return (
210+
`npm cache directory (${result.cacheDir}) is owned by '${result.owner}' instead of the current user. ` +
211+
`This was likely caused by a previous 'sudo npm install'. ` +
212+
`Fix: sudo chown -R $(whoami) ${result.cacheDir}`
213+
);
214+
}
215+
153216
/**
154217
* Check if the project has any Python CodeZip agents that require uv.
155218
*/
@@ -171,6 +234,7 @@ export interface DependencyCheckResult {
171234
passed: boolean;
172235
nodeCheck: VersionCheckResult;
173236
uvCheck: VersionCheckResult | null;
237+
npmCacheCheck: NpmCacheCheckResult;
174238
containerRuntimeAvailable: boolean;
175239
errors: string[];
176240
}
@@ -198,6 +262,12 @@ export async function checkDependencyVersions(projectSpec: AgentCoreProjectSpec)
198262
}
199263
}
200264

265+
// Check npm cache ownership (root-owned cache causes EACCES on npm install)
266+
const npmCacheCheck = await checkNpmCacheOwnership();
267+
if (!npmCacheCheck.satisfied) {
268+
errors.push(formatNpmCacheError(npmCacheCheck));
269+
}
270+
201271
// Check container runtime only if there are Container agents (warn only, not error)
202272
let containerRuntimeAvailable = true;
203273
if (requiresContainerRuntime(projectSpec)) {
@@ -213,6 +283,7 @@ export async function checkDependencyVersions(projectSpec: AgentCoreProjectSpec)
213283
passed: errors.length === 0,
214284
nodeCheck,
215285
uvCheck,
286+
npmCacheCheck,
216287
containerRuntimeAvailable,
217288
errors,
218289
};
@@ -291,6 +362,14 @@ export async function checkCreateDependencies(
291362
errors.push("'npm' is required. Install Node.js from https://nodejs.org/");
292363
}
293364

365+
// Check npm cache ownership (root-owned cache causes EACCES on npm install)
366+
if (npmAvailable) {
367+
const npmCacheCheck = await checkNpmCacheOwnership();
368+
if (!npmCacheCheck.satisfied) {
369+
errors.push(formatNpmCacheError(npmCacheCheck));
370+
}
371+
}
372+
294373
// Check aws (warn if missing)
295374
const awsAvailable = await checkBinaryAvailable('aws');
296375
checks.push({

src/cli/external-requirements/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ export {
1212
checkNodeVersion,
1313
checkUvVersion,
1414
checkAwsCliVersion,
15+
checkNpmCacheOwnership,
1516
getAwsLoginGuidance,
1617
formatVersionError,
18+
formatNpmCacheError,
1719
requiresUv,
1820
requiresContainerRuntime,
1921
checkDependencyVersions,
2022
checkCreateDependencies,
2123
type VersionCheckResult,
24+
type NpmCacheCheckResult,
2225
type DependencyCheckResult,
2326
type CheckSeverity,
2427
type CliToolCheck,

0 commit comments

Comments
 (0)