@@ -54,6 +54,17 @@ function successfulSpawn(stdout = "") {
5454 } ;
5555}
5656
57+ function failedSpawn ( stderr : string , stdout = "" ) {
58+ return {
59+ code : 1 ,
60+ stdout,
61+ stderr,
62+ signal : null ,
63+ killed : false ,
64+ termination : "exit" as const ,
65+ } ;
66+ }
67+
5768function npmViewArgv ( spec : string ) : string [ ] {
5869 return [
5970 "npm" ,
@@ -941,6 +952,198 @@ describe("installPluginFromNpmSpec", () => {
941952 expect ( fs . existsSync ( resolveTestPluginPackageDir ( npmRoot , "missing-lock-plugin" ) ) ) . toBe ( false ) ;
942953 } ) ;
943954
955+ it ( "quarantines and rebuilds a corrupt managed npm project after npm from-argument failures" , async ( ) => {
956+ const stateDir = suiteTempRootTracker . makeTempDir ( ) ;
957+ const npmRoot = path . join ( stateDir , "npm" ) ;
958+ const packageName = "@openclaw/voice-call" ;
959+ const warnings : string [ ] = [ ] ;
960+ const npmProjectRoot = resolvePluginNpmProjectDir ( { npmDir : npmRoot , packageName } ) ;
961+ const stalePackageDir = path . join ( npmProjectRoot , "node_modules" , "stale-plugin" ) ;
962+ fs . mkdirSync ( stalePackageDir , { recursive : true } ) ;
963+ fs . writeFileSync ( path . join ( stalePackageDir , "stale.txt" ) , "old tree" , "utf8" ) ;
964+ fs . writeFileSync (
965+ path . join ( npmProjectRoot , "package-lock.json" ) ,
966+ `${ JSON . stringify ( { lockfileVersion : 3 , packages : { } } ) } \n` ,
967+ "utf8" ,
968+ ) ;
969+
970+ mockNpmViewAndInstall ( {
971+ spec : `${ packageName } @1.0.0` ,
972+ packageName,
973+ version : "1.0.0" ,
974+ pluginId : "voice-call" ,
975+ npmRoot,
976+ expectedDependencySpec : "1.0.0" ,
977+ } ) ;
978+ const delegate = runCommandWithTimeoutMock . getMockImplementation ( ) ;
979+ if ( ! delegate ) {
980+ throw new Error ( "expected npm mock implementation" ) ;
981+ }
982+ let managedInstallAttempts = 0 ;
983+ runCommandWithTimeoutMock . mockImplementation (
984+ async ( argv : string [ ] , options ?: { cwd ?: string } ) => {
985+ if ( isManagedNpmInstallCommand ( argv ) && options ?. cwd === npmProjectRoot ) {
986+ managedInstallAttempts += 1 ;
987+ if ( managedInstallAttempts === 1 ) {
988+ return failedSpawn (
989+ 'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined' ,
990+ ) ;
991+ }
992+ }
993+ return await delegate ( argv , options ) ;
994+ } ,
995+ ) ;
996+
997+ const result = await installPluginFromNpmSpec ( {
998+ spec : `${ packageName } @1.0.0` ,
999+ npmDir : npmRoot ,
1000+ logger : { info : ( ) => { } , warn : ( message ) => warnings . push ( message ) } ,
1001+ } ) ;
1002+
1003+ expect ( result . ok ) . toBe ( true ) ;
1004+ if ( ! result . ok ) {
1005+ return ;
1006+ }
1007+ expect ( managedInstallAttempts ) . toBe ( 2 ) ;
1008+ expect ( result . pluginId ) . toBe ( "voice-call" ) ;
1009+ expect ( fs . existsSync ( resolveTestPluginPackageDir ( npmRoot , packageName ) ) ) . toBe ( true ) ;
1010+ expect ( warnings . some ( ( warning ) => warning . includes ( "managed npm project corruption" ) ) ) . toBe (
1011+ true ,
1012+ ) ;
1013+ const quarantineParent = path . join ( npmProjectRoot , "_openclaw-quarantined-npm-projects" ) ;
1014+ const quarantines = fs . readdirSync ( quarantineParent ) ;
1015+ expect ( quarantines ) . toHaveLength ( 1 ) ;
1016+ const quarantineDir = path . join ( quarantineParent , quarantines [ 0 ] ?? "" ) ;
1017+ expect (
1018+ fs . readFileSync (
1019+ path . join ( quarantineDir , "node_modules" , "stale-plugin" , "stale.txt" ) ,
1020+ "utf8" ,
1021+ ) ,
1022+ ) . toBe ( "old tree" ) ;
1023+ expect ( fs . existsSync ( path . join ( quarantineDir , "package-lock.json" ) ) ) . toBe ( true ) ;
1024+ } ) ;
1025+
1026+ it ( "scans rebuilt hoisted dependencies after managed npm project quarantine" , async ( ) => {
1027+ const stateDir = suiteTempRootTracker . makeTempDir ( ) ;
1028+ const npmRoot = path . join ( stateDir , "npm" ) ;
1029+ const packageName = "unsafe-rebuild-plugin" ;
1030+ const npmProjectRoot = resolvePluginNpmProjectDir ( { npmDir : npmRoot , packageName } ) ;
1031+ fs . mkdirSync ( path . join ( npmProjectRoot , "node_modules" , "plain-crypto-js" ) , {
1032+ recursive : true ,
1033+ } ) ;
1034+
1035+ mockNpmViewAndInstall ( {
1036+ spec : `${ packageName } @1.0.0` ,
1037+ packageName,
1038+ version : "1.0.0" ,
1039+ pluginId : packageName ,
1040+ npmRoot,
1041+ expectedDependencySpec : "1.0.0" ,
1042+ hoistedDependency : { name : "plain-crypto-js" , version : "1.0.0" } ,
1043+ } ) ;
1044+ const delegate = runCommandWithTimeoutMock . getMockImplementation ( ) ;
1045+ if ( ! delegate ) {
1046+ throw new Error ( "expected npm mock implementation" ) ;
1047+ }
1048+ let managedInstallAttempts = 0 ;
1049+ runCommandWithTimeoutMock . mockImplementation (
1050+ async ( argv : string [ ] , options ?: { cwd ?: string } ) => {
1051+ if ( isManagedNpmInstallCommand ( argv ) && options ?. cwd === npmProjectRoot ) {
1052+ managedInstallAttempts += 1 ;
1053+ if ( managedInstallAttempts === 1 ) {
1054+ return failedSpawn (
1055+ 'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined' ,
1056+ ) ;
1057+ }
1058+ }
1059+ return await delegate ( argv , options ) ;
1060+ } ,
1061+ ) ;
1062+
1063+ const result = await installPluginFromNpmSpec ( {
1064+ spec : `${ packageName } @1.0.0` ,
1065+ npmDir : npmRoot ,
1066+ logger : { info : ( ) => { } , warn : ( ) => { } } ,
1067+ } ) ;
1068+
1069+ expect ( result . ok ) . toBe ( false ) ;
1070+ if ( ! result . ok ) {
1071+ expect ( result . error ) . toContain ( "plain-crypto-js" ) ;
1072+ }
1073+ expect ( managedInstallAttempts ) . toBe ( 2 ) ;
1074+ } ) ;
1075+
1076+ it ( "keeps corrupt managed npm project artifacts quarantined when the rebuild retry fails" , async ( ) => {
1077+ const stateDir = suiteTempRootTracker . makeTempDir ( ) ;
1078+ const npmRoot = path . join ( stateDir , "npm" ) ;
1079+ const packageName = "broken-plugin" ;
1080+ const npmProjectRoot = resolvePluginNpmProjectDir ( { npmDir : npmRoot , packageName } ) ;
1081+ fs . mkdirSync ( path . join ( npmProjectRoot , "node_modules" , "stale-plugin" ) , { recursive : true } ) ;
1082+ fs . writeFileSync (
1083+ path . join ( npmProjectRoot , "node_modules" , "stale-plugin" , "stale.txt" ) ,
1084+ "old tree" ,
1085+ "utf8" ,
1086+ ) ;
1087+
1088+ mockNpmViewAndInstall ( {
1089+ spec : `${ packageName } @1.0.0` ,
1090+ packageName,
1091+ version : "1.0.0" ,
1092+ pluginId : packageName ,
1093+ npmRoot,
1094+ expectedDependencySpec : "1.0.0" ,
1095+ } ) ;
1096+ const delegate = runCommandWithTimeoutMock . getMockImplementation ( ) ;
1097+ if ( ! delegate ) {
1098+ throw new Error ( "expected npm mock implementation" ) ;
1099+ }
1100+ let managedInstallAttempts = 0 ;
1101+ runCommandWithTimeoutMock . mockImplementation (
1102+ async ( argv : string [ ] , options ?: { cwd ?: string } ) => {
1103+ if ( isManagedNpmInstallCommand ( argv ) && options ?. cwd === npmProjectRoot ) {
1104+ managedInstallAttempts += 1 ;
1105+ if ( managedInstallAttempts === 1 ) {
1106+ return failedSpawn (
1107+ 'npm ERR! code ERR_INVALID_ARG_TYPE\nnpm ERR! The "from" argument must be of type string. Received undefined' ,
1108+ ) ;
1109+ }
1110+ return failedSpawn ( "npm ERR! still broken" ) ;
1111+ }
1112+ return await delegate ( argv , options ) ;
1113+ } ,
1114+ ) ;
1115+
1116+ const result = await installPluginFromNpmSpec ( {
1117+ spec : `${ packageName } @1.0.0` ,
1118+ npmDir : npmRoot ,
1119+ logger : { info : ( ) => { } , warn : ( ) => { } } ,
1120+ } ) ;
1121+
1122+ expect ( result . ok ) . toBe ( false ) ;
1123+ if ( result . ok ) {
1124+ return ;
1125+ }
1126+ expect ( managedInstallAttempts ) . toBeGreaterThanOrEqual ( 2 ) ;
1127+ expect ( result . error ) . toContain ( "npm install failed after managed npm project recovery" ) ;
1128+ expect ( result . error ) . toContain ( "Original npm error" ) ;
1129+ const quarantineParent = path . join ( npmProjectRoot , "_openclaw-quarantined-npm-projects" ) ;
1130+ const quarantines = fs . readdirSync ( quarantineParent ) ;
1131+ expect ( quarantines ) . toHaveLength ( 1 ) ;
1132+ expect (
1133+ fs . readFileSync (
1134+ path . join (
1135+ quarantineParent ,
1136+ quarantines [ 0 ] ?? "" ,
1137+ "node_modules" ,
1138+ "stale-plugin" ,
1139+ "stale.txt" ,
1140+ ) ,
1141+ "utf8" ,
1142+ ) ,
1143+ ) . toBe ( "old tree" ) ;
1144+ expect ( fs . existsSync ( resolveTestPluginPackageDir ( npmRoot , packageName ) ) ) . toBe ( false ) ;
1145+ } ) ;
1146+
9441147 it ( "rejects npm installs with blocked hoisted transitive dependencies" , async ( ) => {
9451148 const stateDir = suiteTempRootTracker . makeTempDir ( ) ;
9461149 const npmRoot = path . join ( stateDir , "npm" ) ;
0 commit comments