Skip to main content

cognee_http_server/
notebook_runner.rs

1//! Notebook cell execution backend for
2//! `POST /api/v1/notebooks/{notebook_id}/{cell_id}/run`.
3//!
4//! The Python reference at
5//! `/tmp/cognee-python/cognee/modules/notebooks/operations/run_in_local_sandbox.py`
6//! uses in-process `exec()` and captures `print()` calls into a list. Running
7//! arbitrary user code in-process in Rust would be a remote-code-execution
8//! gun, so this module instead spawns an isolated `python3` subprocess and
9//! captures stdout/stderr, then enforces wall-clock, memory, and output-size
10//! caps.
11//!
12//! Trait shape: a single async [`NotebookRunner::run_cell`] returning a
13//! [`RunCellOutcome`].  Production wiring uses [`SubprocessRunner`]; tests
14//! that don't want to spawn Python plug in a mock runner.
15
16use std::process::Stdio;
17use std::sync::Arc;
18use std::time::Duration;
19
20use async_trait::async_trait;
21use thiserror::Error;
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24
25// ─── Public types ────────────────────────────────────────────────────────────
26
27/// Wire-shape outcome of running a single cell.
28///
29/// `print_output` corresponds to Python's `printOutput` list (each top-level
30/// `print()` call appends one entry). The Rust subprocess implementation
31/// emits one entry per line of captured stdout — close enough for the
32/// `RunCodeOutcomeDTO` wire shape (each line is JSON-encoded as a string).
33///
34/// `error` is `None` on success and `Some(traceback_or_runner_message)` on
35/// failure. Mirrors Python's `(printOutput, error)` tuple.
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub struct RunCellOutcome {
38    pub print_output: Vec<String>,
39    pub error: Option<String>,
40}
41
42/// Errors that can surface from a [`NotebookRunner`] implementation.
43///
44/// These are *infrastructure* errors — they map to HTTP 500. Cells that
45/// "successfully ran but raised an exception" are captured into
46/// [`RunCellOutcome::error`] instead, mirroring Python's behavior of
47/// returning HTTP 200 with `{"error": "<traceback>"}`.
48#[derive(Debug, Error)]
49pub enum RunnerError {
50    /// The `python3` binary was not found on PATH (or the configured path).
51    #[error("python interpreter not found: {0}")]
52    InterpreterNotFound(String),
53
54    /// Failed to spawn the subprocess (other than not-found).
55    #[error("failed to spawn interpreter: {0}")]
56    Spawn(String),
57
58    /// Failed to communicate with the subprocess (stdin/stdout/stderr).
59    #[error("subprocess I/O failed: {0}")]
60    Io(String),
61}
62
63// ─── Runner trait ────────────────────────────────────────────────────────────
64
65/// Abstract code-execution backend.
66///
67/// Implementors MUST be `Send + Sync` so they can live behind `Arc<dyn ...>`
68/// in [`crate::components::ComponentHandles`].
69#[async_trait]
70pub trait NotebookRunner: Send + Sync + 'static {
71    /// Execute `code` and return a [`RunCellOutcome`].
72    ///
73    /// `timeout` is a wall-clock cap; the implementation MUST kill the
74    /// subprocess (or abort the work) when the timer fires and return an
75    /// `Outcome { print_output: <whatever was captured so far>, error:
76    /// Some("Execution timed out...") }`.
77    async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError>;
78}
79
80// ─── SubprocessRunner ────────────────────────────────────────────────────────
81
82/// Production runner: spawns `python3 -c '<wrapper>'` and feeds the user's
83/// code via stdin.
84///
85/// **Security knobs:**
86/// - User code is NEVER concatenated into a shell command. The user code is
87///   fed via stdin to a tiny stdin-reading wrapper passed as `-c`.
88/// - The subprocess inherits only a minimal environment (cleared `PATH`,
89///   scoped `TMPDIR` if available).
90/// - stdout/stderr capture is hard-capped at [`Self::stdout_cap_bytes`] /
91///   [`Self::stderr_cap_bytes`] to prevent a `print('A'*10**9)` from OOMing
92///   the server.
93/// - On Unix, the child inherits a soft `RLIMIT_AS` (address space) and
94///   `RLIMIT_CPU` ceiling enforced via `pre_exec`.
95#[derive(Debug, Clone)]
96pub struct SubprocessRunner {
97    /// Path or name of the python interpreter (default `"python3"`).
98    pub python_path: String,
99    /// Maximum captured stdout, in bytes. Default 64 KiB.
100    pub stdout_cap_bytes: usize,
101    /// Maximum captured stderr, in bytes. Default 16 KiB.
102    pub stderr_cap_bytes: usize,
103    /// Memory cap (RLIMIT_AS, Unix only). Default 512 MiB. `None` disables.
104    pub memory_cap_bytes: Option<u64>,
105    /// CPU-time cap (RLIMIT_CPU, Unix only) in seconds. Default 60s.
106    /// Separate from wall-clock timeout, which is enforced by the caller.
107    pub cpu_seconds_cap: Option<u64>,
108}
109
110impl Default for SubprocessRunner {
111    fn default() -> Self {
112        Self {
113            python_path: "python3".to_owned(),
114            stdout_cap_bytes: 64 * 1024,
115            stderr_cap_bytes: 16 * 1024,
116            memory_cap_bytes: Some(512 * 1024 * 1024),
117            cpu_seconds_cap: Some(60),
118        }
119    }
120}
121
122impl SubprocessRunner {
123    /// Construct a runner with all defaults.
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Wrap this runner in an `Arc<dyn NotebookRunner>` for storage in
129    /// [`crate::components::ComponentHandles`].
130    pub fn into_dyn(self) -> Arc<dyn NotebookRunner> {
131        Arc::new(self) as Arc<dyn NotebookRunner>
132    }
133}
134
135/// A tiny Python wrapper that reads user code from stdin, executes it via
136/// `exec`, and captures `print()` arguments to stdout (one repr per line).
137/// Uncaught exceptions go to stderr as a traceback.
138///
139/// The wrapper itself is a static `-c` argument (no user input); the user's
140/// code reaches the interpreter only via stdin.
141const PYTHON_WRAPPER: &str = r#"
142import sys, traceback
143src = sys.stdin.read()
144print_output = []
145def _custom_print(*args, **kwargs):
146    sep = kwargs.get('sep', ' ')
147    print_output.append(sep.join(str(a) for a in args))
148env = {'print': _custom_print, '__name__': '__cognee_cell__'}
149try:
150    exec(compile(src, '<cell>', 'exec'), env)
151except SystemExit:
152    raise
153except BaseException:
154    sys.stderr.write(traceback.format_exc())
155for line in print_output:
156    sys.__stdout__.write(line)
157    sys.__stdout__.write('\n')
158sys.__stdout__.flush()
159"#;
160
161#[async_trait]
162impl NotebookRunner for SubprocessRunner {
163    async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError> {
164        let mut cmd = Command::new(&self.python_path);
165        cmd.arg("-I") // isolated mode: ignore PYTHON* env vars and user site-packages
166            .arg("-c")
167            .arg(PYTHON_WRAPPER)
168            .stdin(Stdio::piped())
169            .stdout(Stdio::piped())
170            .stderr(Stdio::piped())
171            .kill_on_drop(true)
172            // Wipe inherited env. Set only what the child needs.
173            .env_clear()
174            .env("PATH", "/usr/bin:/bin")
175            .env("LANG", "C.UTF-8");
176
177        if let Ok(tmpdir) = std::env::var("TMPDIR") {
178            cmd.env("TMPDIR", tmpdir);
179        }
180
181        #[cfg(unix)]
182        {
183            let mem_cap = self.memory_cap_bytes;
184            let cpu_cap = self.cpu_seconds_cap;
185            unsafe {
186                cmd.pre_exec(move || {
187                    if let Some(mem) = mem_cap {
188                        let lim = libc::rlimit {
189                            rlim_cur: mem as libc::rlim_t,
190                            rlim_max: mem as libc::rlim_t,
191                        };
192                        // Best-effort; ignore failures.
193                        let _ = libc::setrlimit(libc::RLIMIT_AS, &lim);
194                    }
195                    if let Some(cpu) = cpu_cap {
196                        let lim = libc::rlimit {
197                            rlim_cur: cpu as libc::rlim_t,
198                            rlim_max: cpu as libc::rlim_t,
199                        };
200                        let _ = libc::setrlimit(libc::RLIMIT_CPU, &lim);
201                    }
202                    Ok(())
203                });
204            }
205        }
206
207        let mut child = match cmd.spawn() {
208            Ok(c) => c,
209            Err(e) => {
210                if e.kind() == std::io::ErrorKind::NotFound {
211                    return Err(RunnerError::InterpreterNotFound(self.python_path.clone()));
212                }
213                return Err(RunnerError::Spawn(e.to_string()));
214            }
215        };
216
217        // Feed the user's code via stdin.
218        let mut stdin = child
219            .stdin
220            .take()
221            .ok_or_else(|| RunnerError::Io("child stdin missing".to_owned()))?;
222        let code_owned = code.to_owned();
223        let write_handle = tokio::spawn(async move {
224            let res = stdin.write_all(code_owned.as_bytes()).await;
225            // Drop stdin so the child's sys.stdin.read() unblocks.
226            drop(stdin);
227            res
228        });
229
230        // Run with a wall-clock timeout. On timeout, kill the child.
231        let wait_result = tokio::time::timeout(timeout, child.wait_with_output()).await;
232
233        // Ensure stdin task is cleaned up; ignore its error (write may fail
234        // with BrokenPipe if the child exited early — that's fine).
235        let _ = write_handle.await;
236
237        let output = match wait_result {
238            Ok(Ok(output)) => output,
239            Ok(Err(e)) => return Err(RunnerError::Io(format!("wait_with_output: {e}"))),
240            Err(_) => {
241                // Timeout — `kill_on_drop` already armed; nothing more to do.
242                return Ok(RunCellOutcome {
243                    print_output: Vec::new(),
244                    error: Some(format!(
245                        "Cell execution timed out after {} ms",
246                        timeout.as_millis()
247                    )),
248                });
249            }
250        };
251
252        // Cap captured bytes.
253        let stdout_truncated = output.stdout.len() > self.stdout_cap_bytes;
254        let stderr_truncated = output.stderr.len() > self.stderr_cap_bytes;
255        let stdout_bytes = &output.stdout[..output.stdout.len().min(self.stdout_cap_bytes)];
256        let stderr_bytes = &output.stderr[..output.stderr.len().min(self.stderr_cap_bytes)];
257
258        let stdout = String::from_utf8_lossy(stdout_bytes).into_owned();
259        let mut stderr = String::from_utf8_lossy(stderr_bytes).into_owned();
260
261        if stdout_truncated {
262            stderr.push_str("\n[stdout truncated by server: exceeded cap]\n");
263        }
264        if stderr_truncated {
265            stderr.push_str("\n[stderr truncated by server: exceeded cap]\n");
266        }
267
268        let print_output: Vec<String> = stdout
269            .split('\n')
270            .filter(|s| !s.is_empty())
271            .map(str::to_owned)
272            .collect();
273
274        let error = if !output.status.success() && !stderr.is_empty() {
275            Some(stderr.trim_end().to_owned())
276        } else if !stderr.is_empty() {
277            // exec'd code wrote to stderr but exited cleanly — treat as error
278            // (matches Python's `traceback.format_exc()` which we route through
279            // stderr in our wrapper).
280            Some(stderr.trim_end().to_owned())
281        } else if !output.status.success() {
282            // No stderr but non-zero exit — synthesize a message.
283            Some(format!(
284                "Python interpreter exited with status {}",
285                output.status.code().unwrap_or(-1)
286            ))
287        } else {
288            None
289        };
290
291        Ok(RunCellOutcome {
292            print_output,
293            error,
294        })
295    }
296}
297
298// ─── Unit tests ──────────────────────────────────────────────────────────────
299
300#[cfg(test)]
301#[allow(
302    clippy::unwrap_used,
303    clippy::expect_used,
304    reason = "test code — panics are acceptable failures"
305)]
306mod tests {
307    use super::*;
308    use std::sync::Mutex;
309
310    /// Mock runner used to verify handler wiring without spawning Python.
311    /// Each call appends the (code, timeout) pair to `calls` and returns the
312    /// configured `outcome` (or `error`).
313    pub struct MockRunner {
314        pub calls: Mutex<Vec<(String, Duration)>>,
315        pub outcome: Mutex<Result<RunCellOutcome, RunnerErrorStub>>,
316    }
317
318    /// `RunnerError` is not Clone; use a stub for the mock that we map on use.
319    #[derive(Debug, Clone)]
320    pub enum RunnerErrorStub {
321        InterpreterNotFound(String),
322        Spawn(String),
323        Io(String),
324    }
325
326    impl From<&RunnerErrorStub> for RunnerError {
327        fn from(s: &RunnerErrorStub) -> Self {
328            match s {
329                RunnerErrorStub::InterpreterNotFound(s) => Self::InterpreterNotFound(s.clone()),
330                RunnerErrorStub::Spawn(s) => Self::Spawn(s.clone()),
331                RunnerErrorStub::Io(s) => Self::Io(s.clone()),
332            }
333        }
334    }
335
336    impl MockRunner {
337        pub fn with_outcome(outcome: RunCellOutcome) -> Self {
338            Self {
339                calls: Mutex::new(Vec::new()),
340                outcome: Mutex::new(Ok(outcome)),
341            }
342        }
343
344        pub fn with_error(err: RunnerErrorStub) -> Self {
345            Self {
346                calls: Mutex::new(Vec::new()),
347                outcome: Mutex::new(Err(err)),
348            }
349        }
350    }
351
352    #[async_trait]
353    impl NotebookRunner for MockRunner {
354        async fn run_cell(
355            &self,
356            code: &str,
357            timeout: Duration,
358        ) -> Result<RunCellOutcome, RunnerError> {
359            self.calls
360                .lock()
361                .expect("mock calls lock") // lock poison is unrecoverable
362                .push((code.to_owned(), timeout));
363            match &*self.outcome.lock().expect("mock outcome lock") {
364                // lock poison is unrecoverable
365                Ok(o) => Ok(o.clone()),
366                Err(e) => Err(e.into()),
367            }
368        }
369    }
370
371    // ── Mock unit tests: verify the trait contract without spawning python. ──
372
373    #[tokio::test]
374    async fn mock_runner_happy_path() {
375        let mock = MockRunner::with_outcome(RunCellOutcome {
376            print_output: vec!["2".to_owned()],
377            error: None,
378        });
379        let outcome = mock
380            .run_cell("print(1+1)", Duration::from_secs(5))
381            .await
382            .expect("ok");
383        assert_eq!(outcome.print_output, vec!["2".to_owned()]);
384        assert_eq!(outcome.error, None);
385
386        let calls = mock.calls.lock().expect("calls");
387        assert_eq!(calls.len(), 1);
388        assert_eq!(calls[0].0, "print(1+1)");
389        assert_eq!(calls[0].1, Duration::from_secs(5));
390    }
391
392    #[tokio::test]
393    async fn mock_runner_simulated_timeout_outcome() {
394        // A timeout in the production runner is encoded as a successful trait
395        // call returning `error = Some("...timed out...")`. Verify the mock
396        // can model that exact contract.
397        let mock = MockRunner::with_outcome(RunCellOutcome {
398            print_output: Vec::new(),
399            error: Some("Cell execution timed out after 1000 ms".to_owned()),
400        });
401        let outcome = mock
402            .run_cell("import time; time.sleep(60)", Duration::from_millis(1000))
403            .await
404            .expect("ok");
405        assert!(outcome.print_output.is_empty());
406        assert!(
407            outcome
408                .error
409                .as_deref()
410                .unwrap_or_default()
411                .contains("timed out")
412        );
413    }
414
415    #[tokio::test]
416    async fn mock_runner_overflow_outcome() {
417        // Verify the "stdout truncated" contract through the mock.
418        let mut err = String::from("[stdout truncated by server: exceeded cap]");
419        let mock = MockRunner::with_outcome(RunCellOutcome {
420            print_output: vec!["A".repeat(64).to_string()],
421            error: Some(std::mem::take(&mut err)),
422        });
423        let outcome = mock
424            .run_cell("print('A'*10**9)", Duration::from_secs(5))
425            .await
426            .expect("ok");
427        assert!(outcome.error.unwrap().contains("truncated"));
428    }
429
430    #[tokio::test]
431    async fn mock_runner_error_path() {
432        let mock =
433            MockRunner::with_error(RunnerErrorStub::InterpreterNotFound("python3".to_owned()));
434        let err = mock
435            .run_cell("print(1)", Duration::from_secs(5))
436            .await
437            .expect_err("should error");
438        match err {
439            RunnerError::InterpreterNotFound(p) => assert_eq!(p, "python3"),
440            other => panic!("unexpected variant: {other:?}"),
441        }
442    }
443
444    // ── SubprocessRunner builder smoke test (no spawn). ──
445
446    #[test]
447    fn subprocess_runner_defaults() {
448        let r = SubprocessRunner::new();
449        assert_eq!(r.python_path, "python3");
450        assert_eq!(r.stdout_cap_bytes, 64 * 1024);
451        assert_eq!(r.stderr_cap_bytes, 16 * 1024);
452        assert_eq!(r.memory_cap_bytes, Some(512 * 1024 * 1024));
453        assert_eq!(r.cpu_seconds_cap, Some(60));
454    }
455}