@@ -11,6 +11,7 @@ import type {
1111 ConfigWriteNotification ,
1212 OpenClawConfig ,
1313} from "../config/config.js" ;
14+ import type { PluginInstallRecord } from "../config/types.plugins.js" ;
1415import {
1516 pinActivePluginChannelRegistry ,
1617 resetPluginRuntimeStateForTest ,
@@ -579,6 +580,8 @@ function createReloaderHarness(
579580 initialInternalWriteHash ?: string | null ;
580581 recoverSnapshot ?: ( snapshot : ConfigFileSnapshot , reason : string ) => Promise < boolean > ;
581582 promoteSnapshot ?: ( snapshot : ConfigFileSnapshot , reason : string ) => Promise < boolean > ;
583+ initialPluginInstallRecords ?: Record < string , PluginInstallRecord > ;
584+ readPluginInstallRecords ?: ( ) => Promise < Record < string , PluginInstallRecord > > ;
582585 onRecovered ?: ( params : {
583586 reason : string ;
584587 snapshot : ConfigFileSnapshot ;
@@ -611,6 +614,8 @@ function createReloaderHarness(
611614 readSnapshot,
612615 recoverSnapshot : options . recoverSnapshot ,
613616 promoteSnapshot : options . promoteSnapshot ,
617+ initialPluginInstallRecords : options . initialPluginInstallRecords ?? { } ,
618+ readPluginInstallRecords : options . readPluginInstallRecords ?? ( async ( ) => ( { } ) ) ,
614619 onRecovered : options . onRecovered ,
615620 subscribeToWrites,
616621 onHotReload,
@@ -1227,6 +1232,167 @@ describe("startGatewayConfigReloader", () => {
12271232 await harness . reloader . stop ( ) ;
12281233 } ) ;
12291234
1235+ it ( "queues restart when an external plugin source write only changes the managed index" , async ( ) => {
1236+ const activeConfig : OpenClawConfig = {
1237+ gateway : { reload : { debounceMs : 0 } } ,
1238+ plugins : {
1239+ allow : [ "lossless-claw" ] ,
1240+ entries : {
1241+ "lossless-claw" : { enabled : true } ,
1242+ } ,
1243+ } ,
1244+ } ;
1245+ const readSnapshot = vi . fn < ( ) => Promise < ConfigFileSnapshot > > ( ) . mockResolvedValueOnce (
1246+ makeSnapshot ( {
1247+ sourceConfig : activeConfig ,
1248+ runtimeConfig : activeConfig ,
1249+ config : activeConfig ,
1250+ hash : "external-plugin-index-1" ,
1251+ } ) ,
1252+ ) ;
1253+ const readPluginInstallRecords = vi . fn ( ) . mockResolvedValueOnce ( {
1254+ "lossless-claw" : {
1255+ source : "npm" ,
1256+ spec : "@martian-engineering/lossless-claw" ,
1257+ installPath : "/tmp/openclaw/plugins/lossless-claw" ,
1258+ installedAt : "2026-04-22T00:00:00.000Z" ,
1259+ } ,
1260+ } satisfies Record < string , PluginInstallRecord > ) ;
1261+ const harness = createReloaderHarness ( readSnapshot , {
1262+ initialCompareConfig : activeConfig ,
1263+ initialPluginInstallRecords : { } ,
1264+ readPluginInstallRecords,
1265+ } ) ;
1266+
1267+ harness . watcher . emit ( "change" ) ;
1268+ await vi . runOnlyPendingTimersAsync ( ) ;
1269+
1270+ expect ( harness . onHotReload ) . not . toHaveBeenCalled ( ) ;
1271+ expect ( harness . onRestart ) . toHaveBeenCalledTimes ( 1 ) ;
1272+ expect ( harness . onRestart ) . toHaveBeenCalledWith (
1273+ expect . objectContaining ( {
1274+ changedPaths : [ "plugins.installs.lossless-claw" ] ,
1275+ restartGateway : true ,
1276+ restartReasons : [ "plugins.installs.lossless-claw" ] ,
1277+ } ) ,
1278+ activeConfig ,
1279+ ) ;
1280+
1281+ await harness . reloader . stop ( ) ;
1282+ } ) ;
1283+
1284+ it ( "keeps external plugin policy-only writes on the hot reload path" , async ( ) => {
1285+ const previousConfig : OpenClawConfig = {
1286+ gateway : { reload : { debounceMs : 0 } } ,
1287+ plugins : {
1288+ entries : {
1289+ telegram : { enabled : false } ,
1290+ } ,
1291+ } ,
1292+ } ;
1293+ const nextConfig : OpenClawConfig = {
1294+ gateway : { reload : { debounceMs : 0 } } ,
1295+ plugins : {
1296+ entries : {
1297+ telegram : { enabled : true } ,
1298+ } ,
1299+ } ,
1300+ } ;
1301+ const installRecords = {
1302+ telegram : {
1303+ source : "npm" ,
1304+ spec : "@openclaw/telegram" ,
1305+ installPath : "/tmp/openclaw/plugins/telegram" ,
1306+ } ,
1307+ } satisfies Record < string , PluginInstallRecord > ;
1308+ const readSnapshot = vi . fn < ( ) => Promise < ConfigFileSnapshot > > ( ) . mockResolvedValueOnce (
1309+ makeSnapshot ( {
1310+ sourceConfig : nextConfig ,
1311+ runtimeConfig : nextConfig ,
1312+ config : nextConfig ,
1313+ hash : "external-plugin-policy-1" ,
1314+ } ) ,
1315+ ) ;
1316+ const readPluginInstallRecords = vi . fn ( ) . mockResolvedValueOnce ( installRecords ) ;
1317+ const harness = createReloaderHarness ( readSnapshot , {
1318+ initialCompareConfig : previousConfig ,
1319+ initialPluginInstallRecords : installRecords ,
1320+ readPluginInstallRecords,
1321+ } ) ;
1322+
1323+ harness . watcher . emit ( "change" ) ;
1324+ await vi . runOnlyPendingTimersAsync ( ) ;
1325+
1326+ expect ( harness . onRestart ) . not . toHaveBeenCalled ( ) ;
1327+ expect ( harness . onHotReload ) . toHaveBeenCalledTimes ( 1 ) ;
1328+ expect ( harness . onHotReload ) . toHaveBeenCalledWith (
1329+ expect . objectContaining ( {
1330+ changedPaths : [ "plugins.entries.telegram.enabled" ] ,
1331+ restartGateway : false ,
1332+ reloadPlugins : true ,
1333+ hotReasons : [ "plugins.entries.telegram.enabled" ] ,
1334+ } ) ,
1335+ nextConfig ,
1336+ ) ;
1337+
1338+ await harness . reloader . stop ( ) ;
1339+ } ) ;
1340+
1341+ it ( "queues restart when an external plugin source write also changes plugin config" , async ( ) => {
1342+ const previousConfig : OpenClawConfig = {
1343+ gateway : { reload : { debounceMs : 0 } } ,
1344+ plugins : {
1345+ allow : [ "lossless-claw" ] ,
1346+ } ,
1347+ } ;
1348+ const nextConfig : OpenClawConfig = {
1349+ gateway : { reload : { debounceMs : 0 } } ,
1350+ plugins : {
1351+ allow : [ "lossless-claw" ] ,
1352+ entries : {
1353+ "lossless-claw" : { enabled : true } ,
1354+ } ,
1355+ } ,
1356+ } ;
1357+ const readSnapshot = vi . fn < ( ) => Promise < ConfigFileSnapshot > > ( ) . mockResolvedValueOnce (
1358+ makeSnapshot ( {
1359+ sourceConfig : nextConfig ,
1360+ runtimeConfig : nextConfig ,
1361+ config : nextConfig ,
1362+ hash : "external-plugin-source-and-config-1" ,
1363+ } ) ,
1364+ ) ;
1365+ const readPluginInstallRecords = vi . fn ( ) . mockResolvedValueOnce ( {
1366+ "lossless-claw" : {
1367+ source : "npm" ,
1368+ spec : "@martian-engineering/lossless-claw" ,
1369+ installPath : "/tmp/openclaw/plugins/lossless-claw" ,
1370+ installedAt : "2026-04-22T00:00:00.000Z" ,
1371+ } ,
1372+ } satisfies Record < string , PluginInstallRecord > ) ;
1373+ const harness = createReloaderHarness ( readSnapshot , {
1374+ initialCompareConfig : previousConfig ,
1375+ initialPluginInstallRecords : { } ,
1376+ readPluginInstallRecords,
1377+ } ) ;
1378+
1379+ harness . watcher . emit ( "change" ) ;
1380+ await vi . runOnlyPendingTimersAsync ( ) ;
1381+
1382+ expect ( harness . onHotReload ) . not . toHaveBeenCalled ( ) ;
1383+ expect ( harness . onRestart ) . toHaveBeenCalledTimes ( 1 ) ;
1384+ expect ( harness . onRestart ) . toHaveBeenCalledWith (
1385+ expect . objectContaining ( {
1386+ changedPaths : [ "plugins.entries" , "plugins.installs.lossless-claw" ] ,
1387+ restartGateway : true ,
1388+ restartReasons : [ "plugins.installs.lossless-claw" ] ,
1389+ } ) ,
1390+ nextConfig ,
1391+ ) ;
1392+
1393+ await harness . reloader . stop ( ) ;
1394+ } ) ;
1395+
12301396 it ( "skips in-process promotion when the persisted file hash no longer matches the write" , async ( ) => {
12311397 const readSnapshot = vi . fn < ( ) => Promise < ConfigFileSnapshot > > ( ) . mockResolvedValueOnce (
12321398 makeSnapshot ( {
0 commit comments