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(super::PluginHandlerPayload {
107                        message,
108                        status,
109                        code,
110                        details,
111                        logs: Some(logs),
112                        traces: None,
113                    }));
114                }
115            }
116            // Fallback to stderr as error message
117            return Err(PluginError::HandlerError(super::PluginHandlerPayload {
118                message: stderr.to_string(),
119                status: 500,
120                code: None,
121                details: None,
122                logs: Some(logs),
123                traces: None,
124            }));
125        }
126
127        Ok(ScriptResult {
128            logs,
129            return_value,
130            error: stderr.to_string(),
131            trace: Vec::new(),
132        })
133    }
134
135    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
136        let mut result = Vec::new();
137        let mut return_value = String::new();
138
139        for log in logs {
140            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
141                PluginError::PluginExecutionError(format!("Failed to parse log: {}", e))
142            })?;
143
144            if log.level == LogLevel::Result {
145                return_value = log.message;
146            } else {
147                result.push(log);
148            }
149        }
150
151        Ok((result, return_value))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::fs;
158
159    use tempfile::tempdir;
160
161    use super::*;
162
163    static TS_CONFIG: &str = r#"
164    {
165        "compilerOptions": {
166          "target": "es2016",
167          "module": "commonjs",
168          "esModuleInterop": true,
169          "forceConsistentCasingInFileNames": true,
170          "strict": true,
171          "skipLibCheck": true
172        }
173      }
174"#;
175
176    #[tokio::test]
177    async fn test_execute_typescript() {
178        let temp_dir = tempdir().unwrap();
179        let ts_config = temp_dir.path().join("tsconfig.json");
180        let script_path = temp_dir.path().join("test_execute_typescript.ts");
181        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
182
183        let content = r#"
184            export async function handler(api: any, params: any) {
185                console.log('test');
186                console.info('test-info');
187                return 'test-result';
188            }
189        "#;
190        fs::write(script_path.clone(), content).unwrap();
191        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
192
193        let result = ScriptExecutor::execute_typescript(
194            "test-plugin-1".to_string(),
195            script_path.display().to_string(),
196            socket_path.display().to_string(),
197            "{}".to_string(),
198            None,
199        )
200        .await;
201
202        assert!(result.is_ok());
203        let result = result.unwrap();
204        assert_eq!(result.logs[0].level, LogLevel::Log);
205        assert_eq!(result.logs[0].message, "test");
206        assert_eq!(result.logs[1].level, LogLevel::Info);
207        assert_eq!(result.logs[1].message, "test-info");
208        assert_eq!(result.return_value, "test-result");
209    }
210
211    #[tokio::test]
212    async fn test_execute_typescript_with_result() {
213        let temp_dir = tempdir().unwrap();
214        let ts_config = temp_dir.path().join("tsconfig.json");
215        let script_path = temp_dir
216            .path()
217            .join("test_execute_typescript_with_result.ts");
218        let socket_path = temp_dir
219            .path()
220            .join("test_execute_typescript_with_result.sock");
221
222        let content = r#"
223            export async function handler(api: any, params: any) {
224                console.log('test');
225                console.info('test-info');
226                return {
227                    test: 'test-result',
228                    test2: 'test-result2'
229                };
230            }
231        "#;
232        fs::write(script_path.clone(), content).unwrap();
233        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
234
235        let result = ScriptExecutor::execute_typescript(
236            "test-plugin-1".to_string(),
237            script_path.display().to_string(),
238            socket_path.display().to_string(),
239            "{}".to_string(),
240            None,
241        )
242        .await;
243
244        assert!(result.is_ok());
245        let result = result.unwrap();
246        assert_eq!(result.logs[0].level, LogLevel::Log);
247        assert_eq!(result.logs[0].message, "test");
248        assert_eq!(result.logs[1].level, LogLevel::Info);
249        assert_eq!(result.logs[1].message, "test-info");
250        assert_eq!(
251            result.return_value,
252            "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
253        );
254    }
255
256    #[tokio::test]
257    async fn test_execute_typescript_error() {
258        let temp_dir = tempdir().unwrap();
259        let ts_config = temp_dir.path().join("tsconfig.json");
260        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
261        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
262
263        let content = "console.logger('test');";
264        fs::write(script_path.clone(), content).unwrap();
265        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
266
267        let result = ScriptExecutor::execute_typescript(
268            "test-plugin-1".to_string(),
269            script_path.display().to_string(),
270            socket_path.display().to_string(),
271            "{}".to_string(),
272            None,
273        )
274        .await;
275
276        // Script errors should now return an Err with PluginFailed
277        assert!(result.is_err());
278
279        if let Err(PluginError::HandlerError(ctx)) = result {
280            // The error will be from our JSON output or raw stderr
281            // It should contain error info about the logger issue
282            assert_eq!(ctx.status, 500);
283            // The message should contain something about the error
284            assert!(!ctx.message.is_empty());
285        } else {
286            panic!("Expected PluginError::HandlerError, got: {:?}", result);
287        }
288    }
289
290    #[tokio::test]
291    async fn test_execute_typescript_handler_json_error() {
292        let temp_dir = tempdir().unwrap();
293        let ts_config = temp_dir.path().join("tsconfig.json");
294        let script_path = temp_dir
295            .path()
296            .join("test_execute_typescript_handler_json_error.ts");
297        let socket_path = temp_dir
298            .path()
299            .join("test_execute_typescript_handler_json_error.sock");
300
301        // This handler throws an error with code/status/details; our executor should capture
302        // and emit a normalized JSON error to stderr which the Rust side parses.
303        let content = r#"
304            export async function handler(_api: any, _params: any) {
305                const err: any = new Error('Validation failed');
306                err.code = 'VALIDATION_FAILED';
307                err.status = 422;
308                err.details = { field: 'email' };
309                throw err;
310            }
311        "#;
312        fs::write(&script_path, content).unwrap();
313        fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
314
315        let result = ScriptExecutor::execute_typescript(
316            "test-plugin-json-error".to_string(),
317            script_path.display().to_string(),
318            socket_path.display().to_string(),
319            "{}".to_string(),
320            None,
321        )
322        .await;
323
324        match result {
325            Err(PluginError::HandlerError(ctx)) => {
326                assert_eq!(ctx.message, "Validation failed");
327                assert_eq!(ctx.status, 422);
328                assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
329                let d = ctx.details.expect("details should be present");
330                assert_eq!(d["field"].as_str(), Some("email"));
331            }
332            other => panic!("Expected HandlerError, got: {:?}", other),
333        }
334    }
335    #[tokio::test]
336    async fn test_parse_logs_error() {
337        let temp_dir = tempdir().unwrap();
338        let ts_config = temp_dir.path().join("tsconfig.json");
339        let script_path = temp_dir.path().join("test_execute_typescript.ts");
340        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
341
342        let invalid_content = r#"
343            export async function handler(api: any, params: any) {
344                // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
345                process.stdout.write('invalid json line\n');
346                process.stdout.write('{"level":"log","message":"valid"}\n');
347                process.stdout.write('another invalid line\n');
348                return 'test';
349            }
350        "#;
351        fs::write(script_path.clone(), invalid_content).unwrap();
352        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
353
354        let result = ScriptExecutor::execute_typescript(
355            "test-plugin-1".to_string(),
356            script_path.display().to_string(),
357            socket_path.display().to_string(),
358            "{}".to_string(),
359            None,
360        )
361        .await;
362
363        assert!(result.is_err());
364        assert!(result
365            .err()
366            .unwrap()
367            .to_string()
368            .contains("Failed to parse log"));
369    }
370}