openzeppelin_relayer/services/plugins/
script_executor.rs

1//! This module is responsible for executing a typescript script.
2//!
3//! 1. Checks if `ts-node` is installed.
4//! 2. Executes the script using the `ts-node` command.
5//! 3. Returns the output and errors of the script.
6use serde::{Deserialize, Serialize};
7use std::process::Stdio;
8use tokio::process::Command;
9use utoipa::ToSchema;
10
11use super::PluginError;
12
13#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, ToSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16    Log,
17    Info,
18    Error,
19    Warn,
20    Debug,
21    Result,
22}
23
24#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, ToSchema)]
25pub struct LogEntry {
26    pub level: LogLevel,
27    pub message: String,
28}
29
30#[derive(Serialize, Deserialize, Debug, ToSchema)]
31pub struct ScriptResult {
32    pub logs: Vec<LogEntry>,
33    pub error: String,
34    pub trace: Vec<serde_json::Value>,
35    pub return_value: String,
36}
37
38pub struct ScriptExecutor;
39
40impl ScriptExecutor {
41    pub async fn execute_typescript(
42        plugin_id: String,
43        script_path: String,
44        socket_path: String,
45        script_params: String,
46        http_request_id: Option<String>,
47        headers_json: Option<String>,
48    ) -> Result<ScriptResult, PluginError> {
49        if Command::new("ts-node")
50            .arg("--version")
51            .output()
52            .await
53            .is_err()
54        {
55            return Err(PluginError::SocketError(
56                "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
57            ));
58        }
59
60        // Use the centralized executor script instead of executing user script directly
61        // Use absolute path to avoid working directory issues in CI
62        let executor_path = std::env::current_dir()
63            .map(|cwd| cwd.join("plugins/lib/executor.ts").display().to_string())
64            .unwrap_or_else(|_| "plugins/lib/executor.ts".to_string());
65
66        let output = Command::new("ts-node")
67            .arg(executor_path)       // Execute executor script
68            .arg(socket_path)         // Socket path (argv[2])
69            .arg(plugin_id)           // Plugin ID (argv[3])
70            .arg(script_params)       // Plugin parameters (argv[4])
71            .arg(script_path)         // User script path (argv[5])
72            .arg(http_request_id.unwrap_or_default()) // HTTP x-request-id (argv[6], optional)
73            .arg(headers_json.unwrap_or_default()) // HTTP headers as JSON (argv[7], optional)
74            .stdin(Stdio::null())
75            .stdout(Stdio::piped())
76            .stderr(Stdio::piped())
77            .output()
78            .await
79            .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {e}")))?;
80
81        let stdout = String::from_utf8_lossy(&output.stdout);
82        let stderr = String::from_utf8_lossy(&output.stderr);
83
84        let (logs, return_value) =
85            Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
86
87        // Check if the script failed (non-zero exit code)
88        if !output.status.success() {
89            // Try to parse error info from stderr
90            if let Some(error_line) = stderr.lines().find(|l| !l.trim().is_empty()) {
91                if let Ok(error_info) = serde_json::from_str::<serde_json::Value>(error_line) {
92                    let message = error_info["message"]
93                        .as_str()
94                        .unwrap_or(&stderr)
95                        .to_string();
96                    let status = error_info
97                        .get("status")
98                        .and_then(|v| v.as_u64())
99                        .unwrap_or(500) as u16;
100                    let code = error_info
101                        .get("code")
102                        .and_then(|v| v.as_str())
103                        .map(|s| s.to_string());
104                    let details = error_info
105                        .get("details")
106                        .cloned()
107                        .or_else(|| error_info.get("data").cloned());
108                    return Err(PluginError::HandlerError(Box::new(
109                        super::PluginHandlerPayload {
110                            message,
111                            status,
112                            code,
113                            details,
114                            logs: Some(logs),
115                            traces: None,
116                        },
117                    )));
118                }
119            }
120            // Fallback to stderr as error message
121            return Err(PluginError::HandlerError(Box::new(
122                super::PluginHandlerPayload {
123                    message: stderr.to_string(),
124                    status: 500,
125                    code: None,
126                    details: None,
127                    logs: Some(logs),
128                    traces: None,
129                },
130            )));
131        }
132
133        Ok(ScriptResult {
134            logs,
135            return_value,
136            error: stderr.to_string(),
137            trace: Vec::new(),
138        })
139    }
140
141    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
142        let mut result = Vec::new();
143        let mut return_value = String::new();
144
145        for log in logs {
146            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
147                PluginError::PluginExecutionError(format!("Failed to parse log: {e}"))
148            })?;
149
150            if log.level == LogLevel::Result {
151                return_value = log.message;
152            } else {
153                result.push(log);
154            }
155        }
156
157        Ok((result, return_value))
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use std::fs;
164
165    use tempfile::tempdir;
166
167    use super::*;
168
169    static TS_CONFIG: &str = r#"
170    {
171        "compilerOptions": {
172          "target": "es2016",
173          "module": "commonjs",
174          "esModuleInterop": true,
175          "forceConsistentCasingInFileNames": true,
176          "strict": true,
177          "skipLibCheck": true
178        }
179      }
180"#;
181
182    #[tokio::test]
183    async fn test_execute_typescript() {
184        let temp_dir = tempdir().unwrap();
185        let ts_config = temp_dir.path().join("tsconfig.json");
186        let script_path = temp_dir.path().join("test_execute_typescript.ts");
187        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
188
189        let content = r#"
190            export async function handler(api: any, params: any) {
191                console.log('test');
192                console.info('test-info');
193                return 'test-result';
194            }
195        "#;
196        fs::write(script_path.clone(), content).unwrap();
197        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
198
199        let result = ScriptExecutor::execute_typescript(
200            "test-plugin-1".to_string(),
201            script_path.display().to_string(),
202            socket_path.display().to_string(),
203            "{}".to_string(),
204            None,
205            None,
206        )
207        .await;
208
209        assert!(result.is_ok());
210        let result = result.unwrap();
211        assert_eq!(result.logs[0].level, LogLevel::Log);
212        assert_eq!(result.logs[0].message, "test");
213        assert_eq!(result.logs[1].level, LogLevel::Info);
214        assert_eq!(result.logs[1].message, "test-info");
215        assert_eq!(result.return_value, "test-result");
216    }
217
218    #[tokio::test]
219    async fn test_execute_typescript_with_result() {
220        let temp_dir = tempdir().unwrap();
221        let ts_config = temp_dir.path().join("tsconfig.json");
222        let script_path = temp_dir
223            .path()
224            .join("test_execute_typescript_with_result.ts");
225        let socket_path = temp_dir
226            .path()
227            .join("test_execute_typescript_with_result.sock");
228
229        let content = r#"
230            export async function handler(api: any, params: any) {
231                console.log('test');
232                console.info('test-info');
233                return {
234                    test: 'test-result',
235                    test2: 'test-result2'
236                };
237            }
238        "#;
239        fs::write(script_path.clone(), content).unwrap();
240        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
241
242        let result = ScriptExecutor::execute_typescript(
243            "test-plugin-1".to_string(),
244            script_path.display().to_string(),
245            socket_path.display().to_string(),
246            "{}".to_string(),
247            None,
248            None,
249        )
250        .await;
251
252        assert!(result.is_ok());
253        let result = result.unwrap();
254        assert_eq!(result.logs[0].level, LogLevel::Log);
255        assert_eq!(result.logs[0].message, "test");
256        assert_eq!(result.logs[1].level, LogLevel::Info);
257        assert_eq!(result.logs[1].message, "test-info");
258        assert_eq!(
259            result.return_value,
260            "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
261        );
262    }
263
264    #[tokio::test]
265    async fn test_execute_typescript_error() {
266        let temp_dir = tempdir().unwrap();
267        let ts_config = temp_dir.path().join("tsconfig.json");
268        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
269        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
270
271        let content = "console.logger('test');";
272        fs::write(script_path.clone(), content).unwrap();
273        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
274
275        let result = ScriptExecutor::execute_typescript(
276            "test-plugin-1".to_string(),
277            script_path.display().to_string(),
278            socket_path.display().to_string(),
279            "{}".to_string(),
280            None,
281            None,
282        )
283        .await;
284
285        // Script errors should now return an Err with PluginFailed
286        assert!(result.is_err());
287
288        if let Err(PluginError::HandlerError(ctx)) = result {
289            // The error will be from our JSON output or raw stderr
290            // It should contain error info about the logger issue
291            assert_eq!(ctx.status, 500);
292            // The message should contain something about the error
293            assert!(!ctx.message.is_empty());
294        } else {
295            panic!("Expected PluginError::HandlerError, got: {:?}", result);
296        }
297    }
298
299    #[tokio::test]
300    async fn test_execute_typescript_handler_json_error() {
301        let temp_dir = tempdir().unwrap();
302        let ts_config = temp_dir.path().join("tsconfig.json");
303        let script_path = temp_dir
304            .path()
305            .join("test_execute_typescript_handler_json_error.ts");
306        let socket_path = temp_dir
307            .path()
308            .join("test_execute_typescript_handler_json_error.sock");
309
310        // This handler throws an error with code/status/details; our executor should capture
311        // and emit a normalized JSON error to stderr which the Rust side parses.
312        let content = r#"
313            export async function handler(_api: any, _params: any) {
314                const err: any = new Error('Validation failed');
315                err.code = 'VALIDATION_FAILED';
316                err.status = 422;
317                err.details = { field: 'email' };
318                throw err;
319            }
320        "#;
321        fs::write(&script_path, content).unwrap();
322        fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
323
324        let result = ScriptExecutor::execute_typescript(
325            "test-plugin-json-error".to_string(),
326            script_path.display().to_string(),
327            socket_path.display().to_string(),
328            "{}".to_string(),
329            None,
330            None,
331        )
332        .await;
333
334        match result {
335            Err(PluginError::HandlerError(ctx)) => {
336                assert_eq!(ctx.message, "Validation failed");
337                assert_eq!(ctx.status, 422);
338                assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
339                let d = ctx.details.expect("details should be present");
340                assert_eq!(d["field"].as_str(), Some("email"));
341            }
342            other => panic!("Expected HandlerError, got: {:?}", other),
343        }
344    }
345    #[tokio::test]
346    async fn test_parse_logs_error() {
347        let temp_dir = tempdir().unwrap();
348        let ts_config = temp_dir.path().join("tsconfig.json");
349        let script_path = temp_dir.path().join("test_execute_typescript.ts");
350        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
351
352        let invalid_content = r#"
353            export async function handler(api: any, params: any) {
354                // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
355                process.stdout.write('invalid json line\n');
356                process.stdout.write('{"level":"log","message":"valid"}\n');
357                process.stdout.write('another invalid line\n');
358                return 'test';
359            }
360        "#;
361        fs::write(script_path.clone(), invalid_content).unwrap();
362        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
363
364        let result = ScriptExecutor::execute_typescript(
365            "test-plugin-1".to_string(),
366            script_path.display().to_string(),
367            socket_path.display().to_string(),
368            "{}".to_string(),
369            None,
370            None,
371        )
372        .await;
373
374        assert!(result.is_err());
375        assert!(result
376            .err()
377            .unwrap()
378            .to_string()
379            .contains("Failed to parse log"));
380    }
381
382    #[tokio::test]
383    async fn test_execute_typescript_with_headers() {
384        let temp_dir = tempdir().unwrap();
385        let ts_config = temp_dir.path().join("tsconfig.json");
386        let script_path = temp_dir
387            .path()
388            .join("test_execute_typescript_with_headers.ts");
389        let socket_path = temp_dir
390            .path()
391            .join("test_execute_typescript_with_headers.sock");
392
393        // Plugin using modern context pattern to access headers
394        let content = r#"
395            export async function handler(context: any) {
396                const { headers, params } = context;
397                console.log(`Received ${Object.keys(headers).length} headers`);
398                return {
399                    headerCount: Object.keys(headers).length,
400                    customHeader: headers['x-custom-header']?.[0],
401                    authHeader: headers['authorization']?.[0],
402                    multiValueHeader: headers['x-multi-value'],
403                    params: params
404                };
405            }
406        "#;
407        fs::write(script_path.clone(), content).unwrap();
408        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
409
410        // Create headers JSON matching HashMap<String, Vec<String>>
411        let headers_json = r#"{"x-custom-header":["custom-value"],"authorization":["Bearer token123"],"x-multi-value":["value1","value2"]}"#;
412
413        let result = ScriptExecutor::execute_typescript(
414            "test-plugin-headers".to_string(),
415            script_path.display().to_string(),
416            socket_path.display().to_string(),
417            r#"{"foo":"bar"}"#.to_string(),
418            None,
419            Some(headers_json.to_string()),
420        )
421        .await;
422
423        assert!(result.is_ok());
424        let result = result.unwrap();
425
426        // Verify log output
427        assert_eq!(result.logs[0].level, LogLevel::Log);
428        assert!(result.logs[0].message.contains("Received 3 headers"));
429
430        // Parse return value and verify headers were accessible
431        let return_obj: serde_json::Value =
432            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
433
434        assert_eq!(return_obj["headerCount"], 3);
435        assert_eq!(return_obj["customHeader"], "custom-value");
436        assert_eq!(return_obj["authHeader"], "Bearer token123");
437        assert_eq!(
438            return_obj["multiValueHeader"],
439            serde_json::json!(["value1", "value2"])
440        );
441        assert_eq!(return_obj["params"], serde_json::json!({"foo": "bar"}));
442    }
443}