From 45ebeece533d812b8af8868ef48aa0cd807d04cb Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 9 Jan 2026 18:14:50 +0100 Subject: [PATCH 01/18] prepare reaadme to remove custom http library for typescript --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bfaa6f0..6871186 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,20 @@ 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(); +}); +``` + ## Compatibility From f0ba3fd503df5ebfc943ccbe52ee25720522acf0 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 13:56:08 +0100 Subject: [PATCH 02/18] import wasi:filesystem to javascript bootloader, javascript compiler and capsule.wit. Add preopened_dir with path check to our wasi builder --- .../capsule-core/src/wasm/commands/create.rs | 44 ++++- .../src/wasm/compiler/javascript.rs | 7 + .../capsule-core/src/wasm/execution_policy.rs | 12 +- crates/capsule-core/src/wasm/runtime.rs | 4 + crates/capsule-core/src/wasm/utilities/mod.rs | 1 + .../src/wasm/utilities/path_validator.rs | 152 ++++++++++++++++++ crates/capsule-wit/capsule.wit | 4 + 7 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 crates/capsule-core/src/wasm/utilities/path_validator.rs diff --git a/crates/capsule-core/src/wasm/commands/create.rs b/crates/capsule-core/src/wasm/commands/create.rs index c25d94f..b6d8a46 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::{validate_path, FileAccessMode}; 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..f49a121 100644 --- a/crates/capsule-core/src/wasm/compiler/javascript.rs +++ b/crates/capsule-core/src/wasm/compiler/javascript.rs @@ -104,7 +104,11 @@ impl JavascriptWasmCompiler { let wrapper_content = format!( r#"// Auto-generated bootloader for Capsule 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; @@ -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()) @@ -150,6 +155,8 @@ impl JavascriptWasmCompiler { .arg("capsule-agent") .arg("--enable") .arg("http") + .arg("--enable") + .arg("filesystem") .arg("-o") .arg(&output_wasm_normalized) .current_dir(&sdk_path_normalized) 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..c7aebe0 100644 --- a/crates/capsule-core/src/wasm/utilities/mod.rs +++ b/crates/capsule-core/src/wasm/utilities/mod.rs @@ -1,2 +1,3 @@ +pub mod path_validator; pub mod task_config; pub mod task_reporter; 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..52e68e9 --- /dev/null +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -0,0 +1,152 @@ +use std::error::Error; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileAccessMode { + ReadOnly, + ReadWrite, +} + +impl Default for FileAccessMode { + fn default() -> Self { + FileAccessMode::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()); + } + + #[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-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; } From 0167c289e70ed2b7d2b39693d3ed5291c75cf195 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 13:58:19 +0100 Subject: [PATCH 03/18] fix fmt issues --- crates/capsule-core/src/wasm/commands/create.rs | 2 +- .../capsule-core/src/wasm/utilities/path_validator.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/capsule-core/src/wasm/commands/create.rs b/crates/capsule-core/src/wasm/commands/create.rs index b6d8a46..501eec9 100644 --- a/crates/capsule-core/src/wasm/commands/create.rs +++ b/crates/capsule-core/src/wasm/commands/create.rs @@ -13,7 +13,7 @@ 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::{validate_path, FileAccessMode}; +use crate::wasm::utilities::path_validator::{FileAccessMode, validate_path}; pub struct CreateInstance { pub policy: ExecutionPolicy, diff --git a/crates/capsule-core/src/wasm/utilities/path_validator.rs b/crates/capsule-core/src/wasm/utilities/path_validator.rs index 52e68e9..aafd543 100644 --- a/crates/capsule-core/src/wasm/utilities/path_validator.rs +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -42,7 +42,11 @@ impl fmt::Display for PathValidationError { write!(f, "Path does not exist: {}", path) } PathValidationError::InvalidMode(mode) => { - write!(f, "Invalid access mode '{}'. Use :ro (read-only) or :rw (read-write)", mode) + write!( + f, + "Invalid access mode '{}'. Use :ro (read-only) or :rw (read-write)", + mode + ) } } } @@ -58,9 +62,7 @@ fn parse_path_with_mode(path_spec: &str) -> (String, FileAccessMode) { match mode { "ro" => (path.to_string(), FileAccessMode::ReadOnly), "rw" => (path.to_string(), FileAccessMode::ReadWrite), - _ => { - (path_spec.to_string(), FileAccessMode::default()) - } + _ => (path_spec.to_string(), FileAccessMode::default()), } } else { (path_spec.to_string(), FileAccessMode::default()) From 7f9f711e3ba9db942703dc3fccfc44e669a41f7c Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 14:01:57 +0100 Subject: [PATCH 04/18] remove manual default implementation for FileAccessMode --- .../capsule-core/src/wasm/utilities/path_validator.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/capsule-core/src/wasm/utilities/path_validator.rs b/crates/capsule-core/src/wasm/utilities/path_validator.rs index aafd543..8bae52d 100644 --- a/crates/capsule-core/src/wasm/utilities/path_validator.rs +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -2,16 +2,12 @@ use std::error::Error; use std::fmt; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum FileAccessMode { ReadOnly, - ReadWrite, -} -impl Default for FileAccessMode { - fn default() -> Self { - FileAccessMode::ReadWrite - } + #[default] + ReadWrite, } #[derive(Debug)] From c86e9ca4874e7383807e7004330acb80aeb9de21 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 15:50:17 +0100 Subject: [PATCH 05/18] delete http api from typescript/javascript --- crates/capsule-sdk/javascript/src/http.ts | 221 ---------------------- 1 file changed, 221 deletions(-) delete mode 100644 crates/capsule-sdk/javascript/src/http.ts 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); -} From 5df238fa9117b1c99c9fca8903ba7ea87473b3ca Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 15:54:08 +0100 Subject: [PATCH 06/18] add allowed_files option to python --- crates/capsule-sdk/python/src/capsule/decorator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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): From d50d8a6629f64ca6bf4b99fc8ec037aaab9134ba Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sat, 10 Jan 2026 16:15:34 +0100 Subject: [PATCH 07/18] add files api --- crates/capsule-sdk/javascript/src/app.ts | 7 +- crates/capsule-sdk/javascript/src/files.ts | 145 +++++++++++++++++++++ crates/capsule-sdk/javascript/src/index.ts | 3 +- crates/capsule-sdk/javascript/src/task.ts | 7 +- 4 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 crates/capsule-sdk/javascript/src/files.ts 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..06113f7 --- /dev/null +++ b/crates/capsule-sdk/javascript/src/files.ts @@ -0,0 +1,145 @@ +/** + * Capsule Files API for WASM filesystem access. + * + * We provide this API for reading/writing files in allowed directories. + * + * @example + * ```typescript + * import { files } from '@capsule-run/sdk'; + * + * const content = await files.readText("./data/input.txt"); + * await files.writeText("./data/output.txt", content); + * ``` + */ + +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(): Array<{ name: string; type: string }>; + openAt(pathFlags: number, path: string, openFlags: number, descriptorFlags: number): Descriptor; +} + +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 getPreopenedDir(): Descriptor | null { + const fs = getFsBindings(); + if (!fs) return null; + + try { + const dirs = fs.preopens.getDirectories(); + if (dirs && dirs.length > 0) { + return dirs[0][0]; + } + } catch {} + return null; +} + +/** + * Read a file as text. + * @param path - Relative path to the file (must be in allowed_files) + */ +export async function readText(path: string): Promise { + const bytes = await readBytes(path); + return new TextDecoder().decode(bytes); +} + +/** + * Read a file as bytes. + * @param path - Relative path to the file (must be in allowed_files) + */ +export async function readBytes(path: string): Promise { + const dir = getPreopenedDir(); + if (!dir) { + throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + } + + try { + const fd = dir.openAt(0, path, 0, 1); + 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 - Relative path to the file (must be in allowed_files) + * @param content - 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 - Relative path to the file (must be in allowed_files) + * @param data - Binary data to write + */ +export async function writeBytes(path: string, data: Uint8Array): Promise { + const dir = getPreopenedDir(); + if (!dir) { + throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + } + + try { + const fd = dir.openAt(0, path, 1 | 2, 2); + fd.write(data, BigInt(0)); + } catch (e) { + throw new Error(`Failed to write file '${path}': ${e}`); + } +} + +/** + * List files/directories at a path. + * @param path - Relative path to the directory (defaults to ".") + */ +export async function list(path: string = "."): Promise { + const dir = getPreopenedDir(); + if (!dir) { + throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + } + + try { + const targetDir = path === "." ? dir : dir.openAt(0, path, 0, 1); + const entries = targetDir.readDirectory(); + return entries.map((entry: { name: string }) => entry.name); + } catch (e) { + throw new Error(`Failed to list directory '${path}': ${e}`); + } +} + +/** + * Check if a file or directory exists. + * @param path - Relative path to check + */ +export async function exists(path: string): Promise { + const dir = getPreopenedDir(); + if (!dir) { + return false; + } + + try { + dir.openAt(0, path, 0, 1); + return true; + } catch { + return false; + } +} 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 => { From b0996e99e535dedeabc4509cfd63444f792f59ab Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sun, 11 Jan 2026 13:01:30 +0100 Subject: [PATCH 08/18] Update readme to add new allowed_files option --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 6871186..e096055 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 @@ -199,6 +200,45 @@ export const main = task({ }); ``` +### File Access + +By default, tasks run in a fully isolated sandbox with no filesystem access. Use the `allowed_files` parameter to grant read and write access to specific files or directories. + +#### Python + +Python's standard file operations work normally. Use `open()`, `os`, `pathlib`, or any file manipulation library. + +```python +from capsule import task + +@task(name="main", allowed_files=["./data"]) +def main() -> str: + with open("./data/input.txt") as f: + return f.read() +``` + +#### 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 main = task({ + name: "main", + allowedFiles: ["./data"] +}, async () => { + 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 From accd452b000e64d7e8745ebafba5acb957bf2026 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sun, 11 Jan 2026 13:19:32 +0100 Subject: [PATCH 09/18] update roadmap --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. --- From c963ec47e43a9d86fa22e658721aa9988f579717 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 14:35:26 +0100 Subject: [PATCH 10/18] create a custom wit manager to import dependencies, disable broken filesystem option --- crates/capsule-cli/src/commands/run.rs | 14 +++++- .../src/wasm/compiler/javascript.rs | 10 ++--- crates/capsule-core/src/wasm/compiler/mod.rs | 2 - .../capsule-core/src/wasm/compiler/python.rs | 8 ++-- crates/capsule-core/src/wasm/utilities/mod.rs | 1 + .../src/wasm/utilities/wit_manager.rs | 44 +++++++++++++++++++ 6 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 crates/capsule-core/src/wasm/utilities/wit_manager.rs diff --git a/crates/capsule-cli/src/commands/run.rs b/crates/capsule-cli/src/commands/run.rs index 572c295..db73f8a 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()]); // Preopen project root for main task + + 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/compiler/javascript.rs b/crates/capsule-core/src/wasm/compiler/javascript.rs index f49a121..5670c03 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 { @@ -155,8 +155,6 @@ impl JavascriptWasmCompiler { .arg("capsule-agent") .arg("--enable") .arg("http") - .arg("--enable") - .arg("filesystem") .arg("-o") .arg(&output_wasm_normalized) .current_dir(&sdk_path_normalized) @@ -184,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/utilities/mod.rs b/crates/capsule-core/src/wasm/utilities/mod.rs index c7aebe0..9020e10 100644 --- a/crates/capsule-core/src/wasm/utilities/mod.rs +++ b/crates/capsule-core/src/wasm/utilities/mod.rs @@ -1,3 +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/wit_manager.rs b/crates/capsule-core/src/wasm/utilities/wit_manager.rs new file mode 100644 index 0000000..f5f48a7 --- /dev/null +++ b/crates/capsule-core/src/wasm/utilities/wit_manager.rs @@ -0,0 +1,44 @@ +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 new() -> Self { + Self {} + } + + 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(()) + } +} From 96347dce73217dee1638f727d25a1382d475c271 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 14:39:07 +0100 Subject: [PATCH 11/18] cleanest indentation --- .../src/wasm/compiler/javascript.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/capsule-core/src/wasm/compiler/javascript.rs b/crates/capsule-core/src/wasm/compiler/javascript.rs index 5670c03..1ae9d83 100644 --- a/crates/capsule-core/src/wasm/compiler/javascript.rs +++ b/crates/capsule-core/src/wasm/compiler/javascript.rs @@ -103,15 +103,15 @@ impl JavascriptWasmCompiler { let wrapper_content = format!( r#"// Auto-generated bootloader for Capsule - 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 * 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 ); From ef99b64ada89a4df3b4a942a72e93e070ff4c97b Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 14:45:49 +0100 Subject: [PATCH 12/18] fix fmt and clippy --- crates/capsule-cli/src/commands/run.rs | 2 +- .../src/wasm/utilities/wit_manager.rs | 66 ++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/capsule-cli/src/commands/run.rs b/crates/capsule-cli/src/commands/run.rs index db73f8a..f7a287a 100644 --- a/crates/capsule-cli/src/commands/run.rs +++ b/crates/capsule-cli/src/commands/run.rs @@ -107,7 +107,7 @@ pub async fn execute( let execution_policy = ExecutionPolicy::default() .compute(Some(Compute::Custom(u64::MAX))) - .allowed_files(vec![".".to_string()]); // Preopen project root for main task + .allowed_files(vec![".".to_string()]); let project_root = file_path .canonicalize() diff --git a/crates/capsule-core/src/wasm/utilities/wit_manager.rs b/crates/capsule-core/src/wasm/utilities/wit_manager.rs index f5f48a7..b074575 100644 --- a/crates/capsule-core/src/wasm/utilities/wit_manager.rs +++ b/crates/capsule-core/src/wasm/utilities/wit_manager.rs @@ -6,29 +6,63 @@ 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")), - + ( + "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")), - + ( + "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")), + ( + "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 new() -> Self { - Self {} - } - 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)?; From dfc44e7befc19f812b0645b995f08781425ae0dc Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 19:14:49 +0100 Subject: [PATCH 13/18] add more test to path_validator --- .../src/wasm/utilities/path_validator.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/capsule-core/src/wasm/utilities/path_validator.rs b/crates/capsule-core/src/wasm/utilities/path_validator.rs index 8bae52d..eb767c7 100644 --- a/crates/capsule-core/src/wasm/utilities/path_validator.rs +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -77,7 +77,6 @@ pub fn validate_path( } let joined = project_root.join(p); - let resolved = joined .canonicalize() .map_err(|_| PathValidationError::PathNotFound(path_str.clone()))?; @@ -125,6 +124,35 @@ mod tests { 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] From 83c01c32c7830c71ba429e96182c03a53ac0b024 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 19:15:13 +0100 Subject: [PATCH 14/18] add allowed_files to task config --- crates/capsule-core/src/wasm/utilities/task_config.rs | 5 +++++ 1 file changed, 5 insertions(+) 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(); From f337ead0d4b4a48666cf472b98b9925fcdc07bab Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 19:29:54 +0100 Subject: [PATCH 15/18] fix files api --- crates/capsule-sdk/javascript/src/files.ts | 158 +++++++++++++++------ 1 file changed, 115 insertions(+), 43 deletions(-) diff --git a/crates/capsule-sdk/javascript/src/files.ts b/crates/capsule-sdk/javascript/src/files.ts index 06113f7..35b8ad8 100644 --- a/crates/capsule-sdk/javascript/src/files.ts +++ b/crates/capsule-sdk/javascript/src/files.ts @@ -1,15 +1,5 @@ /** * Capsule Files API for WASM filesystem access. - * - * We provide this API for reading/writing files in allowed directories. - * - * @example - * ```typescript - * import { files } from '@capsule-run/sdk'; - * - * const content = await files.readText("./data/input.txt"); - * await files.writeText("./data/output.txt", content); - * ``` */ declare const globalThis: { @@ -21,8 +11,18 @@ interface Descriptor { read(length: bigint, offset: bigint): [Uint8Array, boolean]; write(buffer: Uint8Array, offset: bigint): bigint; stat(): { size: bigint }; - readDirectory(): Array<{ name: string; type: string }>; - openAt(pathFlags: number, path: string, openFlags: number, descriptorFlags: number): Descriptor; + 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 { @@ -36,22 +36,59 @@ function getFsBindings(): { types: any; preopens: any } | null { return null; } -function getPreopenedDir(): Descriptor | null { +function getPreopenedDirs(): PreopenedDir[] { const fs = getFsBindings(); - if (!fs) return null; + if (!fs) return []; try { const dirs = fs.preopens.getDirectories(); - if (dirs && dirs.length > 0) { - return dirs[0][0]; + 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 }; } - } catch {} - return null; + + 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 - Relative path to the file (must be in allowed_files) + * + * @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); @@ -60,16 +97,22 @@ export async function readText(path: string): Promise { /** * Read a file as bytes. - * @param path - Relative path to the file (must be in allowed_files) + * + * @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 dir = getPreopenedDir(); - if (!dir) { - throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); } try { - const fd = dir.openAt(0, path, 0, 1); + 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; @@ -80,8 +123,9 @@ export async function readBytes(path: string): Promise { /** * Write text content to a file. - * @param path - Relative path to the file (must be in allowed_files) - * @param content - Text content to write + * + * @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); @@ -90,17 +134,22 @@ export async function writeText(path: string, content: string): Promise { /** * Write bytes to a file. - * @param path - Relative path to the file (must be in allowed_files) - * @param data - Binary data to write + * + * @param path - The path to write. + * @param data - The bytes to write. */ export async function writeBytes(path: string, data: Uint8Array): Promise { - const dir = getPreopenedDir(); - if (!dir) { - throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); } try { - const fd = dir.openAt(0, path, 1 | 2, 2); + 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}`); @@ -109,18 +158,36 @@ export async function writeBytes(path: string, data: Uint8Array): Promise /** * List files/directories at a path. - * @param path - Relative path to the directory (defaults to ".") + * + * @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 dir = getPreopenedDir(); - if (!dir) { - throw new Error("Filesystem not available. Make sure you've set allowedFiles in your task config."); + const resolved = resolvePath(path); + if (!resolved) { + throw new Error("Filesystem not available."); } try { - const targetDir = path === "." ? dir : dir.openAt(0, path, 0, 1); - const entries = targetDir.readDirectory(); - return entries.map((entry: { name: string }) => entry.name); + 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}`); } @@ -128,16 +195,21 @@ export async function list(path: string = "."): Promise { /** * Check if a file or directory exists. - * @param path - Relative path to check + * + * @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 dir = getPreopenedDir(); - if (!dir) { + const resolved = resolvePath(path); + if (!resolved) { return false; } try { - dir.openAt(0, path, 0, 1); + const pathFlags = { symlinkFollow: false }; + const openFlags = {}; + const descriptorFlags = { read: true }; + resolved.dir.openAt(pathFlags, resolved.relativePath, openFlags, descriptorFlags); return true; } catch { return false; From b6b2a4fcf7c60a313021f09fdf0e456a10a9cf71 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 19:48:16 +0100 Subject: [PATCH 16/18] Refine examples and explaination --- README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e096055..6f27bc1 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,10 @@ export const main = task({ ### File Access -By default, tasks run in a fully isolated sandbox with no filesystem access. Use the `allowed_files` parameter to grant read and write access to specific files or directories. +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 @@ -211,10 +214,14 @@ Python's standard file operations work normally. Use `open()`, `os`, `pathlib`, ```python from capsule import task -@task(name="main", allowed_files=["./data"]) +@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: - with open("./data/input.txt") as f: - return f.read() + restricted_writer() ``` #### TypeScript / JavaScript @@ -224,10 +231,15 @@ Node.js built-ins like `fs` are not available in the WebAssembly sandbox. Instea ```typescript import { task, files } from "@capsule-run/sdk"; -export const main = task({ - name: "main", - allowedFiles: ["./data"] +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"); }); ``` @@ -240,6 +252,7 @@ Available methods: - `files.list(path)` — List directory contents - `files.exists(path)` — Check if file exists + ## Compatibility > [!NOTE] From 12aa2680d0ea12041be9df20f466a87789026e74 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Mon, 12 Jan 2026 19:49:10 +0100 Subject: [PATCH 17/18] Filesystem examples --- .../javascript/file-access/data/input.txt | 2 ++ examples/javascript/file-access/main.ts | 32 +++++++++++++++++++ examples/javascript/file-access/package.json | 8 +++++ examples/python/file-access/data/input.txt | 2 ++ examples/python/file-access/main.py | 30 +++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 examples/javascript/file-access/data/input.txt create mode 100644 examples/javascript/file-access/main.ts create mode 100644 examples/javascript/file-access/package.json create mode 100644 examples/python/file-access/data/input.txt create mode 100644 examples/python/file-access/main.py 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 + } From 7e7515e37924fc330bdf1e80febbc86cb1e017f0 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 13 Jan 2026 00:14:21 +0100 Subject: [PATCH 18/18] fix fmt --- crates/capsule-core/src/wasm/utilities/path_validator.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/capsule-core/src/wasm/utilities/path_validator.rs b/crates/capsule-core/src/wasm/utilities/path_validator.rs index eb767c7..e1ad0e7 100644 --- a/crates/capsule-core/src/wasm/utilities/path_validator.rs +++ b/crates/capsule-core/src/wasm/utilities/path_validator.rs @@ -133,10 +133,7 @@ mod tests { let current = std::env::current_dir().unwrap(); let result = validate_path("./nonexistent_dir", ¤t); - assert!(matches!( - result, - Err(PathValidationError::PathNotFound(_)) - )); + assert!(matches!(result, Err(PathValidationError::PathNotFound(_)))); } #[test]