@@ -1121,6 +1121,155 @@ connect_timeout = 2
11211121 assert_eq ! ( connection. tools( ) [ 0 ] . name, "ready" ) ;
11221122}
11231123
1124+ #[ cfg( target_os = "macos" ) ]
1125+ #[ tokio:: test]
1126+ async fn reviewed_multi_file_node_mjs_plugin_launches_by_staged_path ( ) {
1127+ if std:: process:: Command :: new ( "node" )
1128+ . arg ( "--version" )
1129+ . output ( )
1130+ . is_err ( )
1131+ {
1132+ eprintln ! ( "skipping reviewed multi-file Node ESM launch test because node is unavailable" ) ;
1133+ return ;
1134+ }
1135+
1136+ // The entry imports a sibling module, exactly like the computer-use
1137+ // bundle (#5916). Launched by descriptor, Node would resolve `./lib/...`
1138+ // against `/dev/` and the child would die before the handshake.
1139+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1140+ let plugins_root = dir. path ( ) . join ( "plugins" ) ;
1141+ let plugin_base = plugins_root. join ( "node-esm-multi" ) ;
1142+ fs:: create_dir_all ( plugin_base. join ( "mcp" ) ) . unwrap ( ) ;
1143+ fs:: create_dir_all ( plugin_base. join ( "lib" ) ) . unwrap ( ) ;
1144+ fs:: write (
1145+ plugin_base. join ( "lib" ) . join ( "reply.mjs" ) ,
1146+ r#"import path from 'node:path';
1147+ import url from 'node:url';
1148+ export const TOOL = 'ready-from-sibling';
1149+ export const ENTRY_DIR = path.basename(path.dirname(url.fileURLToPath(import.meta.url)));
1150+ "# ,
1151+ )
1152+ . unwrap ( ) ;
1153+ fs:: write (
1154+ plugin_base. join ( "mcp" ) . join ( "server.mjs" ) ,
1155+ r#"import readline from 'node:readline';
1156+ import { TOOL, ENTRY_DIR } from '../lib/reply.mjs';
1157+ const lines = readline.createInterface({ input: process.stdin });
1158+ lines.on('line', (line) => {
1159+ const request = JSON.parse(line);
1160+ if (request.id === undefined) return;
1161+ let result;
1162+ if (request.method === 'initialize') {
1163+ result = {
1164+ protocolVersion: '2025-06-18',
1165+ capabilities: { tools: {} },
1166+ serverInfo: { name: 'node-esm-multi', version: '1.0.0' }
1167+ };
1168+ } else if (request.method === 'tools/list') {
1169+ result = {
1170+ tools: [{ name: TOOL, description: ENTRY_DIR, inputSchema: { type: 'object' } }]
1171+ };
1172+ } else {
1173+ result = {};
1174+ }
1175+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
1176+ });
1177+ "# ,
1178+ )
1179+ . unwrap ( ) ;
1180+ fs:: write (
1181+ plugin_base. join ( "plugin.toml" ) ,
1182+ r#"
1183+ schema_version = 1
1184+ [plugin]
1185+ name = "node-esm-multi"
1186+ version = "1.0.0"
1187+
1188+ [mcp_servers.local]
1189+ command = "node"
1190+ args = ["mcp/server.mjs"]
1191+ connect_timeout = 2
1192+ "# ,
1193+ )
1194+ . unwrap ( ) ;
1195+
1196+ let discovery = crate :: plugins:: discovery:: DiscoveryConfig {
1197+ workspace : dir. path ( ) . join ( "project" ) ,
1198+ user_plugins_dir : plugins_root,
1199+ workspace_plugins_dir : dir. path ( ) . join ( "workspace-plugins-unused" ) ,
1200+ builtin_plugin_dirs : Vec :: new ( ) ,
1201+ state_path : dir. path ( ) . join ( "plugin-state/state.json" ) ,
1202+ } ;
1203+ let mut registry = crate :: plugins:: discovery:: discover_with_config ( & discovery) ;
1204+ registry. trust ( "node-esm-multi" ) . unwrap ( ) ;
1205+ registry. enable ( "node-esm-multi" ) . unwrap ( ) ;
1206+ let active = registry. active_plugins ( ) [ 0 ] . clone ( ) ;
1207+ let authority = registry. authority_for ( "node-esm-multi" ) . unwrap ( ) ;
1208+ let merged = merge_plugin_mcp_servers_from_plugins (
1209+ McpConfig :: default ( ) ,
1210+ vec ! [ ( "node-esm-multi" . to_string( ) , active, authority) ] ,
1211+ )
1212+ . unwrap ( ) ;
1213+ let mut pool = McpPool :: new ( merged) ;
1214+
1215+ let connection = pool
1216+ . get_or_connect ( "plugin-14-node-esm-multi-local" )
1217+ . await
1218+ . unwrap ( ) ;
1219+ assert_eq ! ( connection. tools( ) . len( ) , 1 ) ;
1220+ assert_eq ! ( connection. tools( ) [ 0 ] . name, "ready-from-sibling" ) ;
1221+ // The sibling resolved from the staged tree, not from `/dev/`.
1222+ assert_eq ! ( connection. tools( ) [ 0 ] . description. as_deref( ) , Some ( "lib" ) ) ;
1223+ }
1224+
1225+ #[ cfg( target_os = "macos" ) ]
1226+ #[ test]
1227+ fn esm_entry_with_module_siblings_keeps_its_staged_path ( ) {
1228+ use std:: collections:: BTreeMap ;
1229+ let staged_root = Path :: new ( "/stage/plugin" ) ;
1230+ let entry = staged_root. join ( "mcp/server.mjs" ) ;
1231+ let hash = |paths : & [ & str ] | {
1232+ paths
1233+ . iter ( )
1234+ . map ( |path| ( PathBuf :: from ( path) , "h" . to_string ( ) ) )
1235+ . collect :: < BTreeMap < _ , _ > > ( )
1236+ } ;
1237+ // Manifests, docs, and data files are not modules.
1238+ assert ! ( !esm_entry_has_module_siblings(
1239+ staged_root,
1240+ & entry,
1241+ & hash( & [
1242+ "mcp/server.mjs" ,
1243+ "plugin.json" ,
1244+ "mcp.json" ,
1245+ "README.md" ,
1246+ "skills/a/SKILL.md"
1247+ ] ) ,
1248+ ) ) ;
1249+ for sibling in [
1250+ "src/tools.mjs" ,
1251+ "lib/x.js" ,
1252+ "lib/x.cjs" ,
1253+ "native/x.node" ,
1254+ "wasm/x.wasm" ,
1255+ ] {
1256+ assert ! (
1257+ esm_entry_has_module_siblings(
1258+ staged_root,
1259+ & entry,
1260+ & hash( & [ "mcp/server.mjs" , "plugin.json" , sibling] ) ,
1261+ ) ,
1262+ "{sibling} must force a path launch"
1263+ ) ;
1264+ }
1265+ // An entry outside the stage never qualifies.
1266+ assert ! ( !esm_entry_has_module_siblings(
1267+ Path :: new( "/elsewhere" ) ,
1268+ & entry,
1269+ & hash( & [ "mcp/server.mjs" , "src/tools.mjs" ] ) ,
1270+ ) ) ;
1271+ }
1272+
11241273#[ cfg( target_os = "macos" ) ]
11251274#[ test]
11261275fn node_esm_descriptor_launch_keeps_options_argv_shape_and_script_arguments ( ) {
0 commit comments