Skip to main content

netget/scripting/
executor.rs

1//! Script execution engine
2//!
3//! See `src/scripting/CLAUDE.md` for the execution model, the input/output
4//! contract, and — importantly — the trust boundary: scripts are **not**
5//! sandboxed and run with the full privileges of the netget process.
6//!
7//! # Async vs sync
8//!
9//! [`execute_script_async`] is the entry point that should be used from every
10//! async context (all protocol servers, event handlers, MCP). It is built on
11//! [`tokio::process`] so no OS thread is parked while a script runs, and it
12//! writes stdin concurrently with draining stdout/stderr so a script that
13//! produces output before consuming its input cannot deadlock.
14//!
15//! [`execute_script`] is a blocking convenience wrapper retained for
16//! synchronous callers (tests, tooling). It runs the async path on a dedicated
17//! thread with its own current-thread runtime, so it never nests runtimes, but
18//! it *does* block the calling thread and must not be used from async code.
19
20use super::process_io::{
21    read_bounded, ProcessGroup, ScriptDirectory, MAX_STDERR_BYTES, MAX_STDOUT_BYTES,
22};
23use super::types::{
24    parse_script_response, ScriptConfig, ScriptInput, ScriptLanguage, ScriptResponse,
25};
26use anyhow::{Context as AnyhowContext, Result};
27use std::process::Stdio;
28use std::time::Duration;
29use tokio::io::AsyncWriteExt;
30use tokio::process::{Child, Command};
31use tracing::{debug, error, trace, warn};
32
33/// Default timeout for script execution (30 seconds)
34///
35/// Scripts must complete within this time limit or they will be terminated.
36/// This value is also communicated to the LLM in prompts.
37pub const SCRIPT_TIMEOUT_SECS: u64 = 30;
38
39/// Default wall-clock budget for a single script invocation.
40///
41/// The budget covers the *entire* interaction: spawning the interpreter,
42/// writing the event JSON to stdin, draining stdout/stderr, and the child
43/// exiting. There is no un-timed phase.
44pub const DEFAULT_SCRIPT_TIMEOUT: Duration = Duration::from_secs(SCRIPT_TIMEOUT_SECS);
45
46/// How long to wait for a killed child to actually be reaped before giving up.
47const KILL_REAP_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Execute a script with the given input (async, non-blocking).
50///
51/// This is the entry point for all async callers. It never parks an OS thread:
52/// the child process is spawned with [`tokio::process`] and awaited with
53/// [`tokio::time::timeout`].
54///
55/// # Arguments
56/// * `config` - Script configuration (language, source, etc.)
57/// * `input` - Structured input to send to the script
58///
59/// # Returns
60/// * `Ok(ScriptResponse)` - Parsed response from the script
61/// * `Err(_)` - If execution failed, timed out, or the response was invalid
62///
63/// # Errors
64/// This function will return an error if:
65/// - The script code cannot be loaded
66/// - The interpreter is not installed / not on `PATH`
67/// - The script execution exceeds the timeout (the child is killed and reaped)
68/// - The script returns invalid JSON
69/// - The script exits with non-zero status
70pub async fn execute_script_async(
71    config: &ScriptConfig,
72    input: &ScriptInput,
73) -> Result<ScriptResponse> {
74    execute_script_with_timeout_async(config, input, DEFAULT_SCRIPT_TIMEOUT).await
75}
76
77/// Execute a script with an explicit timeout (async, non-blocking).
78///
79/// Same as [`execute_script_async`] but with a caller-supplied wall-clock
80/// budget. Primarily useful for tests and for callers that need a tighter
81/// bound than [`DEFAULT_SCRIPT_TIMEOUT`].
82pub async fn execute_script_with_timeout_async(
83    config: &ScriptConfig,
84    input: &ScriptInput,
85    timeout: Duration,
86) -> Result<ScriptResponse> {
87    tokio::time::timeout(timeout, execute_script_inner(config, input, timeout))
88        .await
89        .map_err(|_| {
90            anyhow::anyhow!(
91                "script execution timed out after {:?} (process killed)",
92                timeout
93            )
94        })?
95}
96
97async fn execute_script_inner(
98    config: &ScriptConfig,
99    input: &ScriptInput,
100    timeout: Duration,
101) -> Result<ScriptResponse> {
102    // Source loading and Go staging are inside the outer wall-clock budget.
103    let code = config
104        .source
105        .get_code_async()
106        .await
107        .context("Failed to load script code")?;
108
109    // Serialize input to JSON (pretty-printed for logs)
110    let input_json = serde_json::to_string(&input).context("Failed to serialize input to JSON")?;
111
112    debug!(
113        "Executing {} script for context '{}' (timeout: {}s, input: {} bytes)",
114        config.language.as_str(),
115        input.event_type_id,
116        timeout.as_secs(),
117        input_json.len()
118    );
119
120    if tracing::enabled!(tracing::Level::TRACE) {
121        let input_json_pretty =
122            serde_json::to_string_pretty(&input).unwrap_or_else(|_| input_json.clone());
123        trace!("─────────────────────────────────────────────");
124        trace!("SCRIPT EXECUTION START");
125        trace!("Language: {}", config.language.as_str());
126        trace!("Context: {}", input.event_type_id);
127        trace!("Handles: {:?}", config.handles_contexts);
128        trace!("");
129        trace!("Script code:");
130        trace!("{}", code);
131        trace!("");
132        trace!("Script input (JSON):");
133        trace!("{}", input_json_pretty);
134        trace!("─────────────────────────────────────────────");
135    }
136
137    // Execute the script based on language
138    let (output, stderr) = match config.language {
139        ScriptLanguage::Python => execute_python(&code, &input_json, timeout).await?,
140        ScriptLanguage::JavaScript => execute_javascript(&code, &input_json, timeout).await?,
141        ScriptLanguage::Go => execute_go(&code, &input_json, timeout).await?,
142        ScriptLanguage::Perl => execute_perl(&code, &input_json, timeout).await?,
143    };
144
145    if tracing::enabled!(tracing::Level::TRACE) {
146        trace!("─────────────────────────────────────────────");
147        trace!("SCRIPT EXECUTION COMPLETE");
148        trace!("");
149        trace!("Script stdout:");
150        trace!("{}", output);
151        if !stderr.is_empty() {
152            trace!("");
153            trace!("Script stderr:");
154            trace!("{}", stderr);
155        }
156        trace!("─────────────────────────────────────────────");
157    }
158
159    // Parse the response
160    let response = parse_script_response(&output).with_context(|| {
161        format!(
162            "Failed to parse script response as JSON object with actions array. Output was: {}",
163            output
164        )
165    })?;
166
167    debug!(
168        "Script executed successfully: {} actions",
169        response.actions.len()
170    );
171
172    Ok(response)
173}
174
175/// Execute a script with the given input (blocking).
176///
177/// **Do not call this from async code** — it blocks the calling thread for the
178/// duration of the script. Use [`execute_script_async`] instead.
179///
180/// The work is performed on a dedicated thread running its own current-thread
181/// tokio runtime, so this is safe to call even when a runtime already exists on
182/// the current thread (it will not panic with "cannot start a runtime from
183/// within a runtime"), but it will still block that thread.
184pub fn execute_script(config: &ScriptConfig, input: &ScriptInput) -> Result<ScriptResponse> {
185    execute_script_blocking_with_timeout(config, input, DEFAULT_SCRIPT_TIMEOUT)
186}
187
188/// Blocking variant of [`execute_script_with_timeout_async`].
189///
190/// See [`execute_script`] for the caveats.
191pub fn execute_script_blocking_with_timeout(
192    config: &ScriptConfig,
193    input: &ScriptInput,
194    timeout: Duration,
195) -> Result<ScriptResponse> {
196    let config = config.clone();
197    let input = input.clone();
198
199    std::thread::Builder::new()
200        .name("netget-script-sync".to_string())
201        .spawn(move || -> Result<ScriptResponse> {
202            let runtime = tokio::runtime::Builder::new_current_thread()
203                .enable_all()
204                .build()
205                .context("Failed to build runtime for blocking script execution")?;
206            runtime.block_on(execute_script_with_timeout_async(&config, &input, timeout))
207        })
208        .context("Failed to spawn thread for blocking script execution")?
209        .join()
210        .map_err(|_| anyhow::anyhow!("Script execution thread panicked"))?
211}
212
213/// Execute Python script with stdin/stdout
214///
215/// Returns (stdout, stderr) tuple
216async fn execute_python(
217    code: &str,
218    input_json: &str,
219    timeout: Duration,
220) -> Result<(String, String)> {
221    execute_with_command(
222        ScriptLanguage::Python,
223        "python3",
224        &["-c"],
225        Some(code),
226        input_json,
227        timeout,
228    )
229    .await
230}
231
232/// Execute JavaScript (Node.js) script with stdin/stdout
233///
234/// Returns (stdout, stderr) tuple
235async fn execute_javascript(
236    code: &str,
237    input_json: &str,
238    timeout: Duration,
239) -> Result<(String, String)> {
240    // For Node.js, we need to read from stdin in the script
241    // Wrap the code to automatically read stdin
242    let wrapped_code = format!(
243        r#"
244const fs = require('fs');
245const inputJson = fs.readFileSync(0, 'utf-8');
246const input = JSON.parse(inputJson);
247
248// User's script begins here
249(function() {{
250{}
251}})();
252"#,
253        code
254    );
255
256    execute_with_command(
257        ScriptLanguage::JavaScript,
258        "node",
259        &["-e"],
260        Some(&wrapped_code),
261        input_json,
262        timeout,
263    )
264    .await
265}
266
267/// Execute Perl script with stdin/stdout
268///
269/// Returns (stdout, stderr) tuple
270async fn execute_perl(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
271    execute_with_command(
272        ScriptLanguage::Perl,
273        "perl",
274        &["-e"],
275        Some(code),
276        input_json,
277        timeout,
278    )
279    .await
280}
281
282/// Execute Go script with stdin/stdout
283///
284/// Go requires a file, so we create a temporary .go file and use `go run`
285/// Returns (stdout, stderr) tuple
286async fn execute_go(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
287    let directory = tokio::task::spawn_blocking(ScriptDirectory::new)
288        .await
289        .context("failed to create Go staging worker")??;
290    let script_path = directory.script_path();
291
292    // Wrap the user's code in a complete Go program
293    let wrapped_code = format!(
294        r#"package main
295
296import (
297    "encoding/json"
298    "fmt"
299    "io"
300    "os"
301)
302
303func main() {{
304    // Read JSON input from stdin
305    inputBytes, err := io.ReadAll(os.Stdin)
306    if err != nil {{
307        fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
308        os.Exit(1)
309    }}
310
311    var input map[string]interface{{}}
312    if err := json.Unmarshal(inputBytes, &input); err != nil {{
313        fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
314        os.Exit(1)
315    }}
316
317    // User's script begins here
318    _ = input // Make input available to user code
319    {{
320{}
321    }}
322}}
323"#,
324        code
325    );
326
327    // Write the script to the temp file
328    tokio::fs::write(&script_path, wrapped_code.as_bytes())
329        .await
330        .with_context(|| format!("Failed to write Go script to {:?}", script_path))?;
331
332    // Execute with `go run <path>`
333    let script_path_str = script_path.to_string_lossy().to_string();
334    let result = execute_with_command(
335        ScriptLanguage::Go,
336        "go",
337        &["run", &script_path_str],
338        None,
339        input_json,
340        timeout,
341    )
342    .await;
343
344    // The private directory is removed on completion and future cancellation.
345    drop(directory);
346
347    result
348}
349
350/// Generic async command executor.
351///
352/// Spawns `command args... [code]`, then concurrently:
353/// - writes `input_json` to the child's stdin and closes it,
354/// - drains the child's stdout,
355/// - drains the child's stderr,
356/// - waits for the child to exit.
357///
358/// The whole interaction runs under a single [`tokio::time::timeout`], so there
359/// is no phase (in particular, no stdin write) that can hang unbounded. On
360/// timeout the child is killed *and reaped* so it cannot linger as a zombie.
361///
362/// Returns (stdout, stderr) tuple.
363async fn execute_with_command(
364    language: ScriptLanguage,
365    command: &str,
366    args: &[&str],
367    code: Option<&str>,
368    input_json: &str,
369    timeout: Duration,
370) -> Result<(String, String)> {
371    let mut builder = Command::new(command);
372    builder.args(args);
373    if let Some(code) = code {
374        builder.arg(code);
375    }
376    builder
377        .stdin(Stdio::piped())
378        .stdout(Stdio::piped())
379        .stderr(Stdio::piped())
380        // If this future is dropped (e.g. the connection task is cancelled),
381        // make sure we do not leak the interpreter process.
382        .kill_on_drop(true);
383
384    ProcessGroup::configure(&mut builder);
385    let mut child = builder
386        .spawn()
387        .map_err(|e| spawn_error(language, command, e))?;
388    let group = ProcessGroup::new(&child)?;
389
390    let mut stdin = child.stdin.take();
391    let stdout = child
392        .stdout
393        .take()
394        .context("Failed to capture script stdout")?;
395    let stderr = child
396        .stderr
397        .take()
398        .context("Failed to capture script stderr")?;
399
400    let input_bytes = input_json.as_bytes().to_vec();
401
402    // Write stdin concurrently with draining stdout/stderr. Doing the write
403    // first (as the old synchronous implementation did) deadlocks whenever the
404    // payload exceeds the pipe buffer while the child is itself blocked writing
405    // output.
406    let write_fut = async move {
407        if let Some(mut handle) = stdin.take() {
408            handle.write_all(&input_bytes).await?;
409            handle.flush().await?;
410            // Dropping the handle closes the pipe, giving the script EOF.
411            drop(handle);
412        }
413        Ok::<(), std::io::Error>(())
414    };
415    let stdout_fut = read_bounded(stdout, MAX_STDOUT_BYTES);
416    let stderr_fut = read_bounded(stderr, MAX_STDERR_BYTES);
417    // BrokenPipe is benign; every other failure cancels the other collectors
418    // immediately, so a noisy child cannot hide behind an unfinished wait().
419    let write_fut = async {
420        match write_fut.await {
421            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
422            result => result,
423        }
424    };
425    let interaction = async { tokio::try_join!(write_fut, stdout_fut, stderr_fut, child.wait()) };
426    let joined = tokio::time::timeout(timeout, interaction).await;
427    let (_, stdout_bytes, stderr_bytes, status) = match joined {
428        Ok(Ok(results)) => results,
429        failed => {
430            group.kill();
431            let _ = child.start_kill();
432            reap(&mut child).await;
433            return match failed {
434                Ok(Err(error)) => Err(error).context("script input/output failed"),
435                Err(_) => Err(anyhow::anyhow!(
436                    "{} script execution timed out after {:?} (process killed)",
437                    language.as_str(),
438                    timeout
439                )),
440                Ok(Ok(_)) => unreachable!(),
441            };
442        }
443    };
444    drop(group);
445
446    let stdout = String::from_utf8(stdout_bytes).context("Script stdout is not valid UTF-8")?;
447    let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
448
449    // Check exit status
450    if !status.success() {
451        error!(
452            "{} script execution failed with exit code {:?}",
453            language.as_str(),
454            status.code()
455        );
456        error!("Script stderr: {}", stderr);
457        anyhow::bail!(
458            "{} script exited with non-zero status. stderr: {}",
459            language.as_str(),
460            stderr
461        );
462    }
463
464    // Log warnings if stderr is present
465    if !stderr.is_empty() {
466        warn!("Script produced stderr output (but succeeded): {}", stderr);
467    }
468
469    Ok((stdout.trim().to_string(), stderr))
470}
471
472/// Wait for a killed child to be reaped, bounded so a wedged process cannot
473/// stall the caller forever.
474async fn reap(child: &mut Child) {
475    match tokio::time::timeout(KILL_REAP_TIMEOUT, child.wait()).await {
476        Ok(Ok(status)) => {
477            debug!("Killed script process reaped (status: {:?})", status);
478        }
479        Ok(Err(e)) => {
480            warn!("Failed to reap killed script process: {}", e);
481        }
482        Err(_) => {
483            warn!(
484                "Killed script process did not exit within {:?}; it may linger",
485                KILL_REAP_TIMEOUT
486            );
487        }
488    }
489}
490
491/// The executable each language needs, and how to get it.
492///
493/// Used to turn an opaque "failed to spawn" into an actionable message.
494fn interpreter_requirement(language: ScriptLanguage) -> (&'static str, &'static str) {
495    match language {
496        ScriptLanguage::Python => (
497            "Python 3",
498            "install Python 3 (e.g. `brew install python3` or `apt install python3`)",
499        ),
500        ScriptLanguage::JavaScript => (
501            "Node.js",
502            "install Node.js (e.g. `brew install node` or `apt install nodejs`)",
503        ),
504        ScriptLanguage::Go => (
505            "the Go toolchain",
506            "install Go (e.g. `brew install go` or see https://go.dev/dl/)",
507        ),
508        ScriptLanguage::Perl => (
509            "Perl",
510            "install Perl (e.g. `brew install perl` or `apt install perl`)",
511        ),
512    }
513}
514
515/// Build an actionable error for a failed interpreter spawn.
516fn spawn_error(language: ScriptLanguage, command: &str, err: std::io::Error) -> anyhow::Error {
517    let (requirement, remedy) = interpreter_requirement(language);
518
519    if err.kind() == std::io::ErrorKind::NotFound {
520        error!(
521            "Cannot run {} script: `{}` not found on PATH",
522            language.as_str(),
523            command
524        );
525        anyhow::anyhow!(
526            "Interpreter `{command}` was not found on PATH. \
527             The '{lang}' script handler requires {requirement}. \
528             To fix: {remedy}, or switch this event handler to a language whose \
529             interpreter is installed, or to an `llm`/`static` handler.",
530            command = command,
531            lang = language.as_str(),
532            requirement = requirement,
533            remedy = remedy,
534        )
535    } else {
536        anyhow::anyhow!(
537            "Failed to start interpreter `{command}` for the '{lang}' script handler \
538             (requires {requirement}): {err}",
539            command = command,
540            lang = language.as_str(),
541            requirement = requirement,
542            err = err,
543        )
544    }
545}