diff --git a/README.md b/README.md index bfaa6f0..6f27bc1 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Configure your tasks with these parameters: | `ram` | `str` | Memory limit for the task | `"512MB"`, `"2GB"` | | `timeout` | `str` | Maximum execution time | `"30s"`, `"5m"`, `"1h"` | | `max_retries` | `int` | Number of retry attempts on failure (default: 1) | `3` | +| `allowed_files` | `list` | Files or folders accessible in the sandbox | `["./data", "./output"]` | ### Compute Levels @@ -185,7 +186,72 @@ def main() -> dict: #### TypeScript / JavaScript -Capsule also provides an HTTP client for TypeScript/JavaScript via `@capsule-run/sdk`. However, standard libraries like `fetch` already compatible, so you can use whichever approach you prefer. +Standard libraries like `fetch` are already compatible, so no custom HTTP client is needed for TypeScript/JavaScript. + +```typescript +import { task } from "@capsule-run/sdk"; + +export const main = task({ + name: "main", + compute: "MEDIUM" +}, async () => { + const response = await fetch("https://api.example.com/data"); + return response.json(); +}); +``` + +### File Access + +The **entry point task** (main) has access to the entire project directory. Sub-tasks have **no filesystem access by default** and must declare `allowed_files` to access specific paths. + +> [!NOTE] +> Currently, `allowed_files` only supports directory paths, not individual files. + +#### Python + +Python's standard file operations work normally. Use `open()`, `os`, `pathlib`, or any file manipulation library. + +```python +from capsule import task + +@task(name="restricted_writer", allowed_files=["./output"]) # Sub-task with limited access +def restricted_writer() -> None: + with open("./output/result.txt", "w") as f: + f.write("result") + +@task(name="main") # Has access to entire project +def main() -> str: + restricted_writer() +``` + +#### TypeScript / JavaScript + +Node.js built-ins like `fs` are not available in the WebAssembly sandbox. Instead, use the `files` API provided by the SDK. + +```typescript +import { task, files } from "@capsule-run/sdk"; + +export const restrictedWriter = task({ + name: "restricted_writer", + allowedFiles: ["./output"] +}, async () => { + await files.writeText("./output/result.txt", "result"); +}); + +export const main = task({ name: "main" }, async () => { + restrictedWriter(); + return await files.readText("./data/input.txt"); +}); +``` + +Available methods: +- `files.readText(path)` — Read file as string +- `files.readBytes(path)` — Read file as `Uint8Array` +- `files.writeText(path, content)` — Write string to file +- `files.writeBytes(path, data)` — Write bytes to file +- `files.list(path)` — List directory contents +- `files.exists(path)` — Check if file exists + ## Compatibility diff --git a/ROADMAP.md b/ROADMAP.md index e982424..34f6114 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,11 +35,11 @@ This document tracks the development status of Capsule. ## v0.3.0: Data Access -**Status:** 📅 Planned +**Status:** 📅 In Progress **Goal:** Enable agents to work with local files and datasets. -- [ ] **Filesystem:** Local file mounting (`fs_access`) for reading images, CSVs, and datasets. +- [x] **Filesystem:** Local file mounting (`fs_access`) for reading images, CSVs, and datasets. --- diff --git a/crates/capsule-cli/src/commands/run.rs b/crates/capsule-cli/src/commands/run.rs index 572c295..f7a287a 100644 --- a/crates/capsule-cli/src/commands/run.rs +++ b/crates/capsule-cli/src/commands/run.rs @@ -105,9 +105,19 @@ pub async fn execute( }; let runtime = Runtime::with_config(runtime_config)?; - let execution_policy = ExecutionPolicy::default().compute(Some(Compute::Custom(u64::MAX))); + let execution_policy = ExecutionPolicy::default() + .compute(Some(Compute::Custom(u64::MAX))) + .allowed_files(vec![".".to_string()]); + + let project_root = file_path + .canonicalize() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let create_instance_command = CreateInstance::new(execution_policy.clone(), args.clone()) - .wasm_path(compile_result.wasm_path); + .wasm_path(compile_result.wasm_path) + .project_root(project_root); let (store, instance, task_id) = runtime.execute(create_instance_command).await?; reporter.finish_progress(Some("Runtime launched")); diff --git a/crates/capsule-core/src/wasm/commands/create.rs b/crates/capsule-core/src/wasm/commands/create.rs index c25d94f..501eec9 100644 --- a/crates/capsule-core/src/wasm/commands/create.rs +++ b/crates/capsule-core/src/wasm/commands/create.rs @@ -5,14 +5,15 @@ use nanoid::nanoid; use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::{Store, StoreLimitsBuilder}; -use wasmtime_wasi::WasiCtxBuilder; use wasmtime_wasi::add_to_linker_async; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder}; use wasmtime_wasi_http::WasiHttpCtx; use crate::config::log::{CreateInstanceLog, InstanceState, UpdateInstanceLog}; use crate::wasm::execution_policy::ExecutionPolicy; use crate::wasm::runtime::{Runtime, RuntimeCommand, WasmRuntimeError}; use crate::wasm::state::{CapsuleAgent, State, capsule}; +use crate::wasm::utilities::path_validator::{FileAccessMode, validate_path}; pub struct CreateInstance { pub policy: ExecutionPolicy, @@ -22,6 +23,7 @@ pub struct CreateInstance { pub agent_name: String, pub agent_version: String, pub wasm_path: PathBuf, + pub project_root: PathBuf, } impl CreateInstance { @@ -34,6 +36,7 @@ impl CreateInstance { agent_name: "default".to_string(), agent_version: "0.0.0".to_string(), wasm_path: PathBuf::from(".capsule/capsule.wasm"), + project_root: std::env::current_dir().unwrap_or_default(), } } @@ -56,6 +59,11 @@ impl CreateInstance { self.wasm_path = wasm_path; self } + + pub fn project_root(mut self, project_root: PathBuf) -> Self { + self.project_root = project_root; + self + } } impl RuntimeCommand for CreateInstance { @@ -85,11 +93,39 @@ impl RuntimeCommand for CreateInstance { capsule::host::api::add_to_linker(&mut linker, |state: &mut State| state)?; - let wasi = WasiCtxBuilder::new() + let mut wasi_builder = WasiCtxBuilder::new(); + wasi_builder .inherit_stdout() .inherit_stderr() - .args(&self.args) - .build(); + .args(&self.args); + + for path_spec in &self.policy.allowed_files { + match validate_path(path_spec, &self.project_root) { + Ok(parsed) => { + let (dir_perms, file_perms) = match parsed.mode { + FileAccessMode::ReadOnly => (DirPerms::READ, FilePerms::READ), + FileAccessMode::ReadWrite => (DirPerms::all(), FilePerms::all()), + }; + + if let Err(e) = wasi_builder.preopened_dir( + &parsed.path, + &parsed.guest_path, + dir_perms, + file_perms, + ) { + return Err(WasmRuntimeError::FilesystemError(format!( + "Failed to preopen '{}': {}", + path_spec, e + ))); + } + } + Err(e) => { + return Err(WasmRuntimeError::FilesystemError(e.to_string())); + } + } + } + + let wasi = wasi_builder.build(); let mut limits = StoreLimitsBuilder::new(); diff --git a/crates/capsule-core/src/wasm/compiler/javascript.rs b/crates/capsule-core/src/wasm/compiler/javascript.rs index f8fecb6..1ae9d83 100644 --- a/crates/capsule-core/src/wasm/compiler/javascript.rs +++ b/crates/capsule-core/src/wasm/compiler/javascript.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use super::CAPSULE_WIT; +use crate::wasm::utilities::wit_manager::WitManager; #[derive(Debug)] pub enum JavascriptWasmCompilerError { @@ -103,11 +103,15 @@ impl JavascriptWasmCompiler { let wrapper_content = format!( r#"// Auto-generated bootloader for Capsule - import * as hostApi from 'capsule:host/api'; - globalThis['capsule:host/api'] = hostApi; - import '{}'; - import {{ exports }} from '{}/dist/app.js'; - export const taskRunner = exports; +import * as hostApi from 'capsule:host/api'; +import * as fsTypes from 'wasi:filesystem/types@0.2.0'; +import * as fsPreopens from 'wasi:filesystem/preopens@0.2.0'; +globalThis['capsule:host/api'] = hostApi; +globalThis['wasi:filesystem/types'] = fsTypes; +globalThis['wasi:filesystem/preopens'] = fsPreopens; +import '{}'; +import {{ exports }} from '{}/dist/app.js'; +export const taskRunner = exports; "#, import_path, sdk_path_str ); @@ -127,6 +131,7 @@ impl JavascriptWasmCompiler { .arg("--format=esm") .arg("--platform=neutral") .arg("--external:capsule:host/api") + .arg("--external:wasi:filesystem/*") .arg(format!("--outfile={}", bundled_path_normalized.display())) .current_dir(&sdk_path_normalized) .stdout(Stdio::piped()) @@ -177,11 +182,9 @@ impl JavascriptWasmCompiler { } let wit_dir = self.cache_dir.join("wit"); - let wit_file = wit_dir.join("capsule.wit"); - if !wit_file.exists() { - fs::create_dir_all(&wit_dir)?; - fs::write(&wit_file, CAPSULE_WIT)?; + if !wit_dir.join("capsule.wit").exists() { + WitManager::import_wit_deps(&wit_dir)?; } Ok(wit_dir) diff --git a/crates/capsule-core/src/wasm/compiler/mod.rs b/crates/capsule-core/src/wasm/compiler/mod.rs index a1e62fc..b49f333 100644 --- a/crates/capsule-core/src/wasm/compiler/mod.rs +++ b/crates/capsule-core/src/wasm/compiler/mod.rs @@ -1,4 +1,2 @@ -pub const CAPSULE_WIT: &str = include_str!("../../../../capsule-wit/capsule.wit"); - pub mod javascript; pub mod python; diff --git a/crates/capsule-core/src/wasm/compiler/python.rs b/crates/capsule-core/src/wasm/compiler/python.rs index b1a96c4..7e7fdaf 100644 --- a/crates/capsule-core/src/wasm/compiler/python.rs +++ b/crates/capsule-core/src/wasm/compiler/python.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::process::Stdio; -use super::CAPSULE_WIT; +use crate::wasm::utilities::wit_manager::WitManager; pub enum PythonWasmCompilerError { CompileFailed(String), @@ -248,11 +248,9 @@ from capsule.app import TaskRunner, exports } let wit_dir = self.cache_dir.join("wit"); - let wit_file = wit_dir.join("capsule.wit"); - if !wit_file.exists() { - fs::create_dir_all(&wit_dir)?; - fs::write(&wit_file, CAPSULE_WIT)?; + if !wit_dir.join("capsule.wit").exists() { + WitManager::import_wit_deps(&wit_dir)?; } Ok(wit_dir) diff --git a/crates/capsule-core/src/wasm/execution_policy.rs b/crates/capsule-core/src/wasm/execution_policy.rs index 9edc538..56c2192 100644 --- a/crates/capsule-core/src/wasm/execution_policy.rs +++ b/crates/capsule-core/src/wasm/execution_policy.rs @@ -32,6 +32,8 @@ pub struct ExecutionPolicy { pub ram: Option, pub timeout: Option, pub max_retries: u64, + #[serde(default)] + pub allowed_files: Vec, } impl Default for ExecutionPolicy { @@ -42,6 +44,7 @@ impl Default for ExecutionPolicy { ram: None, timeout: None, max_retries: 0, + allowed_files: Vec::new(), } } } @@ -87,6 +90,11 @@ impl ExecutionPolicy { .as_ref() .and_then(|s| humantime::parse_duration(s).ok()) } + + pub fn allowed_files(mut self, files: Vec) -> Self { + self.allowed_files = files; + self + } } #[cfg(test)] @@ -100,12 +108,14 @@ mod tests { .compute(None) .ram(Some(128)) .timeout(Some("60s".to_string())) - .max_retries(Some(3)); + .max_retries(Some(3)) + .allowed_files(vec!["/etc/passwd".to_string()]); assert_eq!(policy.name, "test"); assert_eq!(policy.compute, Compute::Medium); assert_eq!(policy.ram, Some(128)); assert_eq!(policy.timeout, Some("60s".to_string())); assert_eq!(policy.max_retries, 3); + assert_eq!(policy.allowed_files, vec!["/etc/passwd".to_string()]); } } diff --git a/crates/capsule-core/src/wasm/runtime.rs b/crates/capsule-core/src/wasm/runtime.rs index c9c37f8..465c2ee 100644 --- a/crates/capsule-core/src/wasm/runtime.rs +++ b/crates/capsule-core/src/wasm/runtime.rs @@ -13,6 +13,7 @@ pub enum WasmRuntimeError { WasmtimeError(wasmtime::Error), LogError(LogError), ConfigError(String), + FilesystemError(String), Timeout, } @@ -24,6 +25,9 @@ impl fmt::Display for WasmRuntimeError { } WasmRuntimeError::LogError(msg) => write!(f, "Runtime error > {}", msg), WasmRuntimeError::ConfigError(msg) => write!(f, "Runtime error > Config > {}", msg), + WasmRuntimeError::FilesystemError(msg) => { + write!(f, "Runtime error > Filesystem > {}", msg) + } WasmRuntimeError::Timeout => write!(f, "Timed out"), } } diff --git a/crates/capsule-core/src/wasm/utilities/mod.rs b/crates/capsule-core/src/wasm/utilities/mod.rs index 2ed21b6..9020e10 100644 --- a/crates/capsule-core/src/wasm/utilities/mod.rs +++ b/crates/capsule-core/src/wasm/utilities/mod.rs @@ -1,2 +1,4 @@ +pub mod path_validator; pub mod task_config; pub mod task_reporter; +pub mod wit_manager; diff --git a/crates/capsule-core/src/wasm/utilities/path_validator.rs b/crates/capsule-core/src/wasm/utilities/path_validator.rs new file mode 100644 index 0000000..e1ad0e7 --- /dev/null +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -0,0 +1,175 @@ +use std::error::Error; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FileAccessMode { + ReadOnly, + + #[default] + ReadWrite, +} + +#[derive(Debug)] +pub struct ParsedPath { + pub path: PathBuf, + pub guest_path: String, + pub mode: FileAccessMode, +} + +#[derive(Debug)] +pub enum PathValidationError { + AbsolutePathNotAllowed(String), + EscapesProjectDirectory(String), + PathNotFound(String), + InvalidMode(String), +} + +impl fmt::Display for PathValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PathValidationError::AbsolutePathNotAllowed(path) => { + write!(f, "Absolute paths are not allowed: {}", path) + } + PathValidationError::EscapesProjectDirectory(path) => { + write!(f, "Path escapes project directory: {}", path) + } + PathValidationError::PathNotFound(path) => { + write!(f, "Path does not exist: {}", path) + } + PathValidationError::InvalidMode(mode) => { + write!( + f, + "Invalid access mode '{}'. Use :ro (read-only) or :rw (read-write)", + mode + ) + } + } + } +} + +impl Error for PathValidationError {} + +fn parse_path_with_mode(path_spec: &str) -> (String, FileAccessMode) { + if let Some(pos) = path_spec.rfind(':') { + let (path, mode_str) = path_spec.split_at(pos); + let mode = &mode_str[1..]; + + match mode { + "ro" => (path.to_string(), FileAccessMode::ReadOnly), + "rw" => (path.to_string(), FileAccessMode::ReadWrite), + _ => (path_spec.to_string(), FileAccessMode::default()), + } + } else { + (path_spec.to_string(), FileAccessMode::default()) + } +} + +pub fn validate_path( + path_spec: &str, + project_root: &Path, +) -> Result { + let (path_str, mode) = parse_path_with_mode(path_spec); + let p = Path::new(&path_str); + + if p.is_absolute() { + return Err(PathValidationError::AbsolutePathNotAllowed(path_str)); + } + + let joined = project_root.join(p); + let resolved = joined + .canonicalize() + .map_err(|_| PathValidationError::PathNotFound(path_str.clone()))?; + + let canonical_root = project_root + .canonicalize() + .map_err(|_| PathValidationError::EscapesProjectDirectory(path_str.clone()))?; + + if !resolved.starts_with(&canonical_root) { + return Err(PathValidationError::EscapesProjectDirectory(path_str)); + } + + Ok(ParsedPath { + path: resolved, + guest_path: path_str, + mode, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_absolute_path_rejected() { + let temp = std::env::temp_dir(); + + let result = validate_path("/absolute/path", &temp); + assert!(matches!( + result, + Err(PathValidationError::AbsolutePathNotAllowed(_)) + )); + } + + #[test] + fn test_relative_path_works() { + let current = std::env::current_dir().unwrap(); + + let test_dir = current.join(".capsule_test"); + let _ = fs::create_dir(&test_dir); + + let result = validate_path("./.capsule_test", ¤t); + + let _ = fs::remove_dir(&test_dir); + + assert!(result.is_ok()); + let parsed = result.unwrap(); + assert_eq!(parsed.guest_path, "./.capsule_test"); + } + + #[test] + fn test_non_existent_path_fails() { + let current = std::env::current_dir().unwrap(); + + let result = validate_path("./nonexistent_dir", ¤t); + assert!(matches!(result, Err(PathValidationError::PathNotFound(_)))); + } + + #[test] + fn test_escape_project_root_rejected() { + let temp = std::env::temp_dir(); + let subdir = temp.join("test_subdir"); + let _ = fs::create_dir(&subdir); + + let result = validate_path("../", &subdir); + + let _ = fs::remove_dir(&subdir); + + assert!(matches!( + result, + Err(PathValidationError::EscapesProjectDirectory(_)) + )); + } + + #[test] + fn test_parse_mode_readonly() { + let (path, mode) = parse_path_with_mode("./data:ro"); + assert_eq!(path, "./data"); + assert_eq!(mode, FileAccessMode::ReadOnly); + } + + #[test] + fn test_parse_mode_readwrite() { + let (path, mode) = parse_path_with_mode("./output:rw"); + assert_eq!(path, "./output"); + assert_eq!(mode, FileAccessMode::ReadWrite); + } + + #[test] + fn test_parse_mode_default() { + let (path, mode) = parse_path_with_mode("./data"); + assert_eq!(path, "./data"); + assert_eq!(mode, FileAccessMode::ReadWrite); + } +} diff --git a/crates/capsule-core/src/wasm/utilities/task_config.rs b/crates/capsule-core/src/wasm/utilities/task_config.rs index 3c46d5c..f8207cd 100644 --- a/crates/capsule-core/src/wasm/utilities/task_config.rs +++ b/crates/capsule-core/src/wasm/utilities/task_config.rs @@ -11,6 +11,9 @@ pub struct TaskConfig { #[serde(alias = "maxRetries")] max_retries: Option, + + #[serde(alias = "allowedFiles")] + allowed_files: Option>, } impl TaskConfig { @@ -36,6 +39,7 @@ impl TaskConfig { .ram(ram) .timeout(self.timeout.clone()) .max_retries(self.max_retries) + .allowed_files(self.allowed_files.clone().unwrap_or_default()) } pub fn parse_ram_string(s: &str) -> Option { @@ -131,6 +135,7 @@ mod tests { ram: Some("2GB".to_string()), timeout: Some("30s".to_string()), max_retries: Some(3), + allowed_files: Some(vec!["./data/input.txt".to_string()]), }; let policy = config.to_execution_policy(); diff --git a/crates/capsule-core/src/wasm/utilities/wit_manager.rs b/crates/capsule-core/src/wasm/utilities/wit_manager.rs new file mode 100644 index 0000000..b074575 --- /dev/null +++ b/crates/capsule-core/src/wasm/utilities/wit_manager.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::io; +use std::path::Path; + +pub const CAPSULE_WIT: &str = include_str!("../../../../capsule-wit/capsule.wit"); + +pub const WASI_DEPS: &[(&str, &str, &str)] = &[ + // filesystem deps + ( + "filesystem", + "types.wit", + include_str!("../../../../capsule-wit/deps/filesystem/types.wit"), + ), + ( + "filesystem", + "preopens.wit", + include_str!("../../../../capsule-wit/deps/filesystem/preopens.wit"), + ), + ( + "filesystem", + "world.wit", + include_str!("../../../../capsule-wit/deps/filesystem/world.wit"), + ), + // io deps (required by filesystem) + ( + "io", + "error.wit", + include_str!("../../../../capsule-wit/deps/io/error.wit"), + ), + ( + "io", + "poll.wit", + include_str!("../../../../capsule-wit/deps/io/poll.wit"), + ), + ( + "io", + "streams.wit", + include_str!("../../../../capsule-wit/deps/io/streams.wit"), + ), + ( + "io", + "world.wit", + include_str!("../../../../capsule-wit/deps/io/world.wit"), + ), + // clocks deps (required by filesystem) + ( + "clocks", + "monotonic-clock.wit", + include_str!("../../../../capsule-wit/deps/clocks/monotonic-clock.wit"), + ), + ( + "clocks", + "wall-clock.wit", + include_str!("../../../../capsule-wit/deps/clocks/wall-clock.wit"), + ), + ( + "clocks", + "world.wit", + include_str!("../../../../capsule-wit/deps/clocks/world.wit"), + ), +]; + +pub struct WitManager {} + +impl WitManager { + pub fn import_wit_deps(wit_dir: &Path) -> Result<(), io::Error> { + fs::create_dir_all(wit_dir)?; + fs::write(wit_dir.join("capsule.wit"), CAPSULE_WIT)?; + + for (pkg, filename, content) in WASI_DEPS { + let pkg_dir = wit_dir.join("deps").join(pkg); + fs::create_dir_all(&pkg_dir)?; + fs::write(pkg_dir.join(filename), content)?; + } + + Ok(()) + } +} diff --git a/crates/capsule-sdk/javascript/src/app.ts b/crates/capsule-sdk/javascript/src/app.ts index 9749a4c..939f48a 100644 --- a/crates/capsule-sdk/javascript/src/app.ts +++ b/crates/capsule-sdk/javascript/src/app.ts @@ -13,6 +13,7 @@ export interface TaskConfig { ram?: string; timeout?: string; maxRetries?: number; + allowedFiles?: string[]; } const TASKS: Map> = new Map(); @@ -55,12 +56,6 @@ interface TaskArgs { kwargs?: Record; } -interface TaskResult { - result?: any; - error?: string; - traceback?: string; -} - /** * Implementation of the capsule:host/task-runner interface. * diff --git a/crates/capsule-sdk/javascript/src/files.ts b/crates/capsule-sdk/javascript/src/files.ts new file mode 100644 index 0000000..35b8ad8 --- /dev/null +++ b/crates/capsule-sdk/javascript/src/files.ts @@ -0,0 +1,217 @@ +/** + * Capsule Files API for WASM filesystem access. + */ + +declare const globalThis: { + 'wasi:filesystem/types': any; + 'wasi:filesystem/preopens': any; +}; + +interface Descriptor { + read(length: bigint, offset: bigint): [Uint8Array, boolean]; + write(buffer: Uint8Array, offset: bigint): bigint; + stat(): { size: bigint }; + readDirectory(): any; + openAt( + pathFlags: { symlinkFollow?: boolean }, + path: string, + openFlags: { create?: boolean; directory?: boolean; exclusive?: boolean; truncate?: boolean }, + descriptorFlags: { read?: boolean; write?: boolean; mutateDirectory?: boolean } + ): Descriptor; +} + +interface PreopenedDir { + descriptor: Descriptor; + guestPath: string; +} + +function getFsBindings(): { types: any; preopens: any } | null { + try { + const types = globalThis['wasi:filesystem/types']; + const preopens = globalThis['wasi:filesystem/preopens']; + if (types && preopens) { + return { types, preopens }; + } + } catch {} + return null; +} + +function getPreopenedDirs(): PreopenedDir[] { + const fs = getFsBindings(); + if (!fs) return []; + + try { + const dirs = fs.preopens.getDirectories(); + return (dirs || []).map((entry: [Descriptor, string]) => ({ + descriptor: entry[0], + guestPath: entry[1] + })); + } catch { + return []; + } +} + +function normalizePath(path: string): string { + if (path.startsWith('./')) { + return path.slice(2); + } + return path; +} + +function resolvePath(path: string): { dir: Descriptor; relativePath: string } | null { + const preopens = getPreopenedDirs(); + if (preopens.length === 0) return null; + + const normalizedPath = normalizePath(path); + + for (const { descriptor, guestPath } of preopens) { + const normalizedGuest = normalizePath(guestPath); + + if (normalizedGuest === '.' || normalizedGuest === '') { + return { dir: descriptor, relativePath: normalizedPath }; + } + + if (normalizedPath.startsWith(normalizedGuest + '/')) { + const relativePath = normalizedPath.slice(normalizedGuest.length + 1); + return { dir: descriptor, relativePath }; + } + + if (normalizedPath === normalizedGuest) { + return { dir: descriptor, relativePath: '.' }; + } + } + + return { dir: preopens[0].descriptor, relativePath: normalizedPath }; +} + +/** + * Read a file as text. + * + * @param path - The path to read. + * @returns A promise that resolves to a string containing the file contents. + */ +export async function readText(path: string): Promise { + const bytes = await readBytes(path); + return new TextDecoder().decode(bytes); +} + +/** + * Read a file as bytes. + * + * @param path - The path to read. + * @returns A promise that resolves to a Uint8Array containing the file contents. + */ +export async function readBytes(path: string): Promise { + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); + } + + try { + const pathFlags = { symlinkFollow: false }; + const openFlags = {}; + const descriptorFlags = { read: true }; + + const fd = resolved.dir.openAt(pathFlags, resolved.relativePath, openFlags, descriptorFlags); + const stat = fd.stat(); + const [data] = fd.read(stat.size, BigInt(0)); + return data; + } catch (e) { + throw new Error(`Failed to read file '${path}': ${e}`); + } +} + +/** + * Write text content to a file. + * + * @param path - The path to write. + * @param content - The text content to write. + */ +export async function writeText(path: string, content: string): Promise { + const bytes = new TextEncoder().encode(content); + await writeBytes(path, bytes); +} + +/** + * Write bytes to a file. + * + * @param path - The path to write. + * @param data - The bytes to write. + */ +export async function writeBytes(path: string, data: Uint8Array): Promise { + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); + } + + try { + const pathFlags = { symlinkFollow: false }; + const openFlags = { create: true, truncate: true }; + const descriptorFlags = { write: true }; + + const fd = resolved.dir.openAt(pathFlags, resolved.relativePath, openFlags, descriptorFlags); + fd.write(data, BigInt(0)); + } catch (e) { + throw new Error(`Failed to write file '${path}': ${e}`); + } +} + +/** + * List files/directories at a path. + * + * @param path - The path to list. + * @returns A promise that resolves to an array of strings representing the files and directories at the specified path. + */ +export async function list(path: string = "."): Promise { + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); + } + + try { + let targetDir = resolved.dir; + if (resolved.relativePath !== ".") { + const pathFlags = { symlinkFollow: false }; + const openFlags = { directory: true }; + const descriptorFlags = { read: true }; + targetDir = resolved.dir.openAt(pathFlags, resolved.relativePath, openFlags, descriptorFlags); + } + + const stream = targetDir.readDirectory(); + const entries: string[] = []; + + let entry; + while ((entry = stream.readDirectoryEntry()) !== undefined && entry !== null) { + if (entry.name) { + entries.push(entry.name); + } + } + + return entries; + } catch (e) { + throw new Error(`Failed to list directory '${path}': ${e}`); + } +} + +/** + * Check if a file or directory exists. + * + * @param path - The path to check. + * @returns A promise that resolves to a boolean indicating whether the file or directory exists. + */ +export async function exists(path: string): Promise { + const resolved = resolvePath(path); + if (!resolved) { + return false; + } + + try { + const pathFlags = { symlinkFollow: false }; + const openFlags = {}; + const descriptorFlags = { read: true }; + resolved.dir.openAt(pathFlags, resolved.relativePath, openFlags, descriptorFlags); + return true; + } catch { + return false; + } +} diff --git a/crates/capsule-sdk/javascript/src/http.ts b/crates/capsule-sdk/javascript/src/http.ts deleted file mode 100644 index d84044a..0000000 --- a/crates/capsule-sdk/javascript/src/http.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Capsule SDK - HTTP Client - * - * This module provides HTTP request functions by calling the host's HTTP implementation. - * In WASM mode, requests go through the Rust host. - * In local mode, uses native fetch for testing. - */ - -import { isWasmMode } from "./hostApi.js"; - -export interface RequestOptions { - headers?: Record; - body?: string; - json?: any; -} - -/** - * HTTP Response wrapper with convenient methods. - */ -export class Response { - readonly status: number; - readonly headers: Record; - readonly body: string; - - constructor(status: number, headers: Record, body: string) { - this.status = status; - this.headers = headers; - this.body = body; - } - - /** - * Parse response body as JSON. - */ - json(): T { - return JSON.parse(this.body); - } - - /** - * Get response body as text. - */ - text(): string { - return this.body; - } - - /** - * Check if response status is 2xx. - */ - ok(): boolean { - return this.status >= 200 && this.status < 300; - } - - toString(): string { - return ``; - } -} - -/** - * Internal function to make HTTP requests. - * In WASM mode: calls the host API - * In local mode: uses fetch - */ -async function makeRequest( - method: string, - url: string, - options: RequestOptions = {} -): Promise { - return isWasmMode() - ? makeHostRequest(method, url, options) - : makeLocalRequest(method, url, options); -} - -/** - * Make HTTP request using native fetch (local mode only). - */ -async function makeLocalRequest( - method: string, - url: string, - options: RequestOptions = {} -): Promise { - const headers: Record = { ...options.headers }; - let body: string | undefined = options.body; - - if (options.json !== undefined) { - body = JSON.stringify(options.json); - headers["Content-Type"] = "application/json"; - } - - const fetchOptions: RequestInit = { - method, - headers, - }; - - if (body !== undefined) { - fetchOptions.body = body; - } - - const fetchResponse = await fetch(url, fetchOptions); - const responseText = await fetchResponse.text(); - - const responseHeaders: Record = {}; - fetchResponse.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - return new Response(fetchResponse.status, responseHeaders, responseText); -} - -/** - * Make HTTP request via the Rust host (WASM mode). - */ -function makeHostRequest( - method: string, - url: string, - options: RequestOptions = {} -): Response { - try { - const hostModule = (globalThis as any)["capsule:host/api"]; - - if (!hostModule || !hostModule.httpRequest) { - throw new Error("Host HTTP API not available"); - } - - const headers: [string, string][] = []; - const requestHeaders = options.headers || {}; - - for (const [key, value] of Object.entries(requestHeaders)) { - headers.push([key, value]); - } - - let body: string | undefined = options.body; - - if (options.json !== undefined) { - body = JSON.stringify(options.json); - headers.push(["Content-Type", "application/json"]); - } - - const result = hostModule.httpRequest(method, url, headers, body); - - const responseHeaders: Record = {}; - for (const [key, value] of result.headers) { - responseHeaders[key] = value; - } - - return new Response(result.status, responseHeaders, result.body); - } catch (error) { - throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * Make an HTTP GET request. - * - * @example - * ```typescript - * const response = http.get("https://api.github.com/zen"); - * console.log(response.text()); - * ``` - */ -export async function get( - url: string, - options: Omit = {} -): Promise { - return makeRequest("GET", url, options); -} - -/** - * Make an HTTP POST request. - * - * @example - * ```typescript - * const response = http.post("https://api.example.com/data", { - * json: { key: "value" } - * }); - * ``` - */ -export async function post( - url: string, - options: RequestOptions = {} -): Promise { - return makeRequest("POST", url, options); -} - -/** - * Make an HTTP PUT request. - */ -export async function put( - url: string, - options: RequestOptions = {} -): Promise { - return makeRequest("PUT", url, options); -} - -/** - * Make an HTTP DELETE request. - */ -export async function del( - url: string, - options: Omit = {} -): Promise { - return makeRequest("DELETE", url, options); -} - -/** - * Make an HTTP PATCH request. - */ -export async function patch( - url: string, - options: RequestOptions = {} -): Promise { - return makeRequest("PATCH", url, options); -} - -/** - * Make an HTTP HEAD request. - */ -export async function head( - url: string, - options: Omit = {} -): Promise { - return makeRequest("HEAD", url, options); -} diff --git a/crates/capsule-sdk/javascript/src/index.ts b/crates/capsule-sdk/javascript/src/index.ts index f947c42..b3c5cef 100644 --- a/crates/capsule-sdk/javascript/src/index.ts +++ b/crates/capsule-sdk/javascript/src/index.ts @@ -16,5 +16,6 @@ export { task, type TaskOptions } from "./task.js"; export { TaskRunner, exports, type TaskConfig } from "./app.js"; -export * as http from "./http.js"; +export * as files from "./files.js"; export { isWasmMode } from "./hostApi.js"; + diff --git a/crates/capsule-sdk/javascript/src/task.ts b/crates/capsule-sdk/javascript/src/task.ts index febe11b..fa0f3fa 100644 --- a/crates/capsule-sdk/javascript/src/task.ts +++ b/crates/capsule-sdk/javascript/src/task.ts @@ -23,6 +23,8 @@ export interface TaskOptions { timeout?: string | number; /** Maximum number of retries */ maxRetries?: number; + /** Files/folders accessible in the sandbox, e.g., ["./data"] */ + allowedFiles?: string[]; } interface TaskResult { @@ -35,7 +37,6 @@ interface TaskResult { * * @example * ```typescript - * // String timeout format * export const greet = task({ * name: "greet", * compute: "LOW", @@ -44,11 +45,10 @@ interface TaskResult { * return `Hello, ${name}!`; * }); * - * // Numeric timeout format (milliseconds) * export const process = task({ * name: "process", * compute: "HIGH", - * timeout: 30000 // 30 seconds + * timeout: 30000 * }, (data: any): any => { * return processData(data); * }); @@ -90,6 +90,7 @@ export function task( ram: options.ram, timeout: normalizeTimeout(options.timeout), maxRetries: options.maxRetries, + allowedFiles: options.allowedFiles, }; const wrapper = (...args: TArgs): TReturn => { diff --git a/crates/capsule-sdk/python/src/capsule/decorator.py b/crates/capsule-sdk/python/src/capsule/decorator.py index 8fab5ab..25edbc9 100644 --- a/crates/capsule-sdk/python/src/capsule/decorator.py +++ b/crates/capsule-sdk/python/src/capsule/decorator.py @@ -10,7 +10,7 @@ from . import app -def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None): +def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None, allowed_files=None): """ Decorator to mark a function as a Capsule task. @@ -20,6 +20,7 @@ def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None): ram: RAM limit - e.g., "512MB", "2GB" timeout: Timeout duration - e.g., "30s", "5m" max_retries: Maximum number of retries (default: 1) + allowed_files: List of files/folders accessible in sandbox - e.g., ["./data"] In WASM mode: - The function is registered in the task registry with its config @@ -43,6 +44,8 @@ def decorator(func): task_config["timeout"] = timeout if max_retries is not None: task_config["max_retries"] = max_retries + if allowed_files is not None: + task_config["allowed_files"] = allowed_files @functools.wraps(func) def wrapper(*args, **kwargs): diff --git a/crates/capsule-wit/capsule.wit b/crates/capsule-wit/capsule.wit index 326cba4..afdeb55 100644 --- a/crates/capsule-wit/capsule.wit +++ b/crates/capsule-wit/capsule.wit @@ -42,5 +42,9 @@ interface task-runner { world capsule-agent { import api; + + import wasi:filesystem/types@0.2.0; + import wasi:filesystem/preopens@0.2.0; + export task-runner; } diff --git a/examples/javascript/file-access/data/input.txt b/examples/javascript/file-access/data/input.txt new file mode 100644 index 0000000..2144cb2 --- /dev/null +++ b/examples/javascript/file-access/data/input.txt @@ -0,0 +1,2 @@ +Hello from Capsule! +This is a sample text file for testing filesystem access. diff --git a/examples/javascript/file-access/main.ts b/examples/javascript/file-access/main.ts new file mode 100644 index 0000000..8763e59 --- /dev/null +++ b/examples/javascript/file-access/main.ts @@ -0,0 +1,32 @@ +import { task, files } from "@capsule-run/sdk"; + +/** + * Sub-task with restricted file access. + * When called, it can ONLY access files in ./data directory + */ +export const restrictedWriter = task({ + name: "restricted_writer", + allowedFiles: ["./data"] +}, async (content: string) => { + await files.writeText("./data/output.txt", content); + return { written: true }; +}); + +/** + * Main task has full project access by default. + */ +export const main = task({ name: "main" }, async () => { + const content = await files.readText("./data/input.txt"); + const lines = content.trim().split("\n"); + const lineCount = lines.length; + + const result = restrictedWriter( + `Processed ${lineCount} lines\nFirst line: ${lines[0]}\n` + ); + + return { + inputLines: lineCount, + firstLine: lines[0], + writeResult: result + }; +}); diff --git a/examples/javascript/file-access/package.json b/examples/javascript/file-access/package.json new file mode 100644 index 0000000..4bbf7f8 --- /dev/null +++ b/examples/javascript/file-access/package.json @@ -0,0 +1,8 @@ +{ + "name": "file-access", + "version": "1.0.0", + "type": "commonjs", + "dependencies": { + "typescript": "^5.9.3" + } +} diff --git a/examples/python/file-access/data/input.txt b/examples/python/file-access/data/input.txt new file mode 100644 index 0000000..2144cb2 --- /dev/null +++ b/examples/python/file-access/data/input.txt @@ -0,0 +1,2 @@ +Hello from Capsule! +This is a sample text file for testing filesystem access. diff --git a/examples/python/file-access/main.py b/examples/python/file-access/main.py new file mode 100644 index 0000000..ccd8d79 --- /dev/null +++ b/examples/python/file-access/main.py @@ -0,0 +1,30 @@ +from capsule import task + +@task(name="restricted_writer", allowed_files=["./data"]) +def restricted_writer(content: str) -> dict: + """Sub-task with restricted file access. + When called, it can ONLY access files in ./data directory + """ + with open("./data/output.txt", "w") as f: + f.write(content) + return {"written": True} + + +@task(name="main") +def main() -> dict: + """Main task has full project access by default.""" + + with open("./data/input.txt") as f: + content = f.read() + + lines = content.strip().split("\n") + line_count = len(lines) + + output_content = f"Processed {line_count} lines\nFirst line: {lines[0]}\n" + write_result = restricted_writer(output_content) + + return { + "input_lines": line_count, + "first_line": lines[0], + "write_result": write_result + }