use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crate::entities::attachment::format_bytes;
use crate::entities::chat_file::{ChatFile, is_text_like, sanitize_name, sniff_image};
use crate::entities::profile::ToolId;
use crate::features::chat_files::{self, Stored};
use crate::features::chat_inputs::{MAX_INPUT_BYTES, MAX_INPUT_FILES, over_input_cap};
use crate::shared::config::{MAX_TOOL_RESULT_IMAGES, PythonMode};
use crate::shared::i18n::Locale;
use crate::shared::sandbox::{
OutputFile, OutputLimits, SandboxAvailability, SandboxInput, SandboxJob, SandboxOutput,
SandboxRunner, SkipReason, SkippedOutput,
};
use super::{ChatEffect, Tool, ToolContext, ToolImage, ToolOutcome};
const LOCAL_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_OUTPUT_CHARS: usize = 8000;
const TEXT_HEAD_BYTES: usize = 1024;
const MAX_SKIPPED_LINES: usize = 20;
pub struct PythonExec {
mode: PythonMode,
sandbox: Arc<dyn SandboxRunner>,
net: bool,
net_private: bool,
wasm_timeout: Duration,
images: bool,
}
impl PythonExec {
pub fn new(
mode: PythonMode,
sandbox: Arc<dyn SandboxRunner>,
net: bool,
wasm_timeout: Duration,
) -> Self {
Self {
mode,
sandbox,
net,
net_private: false,
wasm_timeout,
images: true,
}
}
pub fn with_private_network(mut self, allow: bool) -> Self {
self.net_private = allow;
self
}
fn timeout(&self) -> Duration {
match self.mode {
PythonMode::Local => LOCAL_TIMEOUT,
PythonMode::Wasmer => self.wasm_timeout,
}
}
pub fn with_images(mut self, images: bool) -> Self {
self.images = images;
self
}
async fn run_job(&self, code: &str, handles: &[String], ctx: &ToolContext) -> ToolOutcome {
let loc = ctx.loc;
if let SandboxAvailability::Missing(why) = self.sandbox.availability(loc) {
return ToolOutcome::text(
loc.tf("tool.python_exec.err.sandbox_missing", &[("why", &why)]),
);
}
let inputs = match self.stage(handles, ctx) {
Ok(inputs) => inputs,
Err(refusal) => return ToolOutcome::text(refusal),
};
let out = match self
.sandbox
.run(
SandboxJob::new(code, self.net, self.timeout()).with_inputs(&inputs),
loc,
)
.await
{
Ok(out) => out,
Err(e) => {
return ToolOutcome::text(
loc.tf("tool.python_exec.err.sandbox", &[("e", &e.to_string())]),
);
}
};
let kept = self.keep_outputs(ctx, &out);
let result = if out.timed_out {
let timeout = loc.tf(
"tool.python_exec.err.timeout",
&[("secs", &self.timeout().as_secs().to_string())],
);
match kept.section {
Some(section) => format!("{timeout}\n\n{section}"),
None => timeout,
}
} else {
let console = || {
format_output_parts(
&out.stdout,
&out.stderr,
out.exit_code == Some(0),
out.exit_code,
loc,
)
};
let quiet = out.stdout.trim().is_empty()
&& out.stderr.trim().is_empty()
&& out.exit_code == Some(0);
match kept.section {
Some(section) if quiet => section,
Some(section) => format!("{}\n\n{section}", console()),
None => console(),
}
};
let result = if out.net_refused {
format!("{}\n\n{}", loc.t("tool.python_exec.result.net_off"), result)
} else {
result
};
ToolOutcome::with_effects(result, kept.effects).with_images(kept.images)
}
fn stage(&self, handles: &[String], ctx: &ToolContext) -> Result<Vec<SandboxInput>, String> {
use crate::entities::attachment::Resolved;
use crate::features::chat_inputs;
if handles.is_empty() {
return Ok(Vec::new());
}
let loc = ctx.loc;
let items = &ctx.inputs;
let mut taken: Vec<usize> = Vec::new();
for handle in handles {
let at = match chat_inputs::resolve(items, handle) {
Resolved::One(at) => at,
Resolved::Shared(hits) => {
let candidates = hits
.iter()
.map(|&i| {
let item = &items[i];
format!("\n• #{} {} — {}", item.handle, item.name, item.source)
})
.collect::<String>();
return Err(loc.tf(
"tool.python_exec.err.files_shared",
&[("handle", handle.trim()), ("candidates", &candidates)],
));
}
Resolved::Nothing => return Err(unknown_handle(loc, handle, items)),
};
if !taken.contains(&at) {
taken.push(at); }
}
let bytes: u64 = taken.iter().map(|&at| items[at].bytes).sum();
if over_input_cap(taken.len(), bytes) {
return Err(loc.tf(
"tool.python_exec.err.files_over_cap",
&[
("count", &taken.len().to_string()),
("size", &format_bytes(bytes as usize)),
("max_files", &MAX_INPUT_FILES.to_string()),
("max_size", &format_bytes(MAX_INPUT_BYTES as usize)),
("out", self.mode.dirs().1),
],
));
}
taken
.iter()
.map(|&at| self.input_for(&items[at], ctx))
.collect()
}
fn input_for(
&self,
item: &crate::features::chat_inputs::ChatInput,
ctx: &ToolContext,
) -> Result<SandboxInput, String> {
let loc = ctx.loc;
if let Some(name) = &item.file {
let Some(dir) = &ctx.files_dir else {
return Err(loc.tf(
"tool.python_exec.err.files_no_folder",
&[("in", self.mode.dirs().0)],
));
};
let path = dir.join(name);
if !path.is_file() {
return Err(loc.tf("tool.python_exec.err.files_missing", &[("name", name)]));
}
return Ok(SandboxInput::path(item.staged.clone(), path));
}
if let Some(at) = item.attachment {
let text = ctx.attachments[at].text.clone();
return Ok(SandboxInput::bytes(item.staged.clone(), text.into_bytes()));
}
let at = item
.image
.expect("an item is a file, an attachment or an image");
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&ctx.images[at].data)
.map_err(|_| {
loc.tf(
"tool.python_exec.err.files_missing",
&[("name", &item.name)],
)
})?;
Ok(SandboxInput::bytes(item.staged.clone(), bytes))
}
fn keep_outputs(&self, ctx: &ToolContext, out: &SandboxOutput) -> Kept {
let loc = ctx.loc;
let mut kept = Kept::default();
if out.files.is_empty() && out.skipped.is_empty() {
return kept;
}
let mut lines = Vec::new();
match &ctx.files_dir {
Some(dir) => {
if !out.files.is_empty() {
lines.push(loc.tf(
"tool.python_exec.files.saved_in",
&[("dir", &dir.display().to_string())],
));
}
let mut listed = ctx.files.to_vec();
for file in &out.files {
lines.extend(self.keep_one(loc, dir, &mut listed, file, &mut kept));
}
}
None => lines.push(loc.tf(
"tool.python_exec.files.no_folder",
&[("out", self.mode.dirs().1)],
)),
}
let out_dir = self.mode.dirs().1;
lines.extend(
out.skipped
.iter()
.take(MAX_SKIPPED_LINES)
.map(|s| skipped_line(loc, s, out_dir)),
);
if let Some(rest) = out
.skipped
.len()
.checked_sub(MAX_SKIPPED_LINES)
.filter(|n| *n > 0)
{
lines.push(loc.tf(
"tool.python_exec.files.more_skipped",
&[("n", &rest.to_string())],
));
}
kept.section = Some(format!("files:\n{}", lines.join("\n")));
kept
}
fn keep_one(
&self,
loc: &Locale,
dir: &std::path::Path,
listed: &mut Vec<ChatFile>,
file: &OutputFile,
kept: &mut Kept,
) -> Vec<String> {
let Some(name) = sanitize_name(&file.name) else {
return vec![skipped_with(
loc,
&file.name,
loc.t("tool.python_exec.files.reason.bad_name"),
)];
};
let stored = match chat_files::store(dir, listed, &name, &file.bytes) {
Ok(Stored::New(stored)) => stored,
Ok(Stored::Unchanged(existing)) => {
return vec![loc.tf(
"tool.python_exec.files.unchanged",
&[("name", &file.name), ("stored", &existing.name)],
)];
}
Ok(Stored::Restored(existing)) => {
return vec![loc.tf(
"tool.python_exec.files.restored",
&[("name", &file.name), ("stored", &existing.name)],
)];
}
Err(e) => {
let reason = loc.tf(
"tool.python_exec.files.reason.write_failed",
&[("err", &e.to_string())],
);
return vec![skipped_with(loc, &file.name, &reason)];
}
};
let size = format_bytes(file.bytes.len());
let mut entry = if stored.name == file.name {
loc.tf(
"tool.python_exec.files.item",
&[
("name", &stored.name),
("size", &size),
("mime", &stored.mime),
],
)
} else {
loc.tf(
"tool.python_exec.files.renamed",
&[
("name", &file.name),
("stored", &stored.name),
("size", &size),
("mime", &stored.mime),
],
)
};
if let Some(mime) = sniff_image(&file.bytes) {
if !self.images {
entry.push_str(loc.t("tool.python_exec.files.not_shown_off"));
} else if kept.images.len() >= MAX_TOOL_RESULT_IMAGES {
entry.push_str(&loc.tf(
"tool.python_exec.files.not_shown_cap",
&[("max", &MAX_TOOL_RESULT_IMAGES.to_string())],
));
} else {
use base64::Engine as _;
kept.images.push(ToolImage {
mime: mime.to_string(),
data: base64::engine::general_purpose::STANDARD.encode(&file.bytes),
entry: Some(entry.clone()),
});
}
} else if stored.mime == "image/svg+xml" {
entry.push_str(loc.t("tool.python_exec.files.svg"));
}
let mut lines = vec![entry];
if is_text_like(&stored.mime) {
lines.extend(text_head(&file.bytes, TEXT_HEAD_BYTES));
}
listed.push(stored.clone());
kept.effects.push(ChatEffect::AddChatFile(Box::new(stored)));
lines
}
}
#[async_trait::async_trait]
impl Tool for PythonExec {
fn id(&self) -> ToolId {
super::PYTHON_EXEC_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::ExternalWorld
}
fn ui_label(&self) -> &'static str {
"run Python"
}
fn danger(&self) -> bool {
true
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Python)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
let (in_dir, out_dir) = self.mode.dirs();
let limits = OutputLimits::DEFAULT;
let images = if self.images {
loc.tf("tool.python_exec.desc.images_on", &[("out", out_dir)])
} else {
loc.t("tool.python_exec.desc.images_off").to_string()
};
let files = loc.tf(
"tool.python_exec.desc.files",
&[
("out", out_dir),
("files", &limits.max_files.to_string()),
("file", &format_bytes(limits.max_file_bytes as usize)),
("total", &format_bytes(limits.max_total_bytes as usize)),
("images", &images),
],
);
let inputs = loc.tf(
"tool.python_exec.desc.inputs",
&[("in", in_dir), ("out", out_dir)],
);
let head = match self.mode {
PythonMode::Local => loc.t("tool.python_exec.desc.local").to_string(),
PythonMode::Wasmer => {
let net = if self.net && self.net_private {
loc.t("tool.python_exec.net.any")
} else if self.net {
loc.t("tool.python_exec.net.on")
} else {
loc.t("tool.python_exec.net.off")
};
loc.tf("tool.python_exec.desc.wasmer", &[("net", net)])
}
};
format!("{head} {inputs} {files}")
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"code": {"type": "string"},
"files": {
"type": "array",
"items": {"type": "string"},
"description": loc.tf(
"tool.python_exec.param.files",
&[("in", self.mode.dirs().0)],
),
},
},
"required": ["code"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let code = args
.get("code")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.python_exec.err.code_empty")))?
.to_string();
let files = match crate::features::chat_inputs::named_files(&args) {
crate::features::chat_inputs::NamedFiles::Named(files) => files,
crate::features::chat_inputs::NamedFiles::Malformed => {
return Ok(ToolOutcome::text(
ctx.loc.t("tool.python_exec.err.files_shape"),
));
}
};
Ok(self.run_job(&code, &files, ctx).await)
}
}
fn format_output_parts(
stdout: &str,
stderr: &str,
success: bool,
code: Option<i32>,
loc: &crate::shared::i18n::Locale,
) -> String {
super::present::format_console(
None,
&truncate(stdout, MAX_OUTPUT_CHARS, loc),
&truncate(stderr, MAX_OUTPUT_CHARS, loc),
success,
code,
loc,
)
}
fn truncate(s: &str, max: usize, loc: &crate::shared::i18n::Locale) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let cut: String = s.chars().take(max).collect();
format!("{cut}\n{}", loc.t("python.truncated"))
}
}
#[derive(Default)]
struct Kept {
section: Option<String>,
effects: Vec<ChatEffect>,
images: Vec<ToolImage>,
}
fn skipped_line(loc: &Locale, skipped: &SkippedOutput, out_dir: &str) -> String {
let limits = OutputLimits::DEFAULT;
let reason = match skipped.reason {
SkipReason::Directory => loc.tf(
"tool.python_exec.files.reason.directory",
&[("out", out_dir)],
),
SkipReason::NotAFile => loc
.t("tool.python_exec.files.reason.not_a_file")
.to_string(),
SkipReason::TooLarge => loc.tf(
"tool.python_exec.files.reason.too_large",
&[("max", &format_bytes(limits.max_file_bytes as usize))],
),
SkipReason::TooMany => loc.tf(
"tool.python_exec.files.reason.too_many",
&[("max", &limits.max_files.to_string())],
),
SkipReason::OverTotal => loc.tf(
"tool.python_exec.files.reason.over_total",
&[("max", &format_bytes(limits.max_total_bytes as usize))],
),
SkipReason::Unreadable => loc
.t("tool.python_exec.files.reason.unreadable")
.to_string(),
SkipReason::TimedOut => loc.t("tool.python_exec.files.reason.timed_out").to_string(),
};
let name = if skipped.reason == SkipReason::Directory {
format!("{}/", skipped.name)
} else {
skipped.name.clone()
};
skipped_with(loc, &name, &reason)
}
fn known_files(items: &[crate::features::chat_inputs::ChatInput]) -> String {
items
.iter()
.map(|i| {
format!(
"\n• #{} {} ({})",
i.handle,
i.name,
format_bytes(i.bytes as usize)
)
})
.collect()
}
fn unknown_handle(
loc: &Locale,
handle: &str,
items: &[crate::features::chat_inputs::ChatInput],
) -> String {
if items.is_empty() {
return loc.tf(
"tool.python_exec.err.files_unknown_none",
&[("handle", handle.trim())],
);
}
loc.tf(
"tool.python_exec.err.files_unknown",
&[("handle", handle.trim()), ("files", &known_files(items))],
)
}
fn skipped_with(loc: &Locale, name: &str, reason: &str) -> String {
loc.tf(
"tool.python_exec.files.skipped",
&[("name", name), ("reason", reason)],
)
}
fn text_head(bytes: &[u8], max: usize) -> Vec<String> {
let cut = bytes.len() > max;
let head = &bytes[..bytes.len().min(max)];
if head.contains(&0) {
return Vec::new();
}
let decoded = String::from_utf8_lossy(head);
let text: &str = match decoded.rfind('\n') {
Some(end) if cut => &decoded[..end],
_ => &decoded,
};
let mut lines: Vec<String> = text
.trim_end()
.lines()
.map(|l| format!(" | {l}"))
.collect();
if cut && !lines.is_empty() {
lines.push(" | …".to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::super::testkit::{ctx_with_storage, ctx_with_storage_lang};
use super::*;
use crate::shared::i18n::Lang;
use crate::shared::sandbox::{MockSandbox, SandboxOutput};
use uuid::Uuid;
fn no_cyrillic(s: &str) -> bool {
!s.chars()
.any(|c| ('а'..='я').contains(&c) || ('А'..='Я').contains(&c) || c == 'ё' || c == 'Ё')
}
fn ru() -> &'static crate::shared::i18n::Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn local(python_path: Option<String>) -> PythonExec {
PythonExec::new(
PythonMode::Local,
Arc::new(crate::shared::sandbox::LocalSandbox::new(python_path)),
false,
Duration::from_secs(30),
)
}
#[cfg(windows)]
fn local_capped(memory_mb: u64) -> PythonExec {
PythonExec::new(
PythonMode::Local,
Arc::new(
crate::shared::sandbox::LocalSandbox::new(None).with_memory_limit(Some(memory_mb)),
),
false,
Duration::from_secs(30),
)
}
fn local_mock(sandbox: Arc<dyn SandboxRunner>) -> PythonExec {
PythonExec::new(PythonMode::Local, sandbox, false, Duration::from_secs(30))
}
fn wasmer(sandbox: Arc<dyn SandboxRunner>, net: bool) -> PythonExec {
PythonExec::new(PythonMode::Wasmer, sandbox, net, Duration::from_secs(30))
}
#[tokio::test]
async fn rejects_empty_code() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
assert!(
local(None)
.invoke(&ctx, serde_json::json!({"code": " "}))
.await
.is_err()
);
}
#[test]
fn truncate_marks_cut() {
let long = "a".repeat(MAX_OUTPUT_CHARS + 10);
let out = truncate(&long, MAX_OUTPUT_CHARS, ru());
assert!(out.contains("вывод обрезан"));
}
#[test]
fn format_output_parts_shapes_console() {
let s = format_output_parts("hi", "oops", false, Some(2), ru());
assert!(s.contains("stdout (1 line):\nhi"), "{s}");
assert!(s.contains("stderr (1 line):\noops"), "{s}");
assert!(s.contains("код возврата: 2"));
assert_eq!(
format_output_parts("", "", true, Some(0), ru()),
"(пустой вывод, успех)"
);
}
#[tokio::test]
async fn missing_interpreter_reports_error_not_panic() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let tool = local(Some("definitely-not-a-real-python-xyz".into()));
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(1)"}))
.await
.unwrap();
assert!(
out.result.contains("definitely-not-a-real-python-xyz"),
"got: {}",
out.result
);
}
#[tokio::test]
async fn wasmer_mode_dispatches_to_sandbox_and_formats() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
stdout: "42\n".into(),
stderr: String::new(),
exit_code: Some(0),
timed_out: false,
..Default::default()
}));
let tool = wasmer(sb.clone(), true);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(6*7)"}))
.await
.unwrap();
assert!(
out.result.contains("stdout (1 line):\n42"),
"{}",
out.result
);
let calls = sb.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(calls[0].1, "the net flag must be forwarded to the runner");
assert!(calls[0].0.contains("print(6*7)"));
}
#[tokio::test]
async fn wasmer_mode_timeout_message() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
timed_out: true,
..Default::default()
}));
let out = wasmer(sb, false)
.invoke(&ctx, serde_json::json!({"code": "while True: pass"}))
.await
.unwrap();
assert!(out.result.contains("превысил лимит времени"));
}
#[tokio::test]
async fn wasmer_mode_missing_sandbox_explains() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let tool = wasmer(Arc::new(MockSandbox::missing("нет бинаря")), true);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(1)"}))
.await
.unwrap();
assert!(out.result.contains("Песочница Python недоступна"));
assert!(out.result.contains("нет бинаря"));
}
#[test]
fn description_varies_by_mode_and_net() {
let ru = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let local = local(None).description(ru);
assert!(local.contains("интерпретатор машины"), "{local}");
assert!(local.contains("in") && local.contains("out/"), "{local}");
assert!(!local.contains("/w/"), "{local}");
let sb: Arc<dyn SandboxRunner> = Arc::new(MockSandbox::missing("x"));
let described = |net: bool, private: bool| {
wasmer(sb.clone(), net)
.with_private_network(private)
.description(ru)
};
let public_only = described(true, false);
assert!(
public_only.contains("только к публичным адресам"),
"{public_only}"
);
let any = described(true, true);
assert!(any.contains("включая частные"), "{any}");
assert!(described(false, false).contains("без доступа в сеть"));
assert!(described(false, true).contains("без доступа в сеть"));
}
#[test]
fn the_sandbox_description_says_state_does_not_survive_a_call() {
let sb: Arc<dyn SandboxRunner> = Arc::new(MockSandbox::missing("x"));
for lang in Lang::ALL {
let d = wasmer(sb.clone(), true).description(crate::shared::i18n::locale(*lang));
assert!(d.contains("/tmp"), "{lang:?} does not name /tmp: {d}");
}
}
const PNG: &[u8] = b"\x89PNG\r\n\x1a\nnot-really-pixels";
fn ctx_with_folder() -> (tempfile::TempDir, tempfile::TempDir, ToolContext) {
let (dir, _storage, mut ctx) = ctx_with_storage_lang(Uuid::new_v4(), Lang::En);
let folder = tempfile::tempdir().unwrap();
ctx.files_dir = Some(folder.path().to_path_buf());
(dir, folder, ctx)
}
fn out_file(name: &str, bytes: &[u8]) -> OutputFile {
OutputFile {
name: name.into(),
bytes: bytes.to_vec(),
}
}
fn ctx_with_inputs() -> (tempfile::TempDir, tempfile::TempDir, ToolContext) {
use crate::entities::attachment::{AttachMode, Attachment};
use crate::entities::chat_file::FileOrigin;
use crate::entities::message_image::MessageImage;
use base64::Engine as _;
let (dir, folder, mut ctx) = ctx_with_folder();
std::fs::write(folder.path().join("sales.xlsx"), XLSX).unwrap();
ctx.attachments = std::sync::Arc::from(vec![Attachment::new(
"notes.md",
"C:\\notes.md",
NOTES.to_string(),
NOTES.len(),
AttachMode::Inline,
)]);
ctx.files = std::sync::Arc::from(vec![ChatFile::new(
"sales.xlsx",
FileOrigin::Attached,
XLSX,
)]);
ctx.images = std::sync::Arc::from(vec![MessageImage::new(
"shot.png",
"C:\\shot.png",
"image/png",
10,
10,
base64::engine::general_purpose::STANDARD.encode(PNG),
)]);
ctx.sync_inputs();
(dir, folder, ctx)
}
fn renumber(ctx: &mut ToolContext) {
ctx.inputs = std::sync::Arc::from(Vec::new());
ctx.sync_inputs();
}
const NOTES: &str = "the note's text";
const XLSX: &[u8] = b"PK\x03\x04not-really-a-workbook";
fn staging_tool() -> (Arc<MockSandbox>, PythonExec) {
let sb = Arc::new(MockSandbox::ready(SandboxOutput::default()));
(sb.clone(), wasmer(sb, false))
}
#[tokio::test]
async fn one_name_without_its_list_still_names_a_file() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": "sales.xlsx"}),
)
.await
.unwrap();
assert!(!out.result.contains("nothing was run"), "{}", out.result);
let staged = sb.staged.lock().unwrap();
assert_eq!(
staged[0]
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
["sales.xlsx"]
);
}
#[tokio::test]
async fn a_bare_number_names_the_file_its_hash_handle_does() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["2"]}),
)
.await
.unwrap();
assert!(!out.result.contains("nothing was run"), "{}", out.result);
let staged = sb.staged.lock().unwrap();
assert_eq!(staged[0][0].0, "sales.xlsx");
}
#[tokio::test]
async fn a_shared_name_is_refused_with_the_handles_the_turn_carries() {
use crate::entities::attachment::{AttachMode, Attachment};
let (_d, _folder, mut ctx) = ctx_with_inputs(); let note = |source: &str| {
Attachment::new(
"notes.md",
source,
NOTES.to_string(),
NOTES.len(),
AttachMode::Inline,
)
};
ctx.attachments =
std::sync::Arc::from(vec![note("C:\\a\\notes.md"), note("C:\\b\\notes.md")]);
ctx.sync_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["notes.md"]}),
)
.await
.unwrap();
assert!(
out.result.contains("#4 notes.md") && out.result.contains("#5 notes.md"),
"{}",
out.result
);
assert!(!out.result.contains("#3 notes.md"), "{}", out.result);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
}
#[tokio::test]
async fn a_files_argument_that_is_not_names_runs_nothing() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["sales.xlsx", 3]}),
)
.await
.unwrap();
assert_eq!(out.result, ctx.loc.t("tool.python_exec.err.files_shape"));
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
}
#[tokio::test]
async fn a_call_that_skipped_a_flood_of_files_does_not_list_them_all() {
use crate::shared::sandbox::{SkipReason, SkippedOutput};
let skipped: Vec<SkippedOutput> = (0..500)
.map(|i| SkippedOutput {
name: format!("f{i}.txt"),
reason: SkipReason::TooMany,
})
.collect();
let (_d, _folder, ctx) = ctx_with_inputs();
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
skipped,
..Default::default()
}));
let tool = wasmer(sb.clone(), false);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(1)"}))
.await
.unwrap();
let named = out.result.matches("not kept:").count();
assert_eq!(named, 20, "the list has to stop somewhere: {}", out.result);
assert!(
out.result.contains("480"),
"and say how many it did not name: {}",
out.result
);
}
#[tokio::test]
async fn the_files_a_call_names_are_copied_into_the_guest() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["notes.md", "#2", "shot.png"]}),
)
.await
.unwrap();
assert!(!out.result.contains("nothing was run"), "{}", out.result);
let staged = sb.staged.lock().unwrap();
let names: Vec<&str> = staged[0].iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["notes.md", "sales.xlsx", "shot.png"]);
assert_eq!(staged[0][0].1, NOTES.as_bytes());
assert_eq!(staged[0][1].1, XLSX);
assert_eq!(staged[0][2].1, PNG);
}
#[tokio::test]
async fn a_number_promised_before_the_round_still_stages_the_same_file() {
use crate::entities::attachment::{AttachMode, Attachment};
let (_d, _folder, mut ctx) = ctx_with_inputs();
assert_eq!(ctx.inputs[1].name, "sales.xlsx");
assert_eq!(ctx.inputs[1].handle, 2);
let mut attachments = ctx.attachments.to_vec();
attachments.push(Attachment::new(
"A page",
"https://example.com/a",
"page text".into(),
9,
AttachMode::Inline,
));
ctx.attachments = std::sync::Arc::from(attachments);
ctx.sync_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["#2"]}),
)
.await
.unwrap();
assert!(!out.result.contains("nothing was run"), "{}", out.result);
let staged = sb.staged.lock().unwrap();
assert_eq!(
staged[0][0].0, "sales.xlsx",
"#2 must still be the file the block numbered"
);
assert_eq!(staged[0][0].1, XLSX);
}
#[tokio::test]
async fn local_mode_stages_the_chats_files_and_keeps_what_a_run_saved() {
let (_d, _folder, ctx) = ctx_with_inputs();
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
files: vec![OutputFile {
name: "clean.csv".into(),
bytes: b"a,b\n1,2\n".to_vec(),
}],
..SandboxOutput::default()
}));
let out = local_mock(sb.clone())
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["#2"]}),
)
.await
.unwrap();
let staged = sb.staged.lock().unwrap();
assert_eq!(
staged[0]
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
["sales.xlsx"]
);
assert_eq!(staged[0][0].1, XLSX);
assert_eq!(stored_names(&out), ["clean.csv"]);
assert!(out.result.contains("clean.csv"), "{}", out.result);
}
#[tokio::test]
async fn local_mode_refuses_an_unknown_handle_before_running() {
let (_d, _folder, ctx) = ctx_with_inputs();
let sb = Arc::new(MockSandbox::ready(SandboxOutput::default()));
let out = local_mock(sb.clone())
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["ghost.csv"]}),
)
.await
.unwrap();
assert!(out.result.contains("ghost.csv"), "{}", out.result);
assert!(
sb.calls.lock().unwrap().is_empty(),
"a refused call must not reach the interpreter"
);
}
#[tokio::test]
async fn an_unknown_handle_runs_nothing_and_lists_what_the_chat_has() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["ghost.csv"]}),
)
.await
.unwrap();
assert!(out.result.contains("ghost.csv"), "{}", out.result);
assert!(out.result.contains("nothing was run"), "{}", out.result);
assert!(out.result.contains("#1 notes.md"), "{}", out.result);
assert!(out.result.contains("#3 shot.png"), "{}", out.result);
assert!(
sb.calls.lock().unwrap().is_empty(),
"a refused call must not reach the sandbox"
);
}
fn attachment_of(name: &str, bytes: usize) -> crate::entities::attachment::Attachment {
crate::entities::attachment::Attachment::new(
name,
format!("/data/{name}"),
"a,b".to_string(),
bytes,
crate::entities::attachment::AttachMode::Inline,
)
}
#[tokio::test]
async fn an_unknown_handle_in_a_chat_with_no_files_says_it_has_none() {
let (_d, _folder, ctx) = ctx_with_folder();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["#1"]}),
)
.await
.unwrap();
let en = crate::shared::i18n::locale(Lang::En);
assert_eq!(
out.result,
en.tf(
"tool.python_exec.err.files_unknown_none",
&[("handle", "#1")]
)
);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
}
#[tokio::test]
async fn a_call_naming_more_files_than_the_cap_runs_nothing() {
let (_d, _folder, mut ctx) = ctx_with_folder();
ctx.attachments = std::sync::Arc::from(
(1..=MAX_INPUT_FILES + 1)
.map(|n| attachment_of(&format!("part{n}.csv"), 10))
.collect::<Vec<_>>(),
);
renumber(&mut ctx);
let handles = |n: usize| (1..=n).map(|i| format!("#{i}")).collect::<Vec<_>>();
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": handles(MAX_INPUT_FILES + 1)}),
)
.await
.unwrap();
assert!(
out.result
.contains(&format!("names {} files", MAX_INPUT_FILES + 1)),
"{}",
out.result
);
assert!(out.result.contains("Nothing was run"), "{}", out.result);
assert!(
out.result.contains("/w/out"),
"the way through: {}",
out.result
);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": handles(MAX_INPUT_FILES)}),
)
.await
.unwrap();
let staged = sb.staged.lock().unwrap();
assert_eq!(staged.len(), 1, "at the cap the call runs: {}", out.result);
assert_eq!(staged[0].len(), MAX_INPUT_FILES);
}
#[tokio::test]
async fn a_call_naming_more_bytes_than_the_cap_runs_nothing() {
let (_d, _folder, mut ctx) = ctx_with_folder();
let half = |over: u64| (MAX_INPUT_BYTES / 2 + over) as usize;
let pair = |ctx: &mut ToolContext, each: usize| {
ctx.attachments = std::sync::Arc::from(vec![
attachment_of("a.csv", each),
attachment_of("b.csv", each),
]);
renumber(ctx);
};
let (sb, tool) = staging_tool();
let both = serde_json::json!({"code": "print(1)", "files": ["#1", "#2"]});
pair(&mut ctx, half(1));
let out = tool.invoke(&ctx, both.clone()).await.unwrap();
assert!(out.result.contains("names 2 files"), "{}", out.result);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
pair(&mut ctx, half(0));
let out = tool.invoke(&ctx, both).await.unwrap();
let staged = sb.staged.lock().unwrap();
assert_eq!(staged.len(), 1, "at the cap the call runs: {}", out.result);
assert_eq!(staged[0].len(), 2);
}
#[tokio::test]
async fn a_name_two_files_share_runs_nothing() {
use crate::entities::attachment::{AttachMode, Attachment};
let (_d, _folder, mut ctx) = ctx_with_inputs();
ctx.attachments = std::sync::Arc::from(vec![
Attachment::new(
"notes.md",
"C:\\a\\notes.md",
"a".into(),
1,
AttachMode::Inline,
),
Attachment::new(
"notes.md",
"C:\\b\\notes.md",
"b".into(),
1,
AttachMode::Inline,
),
]);
renumber(&mut ctx);
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["notes.md"]}),
)
.await
.unwrap();
assert!(out.result.contains("C:\\a\\notes.md"), "{}", out.result);
assert!(out.result.contains("C:\\b\\notes.md"), "{}", out.result);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
}
#[tokio::test]
async fn a_listed_file_whose_copy_is_gone_runs_nothing() {
use crate::entities::chat_file::FileOrigin;
let (_d, _folder, mut ctx) = ctx_with_inputs();
ctx.files = std::sync::Arc::from(vec![ChatFile::new(
"gone.csv",
FileOrigin::Sandbox,
b"month,total\n",
)]);
renumber(&mut ctx);
let (sb, tool) = staging_tool();
let out = tool
.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["gone.csv"]}),
)
.await
.unwrap();
assert!(out.result.contains("gone.csv"), "{}", out.result);
assert_eq!(
out.result,
ctx.loc.tf(
"tool.python_exec.err.files_missing",
&[("name", "gone.csv")]
),
"listed-but-missing, not unknown"
);
assert!(sb.calls.lock().unwrap().is_empty(), "{}", out.result);
}
#[tokio::test]
async fn a_file_named_twice_is_staged_once() {
let (_d, _folder, ctx) = ctx_with_inputs();
let (sb, tool) = staging_tool();
tool.invoke(
&ctx,
serde_json::json!({"code": "print(1)", "files": ["notes.md", "#1"]}),
)
.await
.unwrap();
let staged = sb.staged.lock().unwrap();
assert_eq!(
staged[0].len(),
1,
"one copy, one name in /w/in: {staged:?}"
);
}
#[test]
fn both_modes_offer_the_files_argument_naming_their_own_folder() {
let en = crate::shared::i18n::locale(Lang::En);
let sb: Arc<dyn SandboxRunner> = Arc::new(MockSandbox::missing("x"));
let wasmer_schema = wasmer(sb, false).parameters(en);
assert!(wasmer_schema["properties"]["files"].is_object());
let guest = wasmer_schema["properties"]["files"]["description"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(guest.contains("/w/in"), "{guest}");
let local_schema = local(None).parameters(en);
assert!(local_schema["properties"]["files"].is_object());
assert_eq!(local_schema["required"], wasmer_schema["required"]);
let host = local_schema["properties"]["files"]["description"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(!host.contains("/w/in"), "{host}");
}
fn stored_names(out: &ToolOutcome) -> Vec<String> {
out.effects
.iter()
.filter_map(|e| match e {
ChatEffect::AddChatFile(f) => Some(f.name.clone()),
_ => None,
})
.collect()
}
async fn run_mock(tool: PythonExec, ctx: &ToolContext) -> ToolOutcome {
tool.invoke(ctx, serde_json::json!({"code": "print(1)"}))
.await
.unwrap()
}
fn saved(files: Vec<OutputFile>) -> Arc<MockSandbox> {
Arc::new(MockSandbox::ready(SandboxOutput {
exit_code: Some(0),
files,
..Default::default()
}))
}
#[tokio::test]
async fn outputs_are_stored_listed_and_an_image_is_shown() {
let (_d, folder, ctx) = ctx_with_folder();
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
stdout: "done\n".into(),
exit_code: Some(0),
files: vec![
out_file("chart.png", PNG),
out_file("totals.csv", b"month,total\n2024-01,7\n"),
],
skipped: vec![SkippedOutput {
name: "charts".into(),
reason: SkipReason::Directory,
}],
..Default::default()
}));
let out = run_mock(wasmer(sb, false), &ctx).await;
let r = &out.result;
assert!(r.starts_with("stdout (1 line):\ndone"), "{r}");
assert!(r.contains("\n\nfiles:\n"), "{r}");
assert!(r.contains(&folder.path().display().to_string()), "{r}");
assert!(r.contains(" | month,total\n | 2024-01,7"), "{r}");
assert!(r.contains("- charts/ — not kept: a folder"), "{r}");
assert_eq!(stored_names(&out), ["chart.png", "totals.csv"]);
assert_eq!(out.images.len(), 1);
assert_eq!(out.images[0].mime, "image/png");
let entry = out.images[0].entry.as_deref().expect("the chart's line");
assert!(
entry.starts_with("- chart.png — ") && entry.ends_with("image/png"),
"{entry}"
);
assert!(r.lines().any(|l| l == entry), "{r}");
assert!(!r.contains("shown to you below"), "{r}");
assert_eq!(std::fs::read(folder.path().join("chart.png")).unwrap(), PNG);
}
#[tokio::test]
async fn with_images_off_the_file_is_kept_and_the_model_is_told_it_has_not_seen_it() {
let (_d, folder, ctx) = ctx_with_folder();
let tool = wasmer(saved(vec![out_file("chart.png", PNG)]), false).with_images(false);
let out = run_mock(tool, &ctx).await;
assert!(out.images.is_empty());
assert!(
out.result.contains("you have not seen it"),
"{}",
out.result
);
assert_eq!(stored_names(&out), ["chart.png"]);
assert!(folder.path().join("chart.png").exists());
}
#[tokio::test]
async fn at_most_four_images_are_shown_and_the_one_past_the_cap_says_so() {
let (_d, _folder, ctx) = ctx_with_folder();
let files = (1u8..=5)
.map(|i| out_file(&format!("{i}.png"), &[PNG, &[i]].concat()))
.collect();
let out = run_mock(wasmer(saved(files), false), &ctx).await;
assert_eq!(out.images.len(), MAX_TOOL_RESULT_IMAGES);
let entries: Vec<_> = out
.images
.iter()
.filter_map(|i| i.entry.as_deref())
.collect();
assert_eq!(
entries.len(),
MAX_TOOL_RESULT_IMAGES,
"each offered on its own line"
);
assert!(entries[3].starts_with("- 4.png — "), "{entries:?}");
assert_eq!(
out.result
.matches("at most 4 images are shown per call")
.count(),
1,
"{}",
out.result
);
assert_eq!(stored_names(&out).len(), 5);
}
#[tokio::test]
async fn a_run_that_printed_nothing_but_saved_a_file_opens_with_the_section() {
let (_d, _folder, ctx) = ctx_with_folder();
let out = run_mock(wasmer(saved(vec![out_file("a.txt", b"hi")]), false), &ctx).await;
assert!(out.result.starts_with("files:\n"), "{}", out.result);
assert!(!out.result.contains("empty output"), "{}", out.result);
}
#[tokio::test]
async fn a_taken_name_is_versioned_and_the_same_bytes_are_not_saved_or_shown_twice() {
let (_d, folder, mut ctx) = ctx_with_folder();
let older = ChatFile::new(
"chart.png",
crate::entities::chat_file::FileOrigin::Sandbox,
b"older",
);
std::fs::write(folder.path().join("chart.png"), b"older").unwrap();
ctx.files = Arc::from(vec![older.clone()]);
let sb = saved(vec![out_file("chart.png", PNG)]);
let out = run_mock(wasmer(sb.clone(), false), &ctx).await;
assert!(
out.result
.contains("- chart.png → saved as chart (2).png — "),
"{}",
out.result
);
let Some(ChatEffect::AddChatFile(stored)) = out.effects.first() else {
panic!("expected a listing: {:?}", out.effects);
};
ctx.files = Arc::from(vec![older, (**stored).clone()]);
let again = run_mock(wasmer(sb, false), &ctx).await;
assert!(again.effects.is_empty(), "{:?}", again.effects);
assert!(again.images.is_empty());
assert!(again.result.contains("unchanged"), "{}", again.result);
}
#[tokio::test]
async fn a_timed_out_run_keeps_nothing_and_names_what_it_left() {
let (_d, _folder, ctx) = ctx_with_folder();
let sb = Arc::new(MockSandbox::ready(SandboxOutput {
timed_out: true,
skipped: vec![SkippedOutput {
name: "half.png".into(),
reason: SkipReason::TimedOut,
}],
..Default::default()
}));
let out = run_mock(wasmer(sb, false), &ctx).await;
assert!(
out.result.contains("exceeded the time limit"),
"{}",
out.result
);
assert!(
out.result
.contains("- half.png — not kept: the call timed out"),
"{}",
out.result
);
assert!(out.effects.is_empty());
}
#[tokio::test]
async fn without_a_chat_folder_nothing_is_kept_and_the_result_says_so() {
let (_d, _s, ctx) = ctx_with_storage_lang(Uuid::new_v4(), Lang::En);
let out = run_mock(wasmer(saved(vec![out_file("a.png", PNG)]), false), &ctx).await;
assert!(
out.result.contains("Nothing saved to /w/out was kept"),
"{}",
out.result
);
assert!(out.effects.is_empty() && out.images.is_empty());
}
#[tokio::test]
async fn a_reserved_name_is_saved_renamed_and_an_svg_is_only_saved() {
let (_d, folder, ctx) = ctx_with_folder();
let files = vec![
out_file("CON.txt", b"x"),
out_file("drawing.svg", b"<svg/>"),
];
let out = run_mock(wasmer(saved(files), false), &ctx).await;
assert!(
out.result.contains("- CON.txt → saved as _CON.txt"),
"{}",
out.result
);
assert!(
out.result.contains("an SVG is not shown to you"),
"{}",
out.result
);
assert!(out.images.is_empty());
assert!(folder.path().join("_CON.txt").exists());
}
#[test]
fn the_description_names_the_output_folder_its_caps_and_whether_images_are_shown() {
let sb: Arc<dyn SandboxRunner> = Arc::new(MockSandbox::missing("x"));
for lang in Lang::ALL {
let loc = crate::shared::i18n::locale(*lang);
let on = wasmer(sb.clone(), false).description(loc);
let off = wasmer(sb.clone(), false)
.with_images(false)
.description(loc);
for d in [&on, &off] {
assert!(d.contains("/w/out"), "{lang:?}: {d}");
assert!(d.contains("matplotlib"), "{lang:?}: {d}");
assert!(
d.contains("25.0 MB") && d.contains("50.0 MB"),
"{lang:?}: {d}"
);
}
assert!(on.contains("savefig('/w/out/"), "{lang:?}: {on}");
assert!(!off.contains("savefig('/w/out/"), "{lang:?}: {off}");
}
}
#[test]
fn a_text_head_quotes_whole_lines_and_marks_a_cut() {
assert_eq!(text_head(b"a,b\r\n1,2\n", 1024), [" | a,b", " | 1,2"]);
let long = "row\n".repeat(400);
assert_eq!(
text_head(long.as_bytes(), 10),
[" | row", " | row", " | …"]
);
assert!(text_head(b"PK\x03\x04\0\0", 1024).is_empty());
}
#[tokio::test]
#[ignore = "runs the real wasmer sidecar; provisions one (~250 MB) if absent"]
async fn runs_real_python_in_sandbox() {
use crate::shared::sandbox::WasmerSandbox;
let (_guard, dir) = ensure_sandbox().await;
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let tool = PythonExec::new(
PythonMode::Wasmer,
Arc::new(WasmerSandbox::new(dir)),
false,
Duration::from_secs(120),
);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print('hello sandbox')"}))
.await
.unwrap();
assert!(out.result.contains("hello sandbox"), "got: {}", out.result);
}
async fn ensure_sandbox() -> (Option<tempfile::TempDir>, Option<std::path::PathBuf>) {
use crate::shared::sandbox::locate_wasmer;
if std::env::var_os("MINDFORK_SANDBOX_WASMER").is_some_and(|v| !v.is_empty()) {
return (None, None);
}
let named = std::env::var("MINDFORK_SANDBOX_DIR")
.ok()
.filter(|d| !d.is_empty())
.map(std::path::PathBuf::from);
if let Some(dir) = &named
&& locate_wasmer(dir).is_some()
{
return (None, Some(dir.clone()));
}
if named.is_none() {
let mut candidates = Vec::new();
if let Ok(paths) = crate::shared::paths::Paths::resolve() {
candidates.push(paths.sandbox_dir());
}
if let Ok(exe) = std::env::current_exe()
&& let Some(profile_dir) = exe.parent().and_then(|deps| deps.parent())
{
candidates.push(profile_dir.join("data").join("sandbox"));
}
if let Some(found) = candidates.into_iter().find(|d| locate_wasmer(d).is_some()) {
return (None, Some(found));
}
}
let (guard, dir) = match named {
Some(dir) => (None, dir),
None => {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
(Some(tmp), dir)
}
};
eprintln!("provisioning a sandbox into {} (~250 MB)…", dir.display());
crate::features::sandbox_setup::setup(
&dir,
&crate::features::sandbox_setup::SetupOptions::default(),
crate::shared::i18n::locale(crate::shared::i18n::Lang::En),
|line| eprintln!(" {line}"),
)
.await
.expect("provisioning the sandbox for the smoke");
(guard, Some(dir))
}
fn sandbox_dir_from_env() -> Option<String> {
let dir = std::env::var("MINDFORK_SANDBOX_DIR").ok();
if dir.is_none() {
eprintln!("skip: MINDFORK_SANDBOX_DIR not set");
}
dir
}
fn provisioned(net: bool, timeout_secs: u64) -> Option<PythonExec> {
use crate::shared::sandbox::WasmerSandbox;
let dir = sandbox_dir_from_env()?;
Some(PythonExec::new(
PythonMode::Wasmer,
Arc::new(WasmerSandbox::new(Some(std::path::PathBuf::from(dir)))),
net,
Duration::from_secs(timeout_secs),
))
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn numpy_in_sandbox() {
let Some(tool) = provisioned(false, 120) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import numpy as np; print('numpy', np.__version__); \
print('sum', int(np.arange(10).sum()))";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("numpy 2."), "got: {}", out.result);
assert!(out.result.contains("sum 45"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn pandas_in_sandbox() {
let Some(tool) = provisioned(false, 120) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import pandas as pd; \
df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}); \
print('pandas', pd.__version__); print('total', int(df.values.sum()))";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("pandas 2."), "got: {}", out.result);
assert!(out.result.contains("total 21"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn beautifulsoup_in_sandbox() {
let Some(tool) = provisioned(false, 120) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import bs4\n\
from bs4 import BeautifulSoup\n\
html = '<html><body><p class=\"x\">hi</p><p>bye</p></body></html>'\n\
soup = BeautifulSoup(html, 'html.parser')\n\
print('bs4', bs4.__version__)\n\
print('text', soup.p.get_text())\n\
print('select', len(soup.select('p.x')))";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("bs4 4."), "got: {}", out.result);
assert!(out.result.contains("text hi"), "got: {}", out.result);
assert!(out.result.contains("select 1"), "got: {}", out.result);
}
async fn run_provisioned(code: &str) -> Option<String> {
let tool = provisioned(false, 120)?;
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
Some(out.result)
}
const STARTER_SET_SCRIPT: &str = r#"
import io, feedparser, mpmath, networkx, openpyxl, pypdf, regex, sympy, yaml
import pandas as pd
from bs4 import BeautifulSoup
from lxml import etree
from PIL import Image
x = sympy.symbols('x')
print('sympy', sympy.solve(x**2 - 4, x))
mpmath.mp.dps = 30
print('mpmath', str(mpmath.pi)[:12])
print('networkx', networkx.shortest_path(networkx.path_graph(4), 0, 3))
print('regex', regex.findall(r'\p{Cyrillic}+', 'abc Привет'))
print('yaml', yaml.safe_load('a: [1, 2]'), yaml.__with_libyaml__)
print('lxml', etree.fromstring('<a><b>7</b></a>').xpath('//b/text()'))
print('bs4-lxml', BeautifulSoup('<p>x<b>y</p>', 'lxml').get_text())
print(pd.DataFrame({'a': [1]}).to_markdown())
feed = feedparser.parse('<rss version="2.0"><channel><title>T</title><item><title>i</title></item></channel></rss>')
print('feedparser', feed.feed.title, len(feed.entries))
book = openpyxl.Workbook()
book.active.append(['q', 5])
xlsx = io.BytesIO()
book.save(xlsx)
xlsx.seek(0)
print('openpyxl', pd.read_excel(xlsx, header=None).iloc[0, 1])
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
pdf = io.BytesIO()
writer.write(pdf)
pdf.seek(0)
print('pypdf', len(pypdf.PdfReader(pdf).pages))
png = io.BytesIO()
Image.new('RGB', (8, 8)).save(png, 'PNG')
print('pillow', png.getvalue()[:4] == b'\x89PNG')
"#;
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn starter_set_packages_work_in_sandbox() {
let Some(out) = run_provisioned(STARTER_SET_SCRIPT).await else {
return;
};
for marker in [
"sympy [-2, 2]",
"mpmath 3.1415926535",
"networkx [0, 1, 2, 3]",
"regex ['Привет']",
"yaml {'a': [1, 2]} True",
"lxml ['7']",
"bs4-lxml xy",
"| 0 | 1 |",
"feedparser T 1",
"openpyxl 5",
"pypdf 1",
"pillow True",
] {
assert!(out.contains(marker), "missing {marker:?} in: {out}");
}
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn matplotlib_renders_text_in_sandbox() {
let code = "import io\n\
import matplotlib.pyplot as plt\n\
fig, ax = plt.subplots()\n\
ax.plot([1, 2, 3], [3, 1, 2], label='ряд')\n\
ax.set_title('Проверка кириллицы')\n\
ax.legend()\n\
png = io.BytesIO()\n\
fig.savefig(png, format='png')\n\
print('png', png.getvalue()[:4] == b'\\x89PNG', len(png.getvalue()) > 1000)";
let Some(out) = run_provisioned(code).await else {
return;
};
assert!(out.contains("png True True"), "got: {out}");
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn site_packages_writes_do_not_survive_a_call() {
let Some(dir) = sandbox_dir_from_env() else {
return;
};
let inject =
"open('/sp/sitecustomize.py', 'w').write('print(\"INJECTED\")')\nprint('wrote')";
let first = run_provisioned(inject).await.unwrap();
let host = std::path::Path::new(&dir)
.join("site-packages")
.join("sitecustomize.py");
let reached_host = host.exists();
if reached_host {
let _ = std::fs::remove_file(&host);
}
let second = run_provisioned("print('clean')").await.unwrap();
assert!(
first.contains("wrote"),
"the write itself must succeed: {first}"
);
assert!(!reached_host, "the write reached the host's site-packages");
assert!(
second.contains("clean") && !second.contains("INJECTED"),
"got: {second}"
);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn outputs_are_kept_from_a_real_sandbox() {
let Some(tool) = provisioned(false, 120) else {
return;
};
let (_d, folder, ctx) = ctx_with_folder();
let code = "import os\n\
import matplotlib.pyplot as plt\n\
plt.bar(['a', 'b'], [3, 5])\n\
plt.savefig('/w/out/chart.png')\n\
open('/w/out/totals.csv', 'w').write('k,v\\na,3\\nb,5\\n')\n\
os.makedirs('/w/out/nested', exist_ok=True)\n\
open('/w/out/nested/inner.txt', 'w').write('x')\n\
open('/w/beside.txt', 'w').write('not collected')\n\
try:\n\
\x20 os.symlink('/w/job.py', '/w/out/link.py')\n\
\x20 print('link made')\n\
except OSError as e:\n\
\x20 print('no link', e)\n\
raise SystemExit(3)";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
let r = &out.result;
eprintln!("{r}");
assert_eq!(stored_names(&out), ["chart.png", "totals.csv"], "{r}");
assert_eq!(out.images.len(), 1, "{r}");
assert!(r.contains("exit code: 3"), "{r}");
assert!(r.contains("- nested/ — not kept"), "{r}");
assert!(!r.contains("beside.txt"), "{r}");
let reached = r.contains("- link.py");
eprintln!("the guest's link reached the host directory: {reached}");
if reached {
assert!(r.contains("- link.py — not kept"), "{r}");
}
assert!(!folder.path().join("link.py").exists());
let chart = std::fs::read(folder.path().join("chart.png")).unwrap();
assert!(chart.starts_with(b"\x89PNG"), "not a PNG");
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn a_timed_out_call_keeps_none_of_its_outputs() {
let Some(tool) = provisioned(false, 8) else {
return;
};
let (_d, folder, ctx) = ctx_with_folder();
let code = "open('/w/out/early.txt', 'w').write('x')\nwhile True: pass";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
let r = &out.result;
assert!(out.effects.is_empty(), "{r}");
assert!(
r.contains("- early.txt — not kept: the call timed out"),
"{r}"
);
assert!(!folder.path().join("early.txt").exists());
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox + network (MINDFORK_SANDBOX_DIR)"]
async fn requests_in_sandbox_with_net() {
let Some(tool) = provisioned(true, 120) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import requests; r = requests.get('https://example.com', timeout=20); \
print('status', r.status_code)";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("status 200"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn requests_blocked_without_net() {
let Some(tool) = provisioned(false, 60) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import requests\n\
try:\n\
\x20 r = requests.get('https://example.com', timeout=10)\n\
\x20 print('status', r.status_code)\n\
except Exception as e:\n\
\x20 print('blocked')";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(!out.result.contains("status 200"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn cyrillic_print_in_sandbox() {
let Some(tool) = provisioned(false, 60) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print('Привет, мир')"}))
.await
.unwrap();
assert!(out.result.contains("Привет, мир"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn timeout_kills_sandbox() {
let Some(tool) = provisioned(false, 3) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = tool
.invoke(&ctx, serde_json::json!({"code": "while True: pass"}))
.await
.unwrap();
assert!(
out.result.contains("превысил лимит времени"),
"got: {}",
out.result
);
}
#[cfg(windows)]
fn provisioned_capped(memory_mb: u64) -> Option<PythonExec> {
use crate::shared::sandbox::WasmerSandbox;
let dir = sandbox_dir_from_env()?;
Some(PythonExec::new(
PythonMode::Wasmer,
Arc::new(
WasmerSandbox::new(Some(std::path::PathBuf::from(dir)))
.with_memory_limit(Some(memory_mb)),
),
false,
Duration::from_secs(60),
))
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn memory_cap_stops_runaway() {
let Some(tool) = provisioned_capped(1024) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "b = bytearray(3 * 1024 * 1024 * 1024)\nprint(len(b))";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
let r = &out.result;
assert!(
r.contains("MemoryError") || r.contains("Fatal") || r.contains("код возврата"),
"expected the allocation to fail under the limit, got: {r}"
);
assert!(!r.contains("3221225472"), "got: {r}");
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn memory_cap_allows_normal_work() {
let Some(tool) = provisioned_capped(2048) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(sum(range(1000)))"}))
.await
.unwrap();
assert!(out.result.contains("499500"), "got: {}", out.result);
}
#[tokio::test]
async fn en_sandbox_missing_is_localized() {
use crate::shared::sandbox::WasmerSandbox;
if std::env::var_os("MINDFORK_SANDBOX_WASMER").is_some() {
return; }
let empty = tempfile::tempdir().unwrap();
let tool = PythonExec::new(
PythonMode::Wasmer,
Arc::new(WasmerSandbox::new(Some(empty.path().to_path_buf()))),
false,
Duration::from_secs(30),
);
let (_d, _s, ctx) = ctx_with_storage_lang(Uuid::new_v4(), Lang::En);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "print(1)"}))
.await
.unwrap();
let r = &out.result;
assert!(r.contains("The Python sandbox is unavailable"), "{r}");
assert!(r.contains("`wasmer` binary not found"), "{r}");
assert!(no_cyrillic(r), "cyrillic leaked on en-profile: {r}");
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn en_sandbox_output_and_exit_label_localized() {
let Some(tool) = provisioned(false, 60) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage_lang(Uuid::new_v4(), Lang::En);
let code = "print('hello'); import sys; sys.exit(3)";
let out = tool
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
let r = &out.result;
assert!(r.contains("hello"), "{r}");
assert!(r.contains("exit code:"), "the en exit-code label: {r}");
assert!(no_cyrillic(r), "cyrillic leaked on en-profile: {r}");
}
#[tokio::test]
#[ignore = "requires a provisioned sandbox (MINDFORK_SANDBOX_DIR)"]
async fn en_sandbox_timeout_localized() {
let Some(tool) = provisioned(false, 3) else {
return;
};
let (_d, _s, ctx) = ctx_with_storage_lang(Uuid::new_v4(), Lang::En);
let out = tool
.invoke(&ctx, serde_json::json!({"code": "while True: pass"}))
.await
.unwrap();
let r = &out.result;
assert!(r.contains("exceeded the time limit"), "{r}");
assert!(no_cyrillic(r), "cyrillic leaked on en-profile: {r}");
}
#[tokio::test]
#[ignore = "requires a Python interpreter on PATH"]
async fn a_process_the_script_leaves_behind_does_not_make_the_call_time_out() {
let (_d, _folder, ctx) = ctx_with_inputs();
let code = concat!(
"import subprocess, sys\n",
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(20)'])\n",
"print('parent done')\n",
);
let started = std::time::Instant::now();
let out = local(None)
.invoke(&ctx, serde_json::json!({"code": code}))
.await
.unwrap();
let took = started.elapsed();
assert!(
out.result.contains("parent done"),
"the script's own output has to survive: {}",
out.result
);
assert!(
!out.result.contains("exceeded"),
"the call did not time out — a child of the script outlived it: {}",
out.result
);
assert!(
took < Duration::from_secs(6),
"the call waited on the pipes rather than the process: {took:?}"
);
}
#[tokio::test]
#[ignore = "requires a Python interpreter on PATH"]
async fn runs_real_python_local() {
let (_d, _folder, ctx) = ctx_with_inputs();
let code = concat!(
"text = open('in/notes.md', encoding='utf-8').read()\n",
"print('read', len(text))\n",
"open('out/echo.txt', 'w', encoding='utf-8').write(text)\n",
);
let out = local(None)
.invoke(
&ctx,
serde_json::json!({"code": code, "files": ["notes.md"]}),
)
.await
.unwrap();
assert!(
out.result.contains(&format!("read {}", NOTES.len())),
"got: {}",
out.result
);
assert_eq!(stored_names(&out), ["echo.txt"]);
let kept = out
.effects
.iter()
.find_map(|e| match e {
ChatEffect::AddChatFile(f) => Some(f.bytes),
_ => None,
})
.expect("the file was stored");
assert_eq!(kept, NOTES.len() as u64);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "requires a Python interpreter on PATH"]
async fn a_local_memory_limit_stops_a_runaway_allocation_and_its_child() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "b = bytearray(1024 * 1024 * 1024)\nprint('allocated', len(b))";
let out = local_capped(256)
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("MemoryError"), "got: {}", out.result);
assert!(!out.result.contains("allocated"), "got: {}", out.result);
let code = concat!(
"import subprocess, sys\n",
"r = subprocess.run([sys.executable, '-c', 'b = bytearray(1 << 30); print(len(b))'],\n",
" capture_output=True, text=True)\n",
"print('child exit', r.returncode)\n",
"print('child stdout', r.stdout.strip() or '-')\n",
"print('child stderr', (r.stderr.strip().splitlines() or ['-'])[-1])\n",
);
let out = local_capped(256)
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(
out.result.contains("child stderr MemoryError"),
"the child is capped too: {}",
out.result
);
assert!(!out.result.contains("1073741824"), "got: {}", out.result);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "requires a Python interpreter on PATH"]
async fn a_local_memory_limit_leaves_ordinary_work_alone() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let code = "import json, csv, statistics\nprint(sum(range(1000)))";
let out = local_capped(256)
.invoke(&ctx, serde_json::json!({ "code": code }))
.await
.unwrap();
assert!(out.result.contains("499500"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a Python interpreter on PATH"]
async fn prints_cyrillic_without_encoding_error() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = local(None)
.invoke(&ctx, serde_json::json!({"code": "print('Привет, мир')"}))
.await
.unwrap();
assert!(out.result.contains("Привет, мир"), "got: {}", out.result);
assert!(
!out.result.contains("UnicodeEncodeError"),
"got: {}",
out.result
);
}
}