Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ ui/dist

# Custom files
plan/
.claude
.pi
6 changes: 6 additions & 0 deletions src/agent/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ pub struct ExecRequest {
pub wasm_path: Option<String>,
/// Source code to execute via a language runtime (alternative to wasm_path).
pub source: Option<String>,
/// Multi-file project: map of filename → file content. Use with `entry`.
/// Files are written to the session root before execution and visible
/// to the runtime (e.g. for `require()` of sibling files).
pub files: Option<HashMap<String, String>>,
/// Entry filename for a multi-file project (must be a key in `files`).
pub entry: Option<String>,
/// Language for source execution: "javascript", "js", or "nodejs".
pub language: Option<String>,
pub function: Option<String>,
Expand Down
189 changes: 182 additions & 7 deletions src/agent/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::agent::api::ApiError;
use crate::runtime::core::native_executor::execute_wasm_bytes_with_env;
use crate::runtime::runtime_cache::{wasmhub_language, RuntimeCache};
use crate::runtime::wasi::WasiEnv;
use std::path::Path;
use std::collections::HashMap;
use std::path::{Component, Path};
use std::sync::{Arc, Mutex};

const JS_SCRIPT_NAME: &str = "_run_.js";
Expand Down Expand Up @@ -40,22 +41,110 @@ pub fn execute_source(
std::fs::write(work_dir.join(JS_SCRIPT_NAME), source)
.map_err(|e| ApiError::Internal(format!("Failed to write script: {e}")))?;

let cache = RuntimeCache::new()
.map_err(|e| ApiError::Internal(format!("Runtime cache unavailable: {e}")))?;
let wasm_bytes = cache
.get_runtime(runtime_name)
.map_err(|e| ApiError::Internal(format!("Failed to fetch {runtime_name} runtime: {e}")))?;
let wasm_bytes = fetch_runtime_bytes(runtime_name)?;

// nodejs-runtime dispatch: "run <file>" reads the file and evals it
let args = vec![
"nodejs-runtime".to_string(),
format!("{runtime_name}-runtime"),
"run".to_string(),
JS_SCRIPT_NAME.to_string(),
];
execute_wasm_bytes_with_env(&wasm_bytes, wasi_env, None, args, max_memory_pages)
.map_err(|e| ApiError::Internal(e.to_string()))
}

/// Execute a multi-file source project in a session sandbox.
///
/// Writes every entry in `files` (path → content) into the session work_dir,
/// then runs the language runtime with `entry` as the script argument.
/// Sibling files are visible to the runtime via the session's preopened
/// WASI directory, enabling relative `require()` between project files.
pub fn execute_source_project(
files: &HashMap<String, String>,
entry: &str,
language: &str,
wasi_env: Arc<Mutex<WasiEnv>>,
work_dir: &Path,
max_memory_pages: Option<u32>,
) -> std::result::Result<i32, ApiError> {
let runtime_name = resolve_runtime(language)?;

if files.is_empty() {
return Err(ApiError::BadRequest("'files' map is empty".into()));
}
if !files.contains_key(entry) {
return Err(ApiError::BadRequest(format!(
"Entry '{entry}' not found in 'files' map",
)));
}

for path in files.keys() {
validate_project_filename(path)?;
}

for (rel_path, content) in files {
let resolved = work_dir.join(rel_path);
if let Some(parent) = resolved.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ApiError::Internal(format!("Failed to create dir: {e}")))?;
}
std::fs::write(&resolved, content)
.map_err(|e| ApiError::Internal(format!("Failed to write {rel_path}: {e}")))?;
}

let args = vec![
format!("{runtime_name}-runtime"),
"run".to_string(),
entry.to_string(),
];
execute_wasm_bytes_with_env(
&fetch_runtime_bytes(runtime_name)?,
wasi_env,
None,
args,
max_memory_pages,
)
.map_err(|e| ApiError::Internal(e.to_string()))
}

fn fetch_runtime_bytes(runtime_name: &str) -> std::result::Result<Vec<u8>, ApiError> {
let cache = RuntimeCache::new()
.map_err(|e| ApiError::Internal(format!("Runtime cache unavailable: {e}")))?;
cache
.get_runtime(runtime_name)
.map_err(|e| ApiError::Internal(format!("Failed to fetch {runtime_name} runtime: {e}")))
}

/// Reject filenames that would escape the session work_dir or are unusable
/// in the runtime's WASI view (absolute paths, parent traversal, empty).
fn validate_project_filename(name: &str) -> std::result::Result<(), ApiError> {
if name.is_empty() {
return Err(ApiError::BadRequest("Empty filename in 'files'".into()));
}
let path = Path::new(name);
if path.is_absolute() {
return Err(ApiError::BadRequest(format!(
"Absolute path not allowed in 'files': {name}",
)));
}
for component in path.components() {
match component {
Component::ParentDir => {
return Err(ApiError::BadRequest(format!(
"Path traversal not allowed in 'files': {name}",
)));
}
Component::Prefix(_) | Component::RootDir => {
return Err(ApiError::BadRequest(format!(
"Absolute path not allowed in 'files': {name}",
)));
}
_ => {}
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -92,4 +181,90 @@ mod tests {
assert!(resolve_runtime("JS").is_err());
assert!(resolve_runtime("NodeJS").is_err());
}

#[test]
fn test_validate_project_filename_accepts_plain() {
assert!(validate_project_filename("main.js").is_ok());
assert!(validate_project_filename("utils.js").is_ok());
assert!(validate_project_filename("lib/helper.js").is_ok());
assert!(validate_project_filename("a/b/c/d.js").is_ok());
}

#[test]
fn test_validate_project_filename_rejects_empty() {
let err = validate_project_filename("").unwrap_err();
assert_eq!(err.status_code(), 400);
}

#[test]
fn test_validate_project_filename_rejects_absolute() {
let err = validate_project_filename("/etc/passwd").unwrap_err();
assert_eq!(err.status_code(), 400);
}

#[test]
fn test_validate_project_filename_rejects_traversal() {
assert!(validate_project_filename("../escape.js").is_err());
assert!(validate_project_filename("sub/../../escape.js").is_err());
assert!(validate_project_filename("a/b/../../../escape.js").is_err());
}

#[test]
fn test_execute_source_project_rejects_empty_files() {
let env = Arc::new(Mutex::new(WasiEnv::new()));
let tmp = std::env::temp_dir();
let err = execute_source_project(&HashMap::new(), "main.js", "javascript", env, &tmp, None)
.unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("empty"));
}

#[test]
fn test_execute_source_project_rejects_missing_entry() {
let env = Arc::new(Mutex::new(WasiEnv::new()));
let tmp = std::env::temp_dir();
let mut files = HashMap::new();
files.insert("a.js".to_string(), "1".to_string());
let err =
execute_source_project(&files, "main.js", "javascript", env, &tmp, None).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("Entry"));
}

#[test]
fn test_execute_source_project_rejects_unsupported_language() {
let env = Arc::new(Mutex::new(WasiEnv::new()));
let tmp = std::env::temp_dir();
let mut files = HashMap::new();
files.insert("main.py".to_string(), "print(1)".to_string());
let err = execute_source_project(&files, "main.py", "python", env, &tmp, None).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("python"));
}

#[test]
fn test_execute_source_project_rejects_path_traversal_in_files() {
// Create an isolated work_dir so the test never touches real files
let tmp = std::env::temp_dir().join(format!(
"wasmrun_proj_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();

let env = Arc::new(Mutex::new(WasiEnv::new()));
let mut files = HashMap::new();
files.insert("main.js".to_string(), "console.log(1)".to_string());
files.insert("../evil.js".to_string(), "pwned".to_string());

let err =
execute_source_project(&files, "main.js", "javascript", env, &tmp, None).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("traversal"));

let _ = std::fs::remove_dir_all(&tmp);
}
}
132 changes: 130 additions & 2 deletions src/agent/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,35 @@ impl AgentServer {

let (tx, rx) = std::sync::mpsc::channel::<std::result::Result<i32, ApiError>>();

if let Some(source) = req.source {
if let Some(files) = req.files {
// Multi-file source project: write all files and run entry through runtime
let lang = req.language.unwrap_or_else(|| "javascript".into());
executor::resolve_runtime(&lang)?;
let entry = req
.entry
.clone()
.ok_or_else(|| ApiError::BadRequest("'entry' is required with 'files'".into()))?;
if !files.contains_key(&entry) {
return Err(ApiError::BadRequest(format!(
"Entry '{entry}' not found in 'files' map"
)));
}
let work_dir_clone = work_dir.clone();
std::thread::Builder::new()
.stack_size(EXEC_THREAD_STACK_BYTES)
.spawn(move || {
let result = executor::execute_source_project(
&files,
&entry,
&lang,
exec_env,
&work_dir_clone,
max_pages,
);
let _ = tx.send(result);
})
.map_err(|e| ApiError::Internal(format!("Failed to spawn exec thread: {e}")))?;
} else if let Some(source) = req.source {
// Source execution: write code to session FS and run via language runtime
let lang = req.language.unwrap_or_else(|| "javascript".into());
// Validate language before spawning so callers get a 400 immediately
Expand Down Expand Up @@ -335,7 +363,9 @@ impl AgentServer {
})
.map_err(|e| ApiError::Internal(format!("Failed to spawn exec thread: {e}")))?;
} else {
return Err(ApiError::BadRequest("Missing wasm_path or source".into()));
return Err(ApiError::BadRequest(
"Missing wasm_path, source, or files".into(),
));
}

let duration_ms;
Expand Down Expand Up @@ -940,6 +970,104 @@ mod tests {
server.session_manager.destroy_all().unwrap();
}

#[test]
fn test_exec_files_without_entry_returns_400() {
let server = test_server();
let id = server.handle_create_session().unwrap().session_id;

let body = r#"{"files": {"main.js": "console.log(1)"}}"#;
let err = server.handle_exec(&id, body).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("entry"));

server.session_manager.destroy_all().unwrap();
}

#[test]
fn test_exec_files_with_unknown_entry_returns_400() {
let server = test_server();
let id = server.handle_create_session().unwrap().session_id;

let body = r#"{"files": {"main.js": "x"}, "entry": "missing.js"}"#;
let err = server.handle_exec(&id, body).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("missing.js"));

server.session_manager.destroy_all().unwrap();
}

#[test]
fn test_exec_files_with_unsupported_language_returns_400() {
let server = test_server();
let id = server.handle_create_session().unwrap().session_id;

let body = r#"{"files": {"a.py": "print(1)"}, "entry": "a.py", "language": "python"}"#;
let err = server.handle_exec(&id, body).unwrap_err();
assert_eq!(err.status_code(), 400);
assert!(err.to_string().contains("python"));

server.session_manager.destroy_all().unwrap();
}

/// Integration test: fetches the nodejs runtime from wasmhub and verifies
/// that all files in a multi-file project are written to the session FS and
/// the entry file executes. Sibling files are visible in the session
/// directory; whether they can be loaded depends on the runtime's module
/// system (the QuickJS-based nodejs runtime currently lacks `require`).
///
/// Ignored by default so the test suite stays offline-friendly. Run with:
/// cargo test --release --bin wasmrun multi_file_js_project_integration -- --ignored --nocapture
#[test]
#[ignore]
fn test_multi_file_js_project_integration() {
let server = test_server();
let id = server.handle_create_session().unwrap().session_id;

let body = r#"{
"files": {
"main.js": "console.log('main-ran');",
"extra.js": "// sibling file, just present"
},
"entry": "main.js",
"timeout": 60
}"#;
let resp = server.handle_exec(&id, body).unwrap();
assert_eq!(
resp.exit_code, 0,
"exit_code != 0; stderr: {}; error: {:?}",
resp.stderr, resp.error
);
assert!(
resp.stdout.contains("main-ran"),
"stdout did not contain expected output: {:?}",
resp.stdout
);

// Verify the sibling file was actually written to the session FS
let extra = server.handle_read_file(&id, "extra.js").unwrap();
assert!(extra.content.contains("sibling file"));

server.session_manager.destroy_all().unwrap();
}

#[test]
fn test_exec_files_routes_to_project_execution() {
let server = test_server();
let id = server.handle_create_session().unwrap().session_id;

// With valid files+entry, request should reach the execution stage and
// return Ok (any runtime fetch failure surfaces as ExecResponse.error,
// not an ApiError from handle_exec itself).
let body = r#"{"files": {"main.js": "console.log('ok')"}, "entry": "main.js"}"#;
let result = server.handle_exec(&id, body);
assert!(
result.is_ok(),
"valid files+entry should not return ApiError, got: {result:?}"
);

server.session_manager.destroy_all().unwrap();
}

#[test]
fn test_exec_source_defaults_to_javascript() {
let server = test_server();
Expand Down
Loading
Loading