@@ -29,6 +29,11 @@ type ManagedNpmRootOpenClawMetadata = {
2929 [ key : string ] : unknown ;
3030} ;
3131
32+ export type ManagedNpmRootPeerDependencySnapshot = {
33+ dependencies : Record < string , string > ;
34+ managedPeerDependencies : string [ ] ;
35+ } ;
36+
3237export type ManagedNpmRootInstalledDependency = {
3338 version ?: string ;
3439 integrity ?: string ;
@@ -47,6 +52,16 @@ type ManagedNpmRootLogger = {
4752
4853type ManagedNpmRootRunCommand = typeof runCommandWithTimeout ;
4954
55+ type ManagedNpmPeerTraversalLimits = {
56+ maxDepth : number ;
57+ maxDirectories : number ;
58+ } ;
59+
60+ const DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS : ManagedNpmPeerTraversalLimits = {
61+ maxDepth : 64 ,
62+ maxDirectories : 10_000 ,
63+ } ;
64+
5065function isRecord ( value : unknown ) : value is Record < string , unknown > {
5166 return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
5267}
@@ -68,6 +83,47 @@ function readDependencyRecord(value: unknown): Record<string, string> {
6883 return dependencies ;
6984}
7085
86+ function readPositiveIntegerEnv ( name : string , fallback : number ) : number {
87+ const rawValue = process . env [ name ] ;
88+ if ( ! rawValue ) {
89+ return fallback ;
90+ }
91+ const parsedValue = Number . parseInt ( rawValue , 10 ) ;
92+ return Number . isFinite ( parsedValue ) && parsedValue >= 1 ? parsedValue : fallback ;
93+ }
94+
95+ function resolveManagedNpmPeerTraversalLimits ( ) : ManagedNpmPeerTraversalLimits {
96+ return {
97+ maxDepth : readPositiveIntegerEnv (
98+ "OPENCLAW_INSTALL_SCAN_MAX_DEPTH" ,
99+ DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS . maxDepth ,
100+ ) ,
101+ maxDirectories : readPositiveIntegerEnv (
102+ "OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES" ,
103+ DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS . maxDirectories ,
104+ ) ,
105+ } ;
106+ }
107+
108+ function isSamePathOrInside ( parentPath : string , candidatePath : string ) : boolean {
109+ const relative = path . relative ( parentPath , candidatePath ) ;
110+ return (
111+ relative === "" || ( ! ! relative && ! relative . startsWith ( ".." ) && ! path . isAbsolute ( relative ) )
112+ ) ;
113+ }
114+
115+ function isSafePackageName ( name : string ) : boolean {
116+ if ( name . startsWith ( "@" ) ) {
117+ const parts = name . split ( "/" ) ;
118+ return (
119+ parts . length === 2 && parts . every ( ( part ) => part . length > 0 && part !== "." && part !== ".." )
120+ ) ;
121+ }
122+ return (
123+ name . length > 0 && ! name . includes ( "/" ) && ! name . includes ( "\\" ) && name !== "." && name !== ".."
124+ ) ;
125+ }
126+
71127function readOverrideRecord ( value : unknown ) : Record < string , unknown > {
72128 if ( ! isRecord ( value ) ) {
73129 return { } ;
@@ -301,7 +357,7 @@ async function listNodeModulesPackageDirs(nodeModulesDir: string): Promise<strin
301357 }
302358 const packageDirs : string [ ] = [ ] ;
303359 for ( const entry of entries . toSorted ( ( left , right ) => left . name . localeCompare ( right . name ) ) ) {
304- if ( entry . name === ".bin" || entry . name . startsWith ( "." ) ) {
360+ if ( entry . name === ".bin" || entry . name === "openclaw" || entry . name . startsWith ( "." ) ) {
305361 continue ;
306362 }
307363 const entryPath = path . join ( nodeModulesDir , entry . name ) ;
@@ -338,20 +394,50 @@ async function collectManagedNpmRootPeerDependencyPins(params: {
338394 npmRoot : string ;
339395} ) : Promise < Record < string , string > > {
340396 const pins = new Map < string , string > ( ) ;
341- const queue = await listNodeModulesPackageDirs ( path . join ( params . npmRoot , "node_modules" ) ) ;
397+ const limits = resolveManagedNpmPeerTraversalLimits ( ) ;
398+ const boundaryRealPath = await fs
399+ . realpath ( params . npmRoot )
400+ . catch ( ( ) => path . resolve ( params . npmRoot ) ) ;
401+ const queue = ( await listNodeModulesPackageDirs ( path . join ( params . npmRoot , "node_modules" ) ) ) . map (
402+ ( packageDir ) => ( { depth : 0 , packageDir } ) ,
403+ ) ;
404+ const visitedRealPaths = new Set < string > ( ) ;
342405 for ( let index = 0 ; index < queue . length ; index += 1 ) {
343- const packageDir = queue [ index ] ;
344- if ( ! packageDir ) {
406+ const current = queue [ index ] ;
407+ if ( ! current ) {
408+ continue ;
409+ }
410+ if ( current . depth > limits . maxDepth ) {
411+ throw new Error (
412+ `managed npm peer dependency scan exceeded max depth (${ limits . maxDepth } ) at ${ current . packageDir } ` ,
413+ ) ;
414+ }
415+ const packageDirRealPath = await fs
416+ . realpath ( current . packageDir )
417+ . catch ( ( ) => path . resolve ( current . packageDir ) ) ;
418+ if ( ! isSamePathOrInside ( boundaryRealPath , packageDirRealPath ) ) {
419+ throw new Error (
420+ `managed npm peer dependency scan found package outside managed npm root at ${ current . packageDir } ` ,
421+ ) ;
422+ }
423+ if ( visitedRealPaths . has ( packageDirRealPath ) ) {
345424 continue ;
346425 }
426+ visitedRealPaths . add ( packageDirRealPath ) ;
427+ if ( visitedRealPaths . size > limits . maxDirectories ) {
428+ throw new Error (
429+ `managed npm peer dependency scan exceeded max packages (${ limits . maxDirectories } ) under ${ params . npmRoot } ` ,
430+ ) ;
431+ }
432+ const packageDir = current . packageDir ;
347433 const manifest = await readPackageJsonIfExists ( packageDir ) ;
348434 if ( manifest ) {
349435 if ( readOptionalString ( manifest . name ) === "openclaw" ) {
350436 continue ;
351437 }
352438 const peerDependencies = readDependencyRecord ( manifest . peerDependencies ) ;
353439 for ( const [ peerName , peerRange ] of Object . entries ( peerDependencies ) ) {
354- if ( peerName === "openclaw" || pins . has ( peerName ) ) {
440+ if ( peerName === "openclaw" || pins . has ( peerName ) || ! isSafePackageName ( peerName ) ) {
355441 continue ;
356442 }
357443 const installedVersion = await readPackageVersion (
@@ -363,13 +449,69 @@ async function collectManagedNpmRootPeerDependencyPins(params: {
363449 pins . set ( peerName , installedVersion ?? peerRange ) ;
364450 }
365451 }
366- queue . push ( ...( await listNodeModulesPackageDirs ( path . join ( packageDir , "node_modules" ) ) ) ) ;
452+ queue . push (
453+ ...( await listNodeModulesPackageDirs ( path . join ( packageDir , "node_modules" ) ) ) . map (
454+ ( nestedPackageDir ) => ( {
455+ depth : current . depth + 1 ,
456+ packageDir : nestedPackageDir ,
457+ } ) ,
458+ ) ,
459+ ) ;
367460 }
368461 return Object . fromEntries (
369462 [ ...pins . entries ( ) ] . toSorted ( ( [ left ] , [ right ] ) => left . localeCompare ( right ) ) ,
370463 ) ;
371464}
372465
466+ export async function readManagedNpmRootPeerDependencySnapshot ( params : {
467+ npmRoot : string ;
468+ } ) : Promise < ManagedNpmRootPeerDependencySnapshot > {
469+ const manifest = await readManagedNpmRootManifest ( path . join ( params . npmRoot , "package.json" ) ) ;
470+ const dependencies = readDependencyRecord ( manifest . dependencies ) ;
471+ const managedPeerDependencies = readManagedPeerDependencyKeys ( manifest . openclaw ) . toSorted ( ) ;
472+ const dependencySnapshot : Record < string , string > = { } ;
473+ for ( const packageName of managedPeerDependencies ) {
474+ const dependencySpec = dependencies [ packageName ] ;
475+ if ( dependencySpec ) {
476+ dependencySnapshot [ packageName ] = dependencySpec ;
477+ }
478+ }
479+ return {
480+ dependencies : dependencySnapshot ,
481+ managedPeerDependencies,
482+ } ;
483+ }
484+
485+ export async function restoreManagedNpmRootPeerDependencySnapshot ( params : {
486+ npmRoot : string ;
487+ snapshot : ManagedNpmRootPeerDependencySnapshot ;
488+ } ) : Promise < void > {
489+ const manifestPath = path . join ( params . npmRoot , "package.json" ) ;
490+ const manifest = await readManagedNpmRootManifest ( manifestPath ) ;
491+ const dependencies = readDependencyRecord ( manifest . dependencies ) ;
492+ for ( const packageName of readManagedPeerDependencyKeys ( manifest . openclaw ) ) {
493+ delete dependencies [ packageName ] ;
494+ }
495+ Object . assign ( dependencies , params . snapshot . dependencies ) ;
496+ const managedOverrideKeys = readManagedOverrideKeys ( manifest . openclaw ) . toSorted ( ) ;
497+ const openclawMetadata = buildManagedOpenClawMetadata ( {
498+ current : manifest . openclaw ,
499+ managedOverrideKeys,
500+ managedPeerDependencyKeys : params . snapshot . managedPeerDependencies . toSorted ( ) ,
501+ } ) ;
502+ const next : ManagedNpmRootManifest = {
503+ ...manifest ,
504+ private : true ,
505+ dependencies,
506+ } ;
507+ if ( openclawMetadata ) {
508+ next . openclaw = openclawMetadata ;
509+ } else {
510+ delete next . openclaw ;
511+ }
512+ await writeJson ( manifestPath , next , { trailingNewline : true } ) ;
513+ }
514+
373515export async function syncManagedNpmRootPeerDependencies ( params : {
374516 npmRoot : string ;
375517 managedOverrides ?: Record < string , unknown > ;
0 commit comments