@@ -67,6 +67,44 @@ function writePackagePlugin(rootDir: string, options: { configPaths?: readonly s
6767 ) ;
6868}
6969
70+ function writeBundledPlugin ( rootDir : string , pluginId : string , entryPath : string ) {
71+ fs . mkdirSync ( rootDir , { recursive : true } ) ;
72+ fs . writeFileSync ( path . join ( rootDir , entryPath ) , "export default { register() {} };\n" , "utf8" ) ;
73+ fs . writeFileSync (
74+ path . join ( rootDir , "openclaw.plugin.json" ) ,
75+ JSON . stringify ( {
76+ id : pluginId ,
77+ name : pluginId ,
78+ description : pluginId ,
79+ configSchema : { type : "object" } ,
80+ } ) ,
81+ "utf8" ,
82+ ) ;
83+ fs . writeFileSync (
84+ path . join ( rootDir , "package.json" ) ,
85+ JSON . stringify ( {
86+ name : `@openclaw/${ pluginId } ` ,
87+ version : "1.0.0" ,
88+ openclaw : { extensions : [ `./${ entryPath } ` ] } ,
89+ } ) ,
90+ "utf8" ,
91+ ) ;
92+ }
93+
94+ function mockLinuxMountInfo ( mountPoints : readonly string [ ] ) {
95+ const originalReadFileSync = fs . readFileSync ;
96+ return vi . spyOn ( fs , "readFileSync" ) . mockImplementation ( ( filePath , options ) => {
97+ if ( filePath === "/proc/self/mountinfo" ) {
98+ return mountPoints
99+ . map (
100+ ( mountPoint , index ) => `${ 100 + index } 99 0:${ index } / ${ mountPoint } rw - tmpfs tmpfs rw` ,
101+ )
102+ . join ( "\n" ) ;
103+ }
104+ return originalReadFileSync ( filePath , options as never ) as never ;
105+ } ) ;
106+ }
107+
70108function createCandidate ( rootDir : string , pluginId = "demo" ) : PluginCandidate {
71109 fs . mkdirSync ( rootDir , { recursive : true } ) ;
72110 fs . writeFileSync ( path . join ( rootDir , "index.ts" ) , "export default { register() {} };\n" , "utf8" ) ;
@@ -753,6 +791,108 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
753791 expectDiagnosticsContainCode ( result . diagnostics , "persisted-registry-stale-source" ) ;
754792 } ) ;
755793
794+ it ( "keeps mixed source-checkout bundled roots from the same checkout" , ( ) => {
795+ const tempRoot = makeTempDir ( ) ;
796+ const packageRoot = path . join ( tempRoot , "openclaw" ) ;
797+ const bundledRoot = path . join ( packageRoot , "dist" , "extensions" ) ;
798+ const sourceRoot = path . join ( packageRoot , "extensions" ) ;
799+ const stateDir = path . join ( tempRoot , "state" ) ;
800+ const env = {
801+ OPENCLAW_BUNDLED_PLUGINS_DIR : bundledRoot ,
802+ OPENCLAW_STATE_DIR : stateDir ,
803+ OPENCLAW_VERSION : "2026.4.26" ,
804+ VITEST : "true" ,
805+ } ;
806+
807+ fs . mkdirSync ( path . join ( packageRoot , "src" ) , { recursive : true } ) ;
808+ fs . writeFileSync ( path . join ( packageRoot , ".git" ) , "gitdir: /tmp/mock\n" , "utf8" ) ;
809+ fs . writeFileSync ( path . join ( packageRoot , "pnpm-workspace.yaml" ) , "packages: []\n" , "utf8" ) ;
810+ writeBundledPlugin ( path . join ( bundledRoot , "codex" ) , "codex" , "index.js" ) ;
811+ writeBundledPlugin ( path . join ( sourceRoot , "whatsapp" ) , "whatsapp" , "index.ts" ) ;
812+
813+ const index = loadInstalledPluginIndex ( { config : { } , env, stateDir } ) ;
814+ expect ( index . plugins . map ( ( plugin ) => plugin . pluginId ) ) . toEqual ( [ "codex" , "whatsapp" ] ) ;
815+ expect ( index . plugins . map ( ( plugin ) => plugin . rootDir ) ) . toEqual ( [
816+ fs . realpathSync ( path . join ( bundledRoot , "codex" ) ) ,
817+ fs . realpathSync ( path . join ( sourceRoot , "whatsapp" ) ) ,
818+ ] ) ;
819+ writePersistedInstalledPluginIndexSync ( index , { stateDir } ) ;
820+
821+ const result = loadPluginRegistrySnapshotWithMetadata ( { config : { } , env, stateDir } ) ;
822+
823+ expect ( result . source ) . toBe ( "persisted" ) ;
824+ expect ( result . diagnostics ) . toStrictEqual ( [ ] ) ;
825+ expect ( result . snapshot . plugins . map ( ( plugin ) => plugin . pluginId ) ) . toEqual ( [ "codex" , "whatsapp" ] ) ;
826+ } ) ;
827+
828+ it ( "treats a persisted source bundled root as stale once its built peer appears" , ( ) => {
829+ const tempRoot = makeTempDir ( ) ;
830+ const packageRoot = path . join ( tempRoot , "openclaw" ) ;
831+ const bundledRoot = path . join ( packageRoot , "dist" , "extensions" ) ;
832+ const sourceRoot = path . join ( packageRoot , "extensions" ) ;
833+ const stateDir = path . join ( tempRoot , "state" ) ;
834+ const env = {
835+ OPENCLAW_BUNDLED_PLUGINS_DIR : bundledRoot ,
836+ OPENCLAW_STATE_DIR : stateDir ,
837+ OPENCLAW_VERSION : "2026.4.26" ,
838+ VITEST : "true" ,
839+ } ;
840+
841+ fs . mkdirSync ( path . join ( packageRoot , "src" ) , { recursive : true } ) ;
842+ fs . mkdirSync ( bundledRoot , { recursive : true } ) ;
843+ fs . writeFileSync ( path . join ( packageRoot , ".git" ) , "gitdir: /tmp/mock\n" , "utf8" ) ;
844+ fs . writeFileSync ( path . join ( packageRoot , "pnpm-workspace.yaml" ) , "packages: []\n" , "utf8" ) ;
845+ writeBundledPlugin ( path . join ( sourceRoot , "whatsapp" ) , "whatsapp" , "index.ts" ) ;
846+
847+ const sourceIndex = loadInstalledPluginIndex ( { config : { } , env, stateDir } ) ;
848+ expect ( sourceIndex . plugins . map ( ( plugin ) => plugin . rootDir ) ) . toEqual ( [
849+ fs . realpathSync ( path . join ( sourceRoot , "whatsapp" ) ) ,
850+ ] ) ;
851+ writePersistedInstalledPluginIndexSync ( sourceIndex , { stateDir } ) ;
852+ writeBundledPlugin ( path . join ( bundledRoot , "whatsapp" ) , "whatsapp" , "index.js" ) ;
853+
854+ const result = loadPluginRegistrySnapshotWithMetadata ( { config : { } , env, stateDir } ) ;
855+
856+ expect ( result . source ) . toBe ( "derived" ) ;
857+ expectDiagnosticsContainCode ( result . diagnostics , "persisted-registry-stale-source" ) ;
858+ expect ( result . snapshot . plugins . map ( ( plugin ) => plugin . rootDir ) ) . toEqual ( [
859+ fs . realpathSync ( path . join ( bundledRoot , "whatsapp" ) ) ,
860+ ] ) ;
861+ } ) ;
862+
863+ it ( "keeps a persisted bind-mounted source overlay when its built peer exists" , ( ) => {
864+ const tempRoot = makeTempDir ( ) ;
865+ const packageRoot = path . join ( tempRoot , "openclaw" ) ;
866+ const bundledRoot = path . join ( packageRoot , "dist" , "extensions" ) ;
867+ const sourcePluginDir = path . join ( packageRoot , "extensions" , "whatsapp" ) ;
868+ const stateDir = path . join ( tempRoot , "state" ) ;
869+ const env = {
870+ OPENCLAW_BUNDLED_PLUGINS_DIR : bundledRoot ,
871+ OPENCLAW_STATE_DIR : stateDir ,
872+ OPENCLAW_VERSION : "2026.4.26" ,
873+ VITEST : "true" ,
874+ } ;
875+
876+ fs . mkdirSync ( path . join ( packageRoot , "src" ) , { recursive : true } ) ;
877+ fs . mkdirSync ( bundledRoot , { recursive : true } ) ;
878+ fs . writeFileSync ( path . join ( packageRoot , ".git" ) , "gitdir: /tmp/mock\n" , "utf8" ) ;
879+ fs . writeFileSync ( path . join ( packageRoot , "pnpm-workspace.yaml" ) , "packages: []\n" , "utf8" ) ;
880+ writeBundledPlugin ( sourcePluginDir , "whatsapp" , "index.ts" ) ;
881+
882+ const sourceIndex = loadInstalledPluginIndex ( { config : { } , env, stateDir } ) ;
883+ writePersistedInstalledPluginIndexSync ( sourceIndex , { stateDir } ) ;
884+ writeBundledPlugin ( path . join ( bundledRoot , "whatsapp" ) , "whatsapp" , "index.js" ) ;
885+ mockLinuxMountInfo ( [ sourcePluginDir ] ) ;
886+
887+ const result = loadPluginRegistrySnapshotWithMetadata ( { config : { } , env, stateDir } ) ;
888+
889+ expect ( result . source ) . toBe ( "persisted" ) ;
890+ expect ( result . diagnostics ) . toStrictEqual ( [ ] ) ;
891+ expect ( result . snapshot . plugins . map ( ( plugin ) => plugin . rootDir ) ) . toEqual ( [
892+ fs . realpathSync ( sourcePluginDir ) ,
893+ ] ) ;
894+ } ) ;
895+
756896 it ( "treats persisted registry as stale when a plugin diagnostic source path no longer exists" , ( ) => {
757897 const tempRoot = makeTempDir ( ) ;
758898 const stateDir = path . join ( tempRoot , "state" ) ;
0 commit comments