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    ) -> Result<ScriptResult, PluginError> {
48        if Command::new("ts-node")
49            .arg("--version")
50            .output()
51            .await
52            .is_err()
53        {
54            return Err(PluginError::SocketError(
55                "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
56            ));
57        }
58
59        // Use the centralized executor script instead of executing user script directly
60        // Use absolute path to avoid working directory issues in CI
61        let executor_path = std::env::current_dir()
62            .map(|cwd| cwd.join("plugins/lib/executor.ts").display().to_string())
63            .unwrap_or_else(|_| "plugins/lib/executor.ts".to_string());
64
65        let output = Command::new("ts-node")
66            .arg(executor_path)       // Execute executor script
67            .arg(socket_path)         // Socket path (argv[2])
68            .arg(plugin_id)           // Plugin ID (argv[3])
69            .arg(script_params)       // Plugin parameters (argv[4])
70            .arg(script_path)         // User script path (argv[5])
71            .arg(http_request_id.unwrap_or_default()) // HTTP x-request-id (argv[6], optional)
72            .stdin(Stdio::null())
73            .stdout(Stdio::piped())
74            .stderr(Stdio::piped())
75            .output()
76            .await
77            .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {e}")))?;
78
79        let stdout = String::from_utf8_lossy(&output.stdout);
80        let stderr = String::from_utf8_lossy(&output.stderr);
81
82        let (logs, return_value) =
83            Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
84
85        // Check if the script failed (non-zero exit code)
86        if !output.status.success() {
87            // Try to parse error info from stderr
88            if let Some(error_line) = stderr.lines().find(|l| !l.trim().is_empty()) {
89                if let Ok(error_info) = serde_json::from_str::<serde_json::Value>(error_line) {
90                    let message = error_info["message"]
91                        .as_str()
92                        .unwrap_or(&stderr)
93                        .to_string();
94                    let status = error_info
95                        .get("status")
96                        .and_then(|v| v.as_u64())
97                        .unwrap_or(500) as u16;
98                    let code = error_info
99                        .get("code")
100                        .and_then(|v| v.as_str())
101                        .map(|s| s.to_string());
102                    let details = error_info
103                        .get("details")
104                        .cloned()
105                        .or_else(|| error_info.get("data").cloned());
106                    return Err(PluginError::HandlerError(Box::new(
107                        super::PluginHandlerPayload {
108                            message,
109                            status,
110                            code,
111                            details,
112                            logs: Some(logs),
113                            traces: None,
114                        },
115                    )));
116                }
117            }
118            // Fallback to stderr as error message
119            return Err(PluginError::HandlerError(Box::new(
120                super::PluginHandlerPayload {
121                    message: stderr.to_string(),
122                    status: 500,
123                    code: None,
124                    details: None,
125                    logs: Some(logs),
126                    traces: None,
127                },
128            )));
129        }
130
131        Ok(ScriptResult {
132            logs,
133            return_value,
134            error: stderr.to_string(),
135            trace: Vec::new(),
136        })
137    }
138
139    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
140        let mut result = Vec::new();
141        let mut return_value = String::new();
142
143        for log in logs {
144            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
145                PluginError::PluginExecutionError(format!("Failed to parse log: {e}"))
146            })?;
147
148            if log.level == LogLevel::Result {
149                return_value = log.message;
150            } else {
151                result.push(log);
152            }
153        }
154
155        Ok((result, return_value))
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use std::fs;
162
163    use tempfile::tempdir;
164
165    use super::*;
166
167    static TS_CONFIG: &str = r#"
168    {
169        "compilerOptions": {
170          "target": "es2016",
171          "module": "commonjs",
172          "esModuleInterop": true,
173          "forceConsistentCasingInFileNames": true,
174          "strict": true,
175          "skipLibCheck": true
176        }
177      }
178"#;
179
180    #[tokio::test]
181    async fn test_execute_typescript() {
182        let temp_dir = tempdir().unwrap();
183        let ts_config = temp_dir.path().join("tsconfig.json");
184        let script_path = temp_dir.path().join("test_execute_typescript.ts");
185        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
186
187        let content = r#"
188            export async function handler(api: any, params: any) {
189                console.log('test');
190                console.info('test-info');
191                return 'test-result';
192            }
193        "#;
194        fs::write(script_path.clone(), content).unwrap();
195        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
196
197        let result = ScriptExecutor::execute_typescript(
198            "test-plugin-1".to_string(),
199            script_path.display().to_string(),
200            socket_path.display().to_string(),
201            "{}".to_string(),
202            None,
203        )
204        .await;
205
206        assert!(result.is_ok());
207        let result = result.unwrap();
208        assert_eq!(result.logs[0].level, LogLevel::Log);
209        assert_eq!(result.logs[0].message, "test");
210        assert_eq!(result.logs[1].level, LogLevel::Info);
211        assert_eq!(result.logs[1].message, "test-info");
212        assert_eq!(result.return_value, "test-result");
213    }
214
215    #[tokio::test]
216    async fn test_execute_typescript_with_result() {
217        let temp_dir = tempdir().unwrap();
218        let ts_config = temp_dir.path().join("tsconfig.json");
219        let script_path = temp_dir
220            .path()
221            .join("test_execute_typescript_with_result.ts");
222        let socket_path = temp_dir
223            .path()
224            .join("test_execute_typescript_with_result.sock");
225
226        let content = r#"
227            export async function handler(api: any, params: any) {
228                console.log('test');
229                console.info('test-info');
230                return {
231                    test: 'test-result',
232                    test2: 'test-result2'
233                };
234            }
235        "#;
236        fs::write(script_path.clone(), content).unwrap();
237        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
238
239        let result = ScriptExecutor::execute_typescript(
240            "test-plugin-1".to_string(),
241            script_path.display().to_string(),
242            socket_path.display().to_string(),
243            "{}".to_string(),
244            None,
245        )
246        .await;
247
248        assert!(result.is_ok());
249        let result = result.unwrap();
250        assert_eq!(result.logs[0].level, LogLevel::Log);
251        assert_eq!(result.logs[0].message, "test");
252        assert_eq!(result.logs[1].level, LogLevel::Info);
253        assert_eq!(result.logs[1].message, "test-info");
254        assert_eq!(
255            result.return_value,
256            "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
257        );
258    }
259
260    #[tokio::test]
261    async fn test_execute_typescript_error() {
262        let temp_dir = tempdir().unwrap();
263        let ts_config = temp_dir.path().join("tsconfig.json");
264        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
265        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
266
267        let content = "console.logger('test');";
268        fs::write(script_path.clone(), content).unwrap();
269        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
270
271        let result = ScriptExecutor::execute_typescript(
272            "test-plugin-1".to_string(),
273            script_path.display().to_string(),
274            socket_path.display().to_string(),
275            "{}".to_string(),
276            None,
277        )
278        .await;
279
280        // Script errors should now return an Err with PluginFailed
281        assert!(result.is_err());
282
283        if let Err(PluginError::HandlerError(ctx)) = result {
284            // The error will be from our JSON output or raw stderr
285            // It should contain error info about the logger issue
286            assert_eq!(ctx.status, 500);
287            // The message should contain something about the error
288            assert!(!ctx.message.is_empty());
289        } else {
290            panic!("Expected PluginError::HandlerError, got: {:?}", result);
291        }
292    }
293
294    #[tokio::test]
295    async fn test_execute_typescript_handler_json_error() {
296        let temp_dir = tempdir().unwrap();
297        let ts_config = temp_dir.path().join("tsconfig.json");
298        let script_path = temp_dir
299            .path()
300            .join("test_execute_typescript_handler_json_error.ts");
301        let socket_path = temp_dir
302            .path()
303            .join("test_execute_typescript_handler_json_error.sock");
304
305        // This handler throws an error with code/status/details; our executor should capture
306        // and emit a normalized JSON error to stderr which the Rust side parses.
307        let content = r#"
308            export async function handler(_api: any, _params: any) {
309                const err: any = new Error('Validation failed');
310                err.code = 'VALIDATION_FAILED';
311                err.status = 422;
312                err.details = { field: 'email' };
313                throw err;
314            }
315        "#;
316        fs::write(&script_path, content).unwrap();
317        fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
318
319        let result = ScriptExecutor::execute_typescript(
320            "test-plugin-json-error".to_string(),
321            script_path.display().to_string(),
322            socket_path.display().to_string(),
323            "{}".to_string(),
324            None,
325        )
326        .await;
327
328        match result {
329            Err(PluginError::HandlerError(ctx)) => {
330                assert_eq!(ctx.message, "Validation failed");
331                assert_eq!(ctx.status, 422);
332                assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
333                let d = ctx.details.expect("details should be present");
334                assert_eq!(d["field"].as_str(), Some("email"));
335            }
336            other => panic!("Expected HandlerError, got: {:?}", other),
337        }
338    }
339    #[tokio::test]
340    async fn test_parse_logs_error() {
341        let temp_dir = tempdir().unwrap();
342        let ts_config = temp_dir.path().join("tsconfig.json");
343        let script_path = temp_dir.path().join("test_execute_typescript.ts");
344        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
345
346        let invalid_content = r#"
347            export async function handler(api: any, params: any) {
348                // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
349                process.stdout.write('invalid json line\n');
350                process.stdout.write('{"level":"log","message":"valid"}\n');
351                process.stdout.write('another invalid line\n');
352                return 'test';
353            }
354        "#;
355        fs::write(script_path.clone(), invalid_content).unwrap();
356        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
357
358        let result = ScriptExecutor::execute_typescript(
359            "test-plugin-1".to_string(),
360            script_path.display().to_string(),
361            socket_path.display().to_string(),
362            "{}".to_string(),
363            None,
364        )
365        .await;
366
367        assert!(result.is_err());
368        assert!(result
369            .err()
370            .unwrap()
371            .to_string()
372            .contains("Failed to parse log"));
373    }
374}