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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
14 changes: 12 additions & 2 deletions crates/capsule-cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
44 changes: 40 additions & 4 deletions crates/capsule-core/src/wasm/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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(),
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down
23 changes: 13 additions & 10 deletions crates/capsule-core/src/wasm/compiler/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
);
Expand All @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions crates/capsule-core/src/wasm/compiler/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
pub const CAPSULE_WIT: &str = include_str!("../../../../capsule-wit/capsule.wit");

pub mod javascript;
pub mod python;
8 changes: 3 additions & 5 deletions crates/capsule-core/src/wasm/compiler/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion crates/capsule-core/src/wasm/execution_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub struct ExecutionPolicy {
pub ram: Option<u64>,
pub timeout: Option<String>,
pub max_retries: u64,
#[serde(default)]
pub allowed_files: Vec<String>,
}

impl Default for ExecutionPolicy {
Expand All @@ -42,6 +44,7 @@ impl Default for ExecutionPolicy {
ram: None,
timeout: None,
max_retries: 0,
allowed_files: Vec::new(),
}
}
}
Expand Down Expand Up @@ -87,6 +90,11 @@ impl ExecutionPolicy {
.as_ref()
.and_then(|s| humantime::parse_duration(s).ok())
}

pub fn allowed_files(mut self, files: Vec<String>) -> Self {
self.allowed_files = files;
self
}
}

#[cfg(test)]
Expand All @@ -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()]);
}
}
4 changes: 4 additions & 0 deletions crates/capsule-core/src/wasm/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub enum WasmRuntimeError {
WasmtimeError(wasmtime::Error),
LogError(LogError),
ConfigError(String),
FilesystemError(String),
Timeout,
}

Expand All @@ -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"),
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/capsule-core/src/wasm/utilities/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
pub mod path_validator;
pub mod task_config;
pub mod task_reporter;
pub mod wit_manager;
Loading