diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf08b56..3e60762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,6 +137,7 @@ jobs: cp -r crates/capsule-wit crates/capsule-core/ sed -i 's|../../../../capsule-wit|../../../capsule-wit|g' crates/capsule-core/src/wasm/compiler/mod.rs sed -i 's|"../capsule-wit"|"./capsule-wit"|g' crates/capsule-core/src/wasm/state.rs + sed -i 's|../../../../capsule-wit|../../../capsule-wit|g' crates/capsule-core/src/wasm/utilities/wit_manager.rs - name: Publish capsule-core working-directory: crates/capsule-core diff --git a/README.md b/README.md index b1b86a6..e1dda68 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Configure your tasks with these parameters: | `timeout` | Maximum execution time | `str` | unlimited | `"30s"`, `"5m"`, `"1h"` | | `max_retries` / `maxRetries` | Number of retry attempts on failure | `int` | `0` | `3` | | `allowed_files` / `allowedFiles` | Folders accessible in the sandbox | `list` | `[]` | `["./data", "./output"]` | +| `env_variables` / `envVariables` | Environment variables accessible in the sandbox | `list` | `[]` | `["API_KEY"]` | ### Compute Levels @@ -304,6 +305,43 @@ Available methods: - `files.list(path)` — List directory contents - `files.exists(path)` — Check if file exists +### Environment Variables + +Tasks can access environment variables to read configuration, API keys, or other runtime settings. + +#### Python + +Use Python's standard `os.environ` to access environment variables: +```python +from capsule import task +import os + +@task(name="main", env_variables=["API_KEY"]) +def main() -> dict: + api_key = os.environ.get("API_KEY") + return {"api_key": api_key} +``` + +#### TypeScript / JavaScript + +Use the `env` API provided by the SDK: +```typescript +import { task, env } from "@capsule-run/sdk"; + +export const main = task({ + name: "main", + envVariables: ["API_KEY"] +}, () => { + const apiKey = env.get("API_KEY"); + return { apiKeySet: apiKey !== undefined, environment }; +}); +``` + +Available methods: +- `env.get(key)` — Get a specific environment variable (returns `undefined` if not found) +- `env.has(key)` — Check if an environment variable exists +- `env.getAll()` — Get all environment variables as an object + ## Compatibility diff --git a/crates/capsule-cli/Cargo.lock b/crates/capsule-cli/Cargo.lock index f070feb..a22a9fd 100644 --- a/crates/capsule-cli/Cargo.lock +++ b/crates/capsule-cli/Cargo.lock @@ -322,7 +322,7 @@ dependencies = [ [[package]] name = "capsule-core" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "blake3", @@ -350,10 +350,11 @@ dependencies = [ [[package]] name = "capsule-run" -version = "0.4.1" +version = "0.4.2" dependencies = [ "capsule-core", "clap", + "dotenvy", "serde_json", "tokio", ] @@ -752,6 +753,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.15.0" diff --git a/crates/capsule-cli/Cargo.toml b/crates/capsule-cli/Cargo.toml index 9720b64..ac0bc7b 100644 --- a/crates/capsule-cli/Cargo.toml +++ b/crates/capsule-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capsule-run" -version = "0.4.1" +version = "0.4.2" edition = "2024" description = "Secure WASM runtime to isolate and manage AI agent tasks" license = "Apache-2.0" @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5.53", features = ["derive"] } -capsule-core = { version= "0.4.1", path = "../capsule-core" } +capsule-core = { version= "0.4.2", path = "../capsule-core" } tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread", "macros"] } serde_json = "1" +dotenvy = "0.15.7" diff --git a/crates/capsule-cli/docs/README-pypi.md b/crates/capsule-cli/docs/README-pypi.md index 0fd2c69..1a7c93a 100644 --- a/crates/capsule-cli/docs/README-pypi.md +++ b/crates/capsule-cli/docs/README-pypi.md @@ -37,7 +37,6 @@ Run it: capsule run hello.py ``` -> [!TIP] > Use `--verbose` to display real-time task execution details. ## How It Works @@ -53,7 +52,6 @@ def analyze_data(dataset: list) -> dict: return {"processed": len(dataset), "status": "complete"} ``` -> [!NOTE] > The runtime requires a task named `"main"` as the entry point. Python can define the main task itself, but it's recommended to set it manually. When you run `capsule run main.py`, your code is compiled into a WebAssembly module and executed in a dedicated sandbox. @@ -97,6 +95,7 @@ Every task returns a structured JSON envelope containing both the result and exe | `timeout` | Maximum execution time | `str` | unlimited | `"30s"`, `"5m"` | | `max_retries` | Retry attempts on failure | `int` | `0` | `3` | | `allowed_files` | Folders accessible in the sandbox | `list` | `[]` | `["./data", "./output"]` | +| `env_variables` | Environment variables accessible in the sandbox | `list` | `[]` | `["API_KEY"]` | ### Compute Levels @@ -154,6 +153,20 @@ def main() -> str: restricted_writer() ``` +### Environment Variables + +Tasks can access environment variables to read configuration, API keys, or other runtime settings. Use Python's standard `os.environ` to access environment variables: + +```python +from capsule import task +import os + +@task(name="main", env_variables=["API_KEY"]) +def main() -> dict: + api_key = os.environ.get("API_KEY") + return {"api_key": api_key} +``` + ## Compatibility ✅ **Supported:** diff --git a/crates/capsule-cli/npm/README.md b/crates/capsule-cli/npm/README.md index 6bfeecc..dfb4f4b 100644 --- a/crates/capsule-cli/npm/README.md +++ b/crates/capsule-cli/npm/README.md @@ -42,7 +42,6 @@ Run it: capsule run hello.ts ``` -> [!TIP] > Use `--verbose` to display real-time task execution details. ## How It Works @@ -71,7 +70,6 @@ export const main = task({ }); ``` -> [!NOTE] > The runtime requires a task named `"main"` as the entry point. When you run `capsule run main.ts`, your code is compiled into a WebAssembly module and executed in a dedicated sandbox. @@ -115,6 +113,7 @@ Every task returns a structured JSON envelope containing both the result and exe | `timeout` | Maximum execution time | `string` or `number` | unlimited | `"30s"`, `"5m"`, `30000` (ms) | | `maxRetries` | Retry attempts on failure | `number` | `0` | `3` | | `allowedFiles` | Folders accessible in the sandbox | `string[]` | `[]` | `["./data", "./output"]` | +| `envVariables` | Environment variables accessible in the sandbox | `string[]` | `[]` | `["API_KEY"]` | ### Compute Levels @@ -171,6 +170,27 @@ Available methods: - `files.list(path)` — List directory contents - `files.exists(path)` — Check if file exists +### Environment Variables + +Tasks can access environment variables to read configuration, API keys, or other runtime settings. Use the `env` API provided by the SDK: + +```typescript +import { task, env } from "@capsule-run/sdk"; + +export const main = task({ + name: "main", + envVariables: ["API_KEY"] +}, () => { + const apiKey = env.get("API_KEY"); + return { apiKeySet: apiKey !== undefined }; +}); +``` + +Available methods: +- `env.get(key)` — Get a specific environment variable (returns `undefined` if not found) +- `env.has(key)` — Check if an environment variable exists +- `env.getAll()` — Get all environment variables as an object + ## Compatibility ✅ **Supported:** diff --git a/crates/capsule-cli/npm/package.json b/crates/capsule-cli/npm/package.json index 6e435b4..4736596 100644 --- a/crates/capsule-cli/npm/package.json +++ b/crates/capsule-cli/npm/package.json @@ -1,6 +1,6 @@ { "name": "@capsule-run/cli", - "version": "0.4.1", + "version": "0.4.2", "description": "Secure WASM runtime to isolate and manage AI agent tasks", "bin": { "capsule": "./bin/capsule.js" @@ -29,9 +29,9 @@ "node": ">=18" }, "optionalDependencies": { - "@capsule-run/cli-darwin-arm64": "0.4.1", - "@capsule-run/cli-darwin-x64": "0.4.1", - "@capsule-run/cli-linux-x64": "0.4.1", - "@capsule-run/cli-win32-x64": "0.4.1" + "@capsule-run/cli-darwin-arm64": "0.4.2", + "@capsule-run/cli-darwin-x64": "0.4.2", + "@capsule-run/cli-linux-x64": "0.4.2", + "@capsule-run/cli-win32-x64": "0.4.2" } } diff --git a/crates/capsule-cli/pyproject.toml b/crates/capsule-cli/pyproject.toml index e40c1e9..dd2a60d 100644 --- a/crates/capsule-cli/pyproject.toml +++ b/crates/capsule-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "capsule-run" -version = "0.4.1" +version = "0.4.2" description = "Secure WASM runtime to isolate and manage AI agent tasks" readme = "docs/README-pypi.md" license = {text = "Apache-2.0"} diff --git a/crates/capsule-cli/src/commands/run.rs b/crates/capsule-cli/src/commands/run.rs index b334d61..9f4df9f 100644 --- a/crates/capsule-cli/src/commands/run.rs +++ b/crates/capsule-cli/src/commands/run.rs @@ -117,6 +117,20 @@ fn extract_main_execution_policy( Some(task_config.to_execution_policy(capsule_toml)) } +fn load_env_variables(project_root: &Path) -> Result<(), RunError> { + let env_files = vec![".env", ".env.local", ".env.development", ".env.production"]; + + for file_name in env_files { + let file_path = project_root.join(file_name); + + if file_path.exists() { + dotenvy::from_path(&file_path).map_err(|e| RunError::IoError(e.to_string()))?; + } + } + + Ok(()) +} + pub async fn execute( file_path: Option<&Path>, args: Vec, @@ -135,6 +149,15 @@ pub async fn execute( reporter.finish_progress(Some("Environment ready")); reporter.start_progress("Initializing runtime"); + + 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()); + + load_env_variables(&project_root)?; + let runtime_config = RuntimeConfig { cache_dir: compile_result.cache_dir, verbose, @@ -146,12 +169,6 @@ pub async fn execute( let runtime = Runtime::new(runtime_config, manifest.capsule_toml)?; - 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) .project_root(project_root); diff --git a/crates/capsule-core/Cargo.lock b/crates/capsule-core/Cargo.lock index 0014693..9bacc46 100644 --- a/crates/capsule-core/Cargo.lock +++ b/crates/capsule-core/Cargo.lock @@ -302,7 +302,7 @@ dependencies = [ [[package]] name = "capsule-core" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "blake3", diff --git a/crates/capsule-core/Cargo.toml b/crates/capsule-core/Cargo.toml index b0a7f0e..0477339 100644 --- a/crates/capsule-core/Cargo.toml +++ b/crates/capsule-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capsule-core" -version = "0.4.1" +version = "0.4.2" edition = "2024" description = "Core library for Capsule - WASM runtime for AI agent isolation" license = "Apache-2.0" diff --git a/crates/capsule-core/src/config/manifest.rs b/crates/capsule-core/src/config/manifest.rs index c00bfcb..0bf2990 100644 --- a/crates/capsule-core/src/config/manifest.rs +++ b/crates/capsule-core/src/config/manifest.rs @@ -36,6 +36,7 @@ pub struct DefaultPolicy { pub default_timeout: Option, pub default_max_retries: Option, pub default_allowed_files: Option>, + pub default_env_variables: Option>, } #[derive(Debug, Default, Deserialize)] diff --git a/crates/capsule-core/src/wasm/commands/create.rs b/crates/capsule-core/src/wasm/commands/create.rs index 501eec9..5245bca 100644 --- a/crates/capsule-core/src/wasm/commands/create.rs +++ b/crates/capsule-core/src/wasm/commands/create.rs @@ -93,10 +93,17 @@ impl RuntimeCommand for CreateInstance { capsule::host::api::add_to_linker(&mut linker, |state: &mut State| state)?; + let envs = std::env::vars() + .collect::>() + .into_iter() + .filter(|(key, _)| self.policy.env_variables.contains(key)) + .collect::>(); + let mut wasi_builder = WasiCtxBuilder::new(); wasi_builder .inherit_stdout() .inherit_stderr() + .envs(&envs) .args(&self.args); for path_spec in &self.policy.allowed_files { diff --git a/crates/capsule-core/src/wasm/compiler/javascript.rs b/crates/capsule-core/src/wasm/compiler/javascript.rs index 5257b72..e639c31 100644 --- a/crates/capsule-core/src/wasm/compiler/javascript.rs +++ b/crates/capsule-core/src/wasm/compiler/javascript.rs @@ -139,9 +139,11 @@ impl JavascriptWasmCompiler { 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'; +import * as environment from 'wasi:cli/environment@0.2.0'; globalThis['capsule:host/api'] = hostApi; globalThis['wasi:filesystem/types'] = fsTypes; globalThis['wasi:filesystem/preopens'] = fsPreopens; +globalThis['wasi:cli/environment'] = environment; import '{}'; import {{ exports }} from '{}/dist/app.js'; export const taskRunner = exports; @@ -165,6 +167,7 @@ export const taskRunner = exports; .arg("--platform=neutral") .arg("--external:capsule:host/api") .arg("--external:wasi:filesystem/*") + .arg("--external:wasi:cli/*") .arg(format!("--outfile={}", bundled_path_normalized.display())) .current_dir(&sdk_path_normalized) .stdout(Stdio::piped()) @@ -293,9 +296,7 @@ export const taskRunner = exports; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("TypeScript compilation failed!"); - eprintln!("stdout: {}", stdout); - eprintln!("stderr: {}", stderr); + return Err(JavascriptWasmCompilerError::CompileFailed(format!( "TypeScript compilation failed: {}{}", stderr.trim(), diff --git a/crates/capsule-core/src/wasm/execution_policy.rs b/crates/capsule-core/src/wasm/execution_policy.rs index 89be0a8..bb8b682 100644 --- a/crates/capsule-core/src/wasm/execution_policy.rs +++ b/crates/capsule-core/src/wasm/execution_policy.rs @@ -32,8 +32,12 @@ pub struct ExecutionPolicy { pub ram: Option, pub timeout: Option, pub max_retries: u64, + #[serde(default)] pub allowed_files: Vec, + + #[serde(default)] + pub env_variables: Vec, } impl Default for ExecutionPolicy { @@ -45,6 +49,7 @@ impl Default for ExecutionPolicy { timeout: None, max_retries: 0, allowed_files: Vec::new(), + env_variables: Vec::new(), } } } @@ -95,6 +100,11 @@ impl ExecutionPolicy { self.allowed_files = files; self } + + pub fn env_variables(mut self, env_variables: Vec) -> Self { + self.env_variables = env_variables; + self + } } #[cfg(test)] @@ -109,7 +119,8 @@ mod tests { .ram(Some(128)) .timeout(Some("60s".to_string())) .max_retries(Some(3)) - .allowed_files(vec!["/etc/passwd".to_string()]); + .allowed_files(vec!["/etc/passwd".to_string()]) + .env_variables(vec!["API_KEY".to_string()]); assert_eq!(policy.name, "test"); assert_eq!(policy.compute, Compute::Medium); @@ -117,5 +128,6 @@ mod tests { assert_eq!(policy.timeout, Some("60s".to_string())); assert_eq!(policy.max_retries, 3); assert_eq!(policy.allowed_files, vec!["/etc/passwd".to_string()]); + assert_eq!(policy.env_variables, vec!["API_KEY".to_string()]); } } diff --git a/crates/capsule-core/src/wasm/utilities/introspection/javascript.rs b/crates/capsule-core/src/wasm/utilities/introspection/javascript.rs index 176a0aa..e4819bb 100644 --- a/crates/capsule-core/src/wasm/utilities/introspection/javascript.rs +++ b/crates/capsule-core/src/wasm/utilities/introspection/javascript.rs @@ -3,8 +3,8 @@ use swc_common::input::StringInput; use swc_common::sync::Lrc; use swc_common::{FileName, SourceMap}; use swc_ecma_ast::{ - Callee, Decl, Expr, Lit, ModuleDecl, ModuleItem, ObjectLit, Pat, Prop, PropName, PropOrSpread, - Stmt, + CallExpr, Callee, Decl, Expr, Lit, ModuleDecl, ModuleItem, ObjectLit, Pat, Prop, PropName, + PropOrSpread, Stmt, }; use swc_ecma_parser::{EsSyntax, Parser, Syntax, TsSyntax}; @@ -30,64 +30,86 @@ pub fn extract_js_task_configs( let mut tasks = HashMap::new(); for item in &module.body { - if let ModuleItem::Stmt(stmt) = item - && let Stmt::Decl(decl) = stmt - && let Decl::Var(var_decl) = decl - { - for decl in var_decl.decls.iter() { - if let Some(init) = &decl.init - && let Some((var_name, config)) = extract_task_call(init, &decl.name) - { - tasks.insert(var_name, serde_json::to_value(&config).unwrap()); + match item { + ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) => { + for decl in var_decl.decls.iter() { + if let Some(init) = &decl.init + && let Some((task_name, config)) = + extract_task_with_binding(init, Some(&decl.name)) + { + tasks.insert(task_name, serde_json::to_value(&config).unwrap()); + } } } - } - if let ModuleItem::ModuleDecl(mod_decl) = item - && let ModuleDecl::ExportDecl(export) = mod_decl - && let Decl::Var(var_decl) = &export.decl - { - for decl in var_decl.decls.iter() { - if let Some(init) = &decl.init - && let Some((var_name, config)) = extract_task_call(init, &decl.name) + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => { + if let Decl::Var(var_decl) = &export.decl { + for decl in var_decl.decls.iter() { + if let Some(init) = &decl.init + && let Some((task_name, config)) = + extract_task_with_binding(init, Some(&decl.name)) + { + tasks.insert(task_name, serde_json::to_value(&config).unwrap()); + } + } + } + } + + ModuleItem::Stmt(Stmt::Expr(expr_stmt)) => { + if let Expr::Call(call) = expr_stmt.expr.as_ref() + && let Some((task_name, config)) = extract_task_from_call(call, None) { - tasks.insert(var_name, serde_json::to_value(&config).unwrap()); + tasks.insert(task_name, serde_json::to_value(&config).unwrap()); } } + + _ => {} } } if tasks.is_empty() { None } else { Some(tasks) } } -fn extract_task_call( +fn extract_task_with_binding( expr: &Expr, - binding_name: &Pat, + binding_name: Option<&Pat>, ) -> Option<(String, HashMap)> { - let var_name = match binding_name { - Pat::Ident(ident) => ident.sym.as_str().to_string(), - _ => return None, - }; + if let Expr::Call(call) = expr { + let fallback_name = binding_name.and_then(|pat| match pat { + Pat::Ident(ident) => Some(ident.sym.as_str().to_string()), + _ => None, + }); + extract_task_from_call(call, fallback_name.as_deref()) + } else { + None + } +} - if let Expr::Call(call) = expr - && let Callee::Expr(callee) = &call.callee +fn extract_task_from_call( + call: &CallExpr, + fallback_name: Option<&str>, +) -> Option<(String, HashMap)> { + if let Callee::Expr(callee) = &call.callee && let Expr::Ident(ident) = callee.as_ref() && ident.sym.as_ref() == "task" && let Some(first_arg) = call.args.first() && let Expr::Object(obj) = first_arg.expr.as_ref() { - let mut config = extract_object_literal(obj); + let config = extract_object_literal(obj); + let task_name = config .get("name") .and_then(|v| v.as_str()) - .unwrap_or(&var_name) - .to_string(); + .map(|s| s.to_string()) + .or_else(|| fallback_name.map(|s| s.to_string()))?; - config.insert( + let mut final_config = config; + final_config.insert( "name".to_string(), serde_json::Value::String(task_name.clone()), ); - return Some((task_name, config)); + + return Some((task_name, final_config)); } None @@ -178,4 +200,28 @@ function main() { let configs = extract_js_task_configs(source, false); assert!(configs.is_none()); } + + #[test] + fn test_direct_task_call() { + let source = r#" +task({ name: "directTask", compute: "HIGH" }, () => { + return "hello"; +}); +"#; + let configs = extract_js_task_configs(source, false).unwrap(); + assert!(configs.contains_key("directTask")); + assert_eq!(configs["directTask"]["compute"], "HIGH"); + } + + #[test] + fn test_task_without_name_uses_variable() { + let source = r#" +const myTask = task({ compute: "LOW" }, () => { + return "hello"; +}); +"#; + let configs = extract_js_task_configs(source, false).unwrap(); + assert!(configs.contains_key("myTask")); + assert_eq!(configs["myTask"]["name"], "myTask"); + } } diff --git a/crates/capsule-core/src/wasm/utilities/task_config.rs b/crates/capsule-core/src/wasm/utilities/task_config.rs index 89b0802..ec57bf1 100644 --- a/crates/capsule-core/src/wasm/utilities/task_config.rs +++ b/crates/capsule-core/src/wasm/utilities/task_config.rs @@ -37,6 +37,9 @@ pub struct TaskConfig { #[serde(alias = "allowedFiles")] allowed_files: Option>, + + #[serde(alias = "envVariables")] + env_variables: Option>, } impl TaskConfig { @@ -82,6 +85,12 @@ impl TaskConfig { .or_else(|| default_policy.and_then(|p| p.default_allowed_files.clone())) .unwrap_or_default(); + let env_variables = self + .env_variables + .clone() + .or_else(|| default_policy.and_then(|p| p.default_env_variables.clone())) + .unwrap_or_default(); + ExecutionPolicy::new() .name(self.name.clone()) .compute(compute) @@ -89,6 +98,7 @@ impl TaskConfig { .timeout(timeout) .max_retries(max_retries) .allowed_files(allowed_files) + .env_variables(env_variables) } pub fn parse_ram_string(s: &str) -> Option { @@ -186,6 +196,7 @@ mod tests { timeout: Some("30s".to_string()), max_retries: Some(3), allowed_files: Some(vec!["./data".to_string()]), + env_variables: Some(vec!["FOO".to_string()]), }; let policy = config.to_execution_policy(&CapsuleToml::default()); @@ -246,6 +257,7 @@ mod tests { default_timeout: Some("60s".to_string()), default_max_retries: Some(5), default_allowed_files: Some(vec!["./default".to_string()]), + default_env_variables: Some(vec!["FOO".to_string()]), }), }; @@ -269,6 +281,7 @@ mod tests { default_timeout: Some("30s".to_string()), default_max_retries: Some(2), default_allowed_files: Some(vec!["./default.txt".to_string()]), + default_env_variables: Some(vec!["FOO".to_string()]), }), }; @@ -279,6 +292,7 @@ mod tests { timeout: Some("120s".to_string()), max_retries: Some(10), allowed_files: Some(vec!["./custom".to_string()]), + env_variables: Some(vec!["BAR".to_string()]), }; let policy = config.to_execution_policy(&capsule_toml); @@ -289,6 +303,7 @@ mod tests { assert_eq!(policy.timeout, Some("120s".to_string())); assert_eq!(policy.max_retries, 10); assert_eq!(policy.allowed_files, vec!["./custom".to_string()]); + assert_eq!(policy.env_variables, vec!["BAR".to_string()]); } #[test] @@ -301,6 +316,7 @@ mod tests { default_timeout: Some("45s".to_string()), default_max_retries: Some(3), default_allowed_files: Some(vec!["./default".to_string()]), + default_env_variables: Some(vec!["FOO".to_string()]), }), }; @@ -311,6 +327,7 @@ mod tests { timeout: None, max_retries: Some(1), allowed_files: None, + env_variables: None, }; let policy = config.to_execution_policy(&capsule_toml); @@ -321,5 +338,6 @@ mod tests { assert_eq!(policy.timeout, Some("45s".to_string())); assert_eq!(policy.max_retries, 1); assert_eq!(policy.allowed_files, vec!["./default".to_string()]); + assert_eq!(policy.env_variables, vec!["FOO".to_string()]); } } diff --git a/crates/capsule-core/src/wasm/utilities/wit_manager.rs b/crates/capsule-core/src/wasm/utilities/wit_manager.rs index b074575..4a5274a 100644 --- a/crates/capsule-core/src/wasm/utilities/wit_manager.rs +++ b/crates/capsule-core/src/wasm/utilities/wit_manager.rs @@ -58,6 +58,104 @@ pub const WASI_DEPS: &[(&str, &str, &str)] = &[ "world.wit", include_str!("../../../../capsule-wit/deps/clocks/world.wit"), ), + // cli deps for environment variables + ( + "cli", + "command.wit", + include_str!("../../../../capsule-wit/deps/cli/command.wit"), + ), + ( + "cli", + "environment.wit", + include_str!("../../../../capsule-wit/deps/cli/environment.wit"), + ), + ( + "cli", + "exit.wit", + include_str!("../../../../capsule-wit/deps/cli/exit.wit"), + ), + ( + "cli", + "imports.wit", + include_str!("../../../../capsule-wit/deps/cli/imports.wit"), + ), + ( + "cli", + "run.wit", + include_str!("../../../../capsule-wit/deps/cli/run.wit"), + ), + ( + "cli", + "stdio.wit", + include_str!("../../../../capsule-wit/deps/cli/stdio.wit"), + ), + ( + "cli", + "terminal.wit", + include_str!("../../../../capsule-wit/deps/cli/terminal.wit"), + ), + // sockets deps (required by cli) + ( + "sockets", + "instance-network.wit", + include_str!("../../../../capsule-wit/deps/sockets/instance-network.wit"), + ), + ( + "sockets", + "ip-name-lookup.wit", + include_str!("../../../../capsule-wit/deps/sockets/ip-name-lookup.wit"), + ), + ( + "sockets", + "network.wit", + include_str!("../../../../capsule-wit/deps/sockets/network.wit"), + ), + ( + "sockets", + "tcp-create-socket.wit", + include_str!("../../../../capsule-wit/deps/sockets/tcp-create-socket.wit"), + ), + ( + "sockets", + "tcp.wit", + include_str!("../../../../capsule-wit/deps/sockets/tcp.wit"), + ), + ( + "sockets", + "udp-create-socket.wit", + include_str!("../../../../capsule-wit/deps/sockets/udp-create-socket.wit"), + ), + ( + "sockets", + "udp.wit", + include_str!("../../../../capsule-wit/deps/sockets/udp.wit"), + ), + ( + "sockets", + "world.wit", + include_str!("../../../../capsule-wit/deps/sockets/world.wit"), + ), + // random deps (required by cli) + ( + "random", + "insecure-seed.wit", + include_str!("../../../../capsule-wit/deps/random/insecure-seed.wit"), + ), + ( + "random", + "insecure.wit", + include_str!("../../../../capsule-wit/deps/random/insecure.wit"), + ), + ( + "random", + "random.wit", + include_str!("../../../../capsule-wit/deps/random/random.wit"), + ), + ( + "random", + "world.wit", + include_str!("../../../../capsule-wit/deps/random/world.wit"), + ), ]; pub struct WitManager {} diff --git a/crates/capsule-sdk/javascript/README.md b/crates/capsule-sdk/javascript/README.md index 91723a0..12e6e38 100644 --- a/crates/capsule-sdk/javascript/README.md +++ b/crates/capsule-sdk/javascript/README.md @@ -42,7 +42,6 @@ Run it: capsule run hello.ts ``` -> [!TIP] > Use `--verbose` to display real-time task execution details. ## How It Works @@ -71,7 +70,6 @@ export const main = task({ }); ``` -> [!NOTE] > The runtime requires a task named `"main"` as the entry point. When you run `capsule run main.ts`, your code is compiled into a WebAssembly module and executed in a dedicated sandbox. @@ -115,6 +113,7 @@ Every task returns a structured JSON envelope containing both the result and exe | `timeout` | Maximum execution time | `string` or `number` | unlimited | `"30s"`, `"5m"` | | `maxRetries` | Retry attempts on failure | `number` | `0` | `3` | | `allowedFiles` | Folders accessible in the sandbox | `string[]` | `[]` | `["./data", "./output"]` | +| `envVariables` | Environment variables accessible in the sandbox | `string[]` | `[]` | `["API_KEY"]` | ### Compute Levels @@ -171,6 +170,27 @@ Available methods: - `files.list(path)` — List directory contents - `files.exists(path)` — Check if file exists +### Environment Variables + +Tasks can access environment variables to read configuration, API keys, or other runtime settings. Use the `env` API provided by the SDK: + +```typescript +import { task, env } from "@capsule-run/sdk"; + +export const main = task({ + name: "main", + envVariables: ["API_KEY"] +}, () => { + const apiKey = env.get("API_KEY"); + return { apiKeySet: apiKey !== undefined }; +}); +``` + +Available methods: +- `env.get(key)` — Get a specific environment variable (returns `undefined` if not found) +- `env.has(key)` — Check if an environment variable exists +- `env.getAll()` — Get all environment variables as an object + ## Compatibility ✅ **Supported:** diff --git a/crates/capsule-sdk/javascript/package-lock.json b/crates/capsule-sdk/javascript/package-lock.json index f1cc7cc..fc29ac1 100644 --- a/crates/capsule-sdk/javascript/package-lock.json +++ b/crates/capsule-sdk/javascript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@capsule-run/sdk", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@capsule-run/sdk", - "version": "0.4.1", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { "@bytecodealliance/jco": "^1.0.0", diff --git a/crates/capsule-sdk/javascript/package.json b/crates/capsule-sdk/javascript/package.json index 35a309b..a6737e6 100644 --- a/crates/capsule-sdk/javascript/package.json +++ b/crates/capsule-sdk/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@capsule-run/sdk", - "version": "0.4.1", + "version": "0.4.2", "description": "Capsule JavaScript SDK - run AI agent tasks in secure WASM sandboxes", "type": "module", "main": "./dist/index.js", diff --git a/crates/capsule-sdk/javascript/src/app.ts b/crates/capsule-sdk/javascript/src/app.ts index 939f48a..966ea89 100644 --- a/crates/capsule-sdk/javascript/src/app.ts +++ b/crates/capsule-sdk/javascript/src/app.ts @@ -14,6 +14,7 @@ export interface TaskConfig { timeout?: string; maxRetries?: number; allowedFiles?: string[]; + envVariables?: string[]; } const TASKS: Map> = new Map(); diff --git a/crates/capsule-sdk/javascript/src/env.ts b/crates/capsule-sdk/javascript/src/env.ts new file mode 100644 index 0000000..9eb1ff9 --- /dev/null +++ b/crates/capsule-sdk/javascript/src/env.ts @@ -0,0 +1,67 @@ +/** + * Capsule Environment API for WASM environment variables access. + * Based on wasi:cli/environment@0.2.0 + */ + +declare const globalThis: { + 'wasi:cli/environment': { + getEnvironment(): [string, string][]; + }; +}; + +/** + * Internal helper to safely get the WASI environment bindings. + */ +function getEnvBindings() { + try { + const env = globalThis['wasi:cli/environment']; + if (env && typeof env.getEnvironment === 'function') { + return env; + } + } catch {} + return null; +} + +/** + * Returns all environment variables as an object. + * * @returns An object where keys are variable names and values are their values. + * @example { "PORT": "8080" } + */ +export function getAll(): Record { + const bindings = getEnvBindings(); + if (!bindings) return {}; + + try { + const envList = bindings.getEnvironment(); + return Object.fromEntries(envList); + } catch (e) { + console.error("Failed to fetch environment variables:", e); + return {}; + } +} + +/** + * Get the value of a specific environment variable. + * * @param key - The name of the environment variable. + * @returns The value of the variable, or undefined if not found. + */ +export function get(key: string): string | undefined { + const bindings = getEnvBindings(); + if (!bindings) return undefined; + + try { + const envList = bindings.getEnvironment(); + const entry = envList.find(([k]) => k === key); + return entry ? entry[1] : undefined; + } catch { + return undefined; + } +} + +/** + * Check if an environment variable exists. + * * @param key - The name of the environment variable. + */ +export function has(key: string): boolean { + return get(key) !== undefined; +} diff --git a/crates/capsule-sdk/javascript/src/index.ts b/crates/capsule-sdk/javascript/src/index.ts index b3c5cef..378ae4f 100644 --- a/crates/capsule-sdk/javascript/src/index.ts +++ b/crates/capsule-sdk/javascript/src/index.ts @@ -17,5 +17,6 @@ export { task, type TaskOptions } from "./task.js"; export { TaskRunner, exports, type TaskConfig } from "./app.js"; export * as files from "./files.js"; +export * as env from "./env.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 ab8d253..d137805 100644 --- a/crates/capsule-sdk/javascript/src/task.ts +++ b/crates/capsule-sdk/javascript/src/task.ts @@ -25,6 +25,8 @@ export interface TaskOptions { maxRetries?: number; /** Files/folders accessible in the sandbox, e.g., ["./data"] */ allowedFiles?: string[]; + /** Environment variables available from your .env file for the task */ + envVariables?: string[]; } interface TaskResult { @@ -106,6 +108,7 @@ export function task( timeout: normalizeTimeout(options.timeout), maxRetries: options.maxRetries, allowedFiles: options.allowedFiles, + envVariables: options.envVariables, }; const wrapper = (...args: TArgs): TaskReturnType => { diff --git a/crates/capsule-sdk/python/pyproject.toml b/crates/capsule-sdk/python/pyproject.toml index 263309e..6cc4349 100644 --- a/crates/capsule-sdk/python/pyproject.toml +++ b/crates/capsule-sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capsule" -version = "0.4.1" +version = "0.4.2" description = "Capsule Python SDK - run AI agent tasks in secure WASM sandboxes" readme = "README.md" requires-python = ">=3.8" diff --git a/crates/capsule-sdk/python/src/capsule/decorator.py b/crates/capsule-sdk/python/src/capsule/decorator.py index f8de715..98fa5a3 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, allowed_files=None): +def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None, allowed_files=None, env_variables=None): """ Decorator to mark a function as a Capsule task. @@ -21,6 +21,7 @@ def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None, 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"] + env_variables: List of environment variables accessible in sandbox - e.g., ["ANTHROPIC_API_KEY"] In WASM mode: - The function is registered in the task registry with its config @@ -46,6 +47,8 @@ def decorator(func): task_config["max_retries"] = max_retries if allowed_files is not None: task_config["allowed_files"] = allowed_files + if env_variables is not None: + task_config["env_variables"] = env_variables @functools.wraps(func) def wrapper(*args, **kwargs): diff --git a/crates/capsule-wit/capsule.wit b/crates/capsule-wit/capsule.wit index afdeb55..670305d 100644 --- a/crates/capsule-wit/capsule.wit +++ b/crates/capsule-wit/capsule.wit @@ -46,5 +46,7 @@ world capsule-agent { import wasi:filesystem/types@0.2.0; import wasi:filesystem/preopens@0.2.0; + import wasi:cli/environment@0.2.0; + export task-runner; } diff --git a/examples/javascript/dialogue-evaluator/.env.example b/examples/javascript/dialogue-evaluator/.env.example new file mode 100644 index 0000000..f46980b --- /dev/null +++ b/examples/javascript/dialogue-evaluator/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=xxxx diff --git a/examples/javascript/langchain-dialogue-evaluator/capsule.toml b/examples/javascript/dialogue-evaluator/README.md similarity index 100% rename from examples/javascript/langchain-dialogue-evaluator/capsule.toml rename to examples/javascript/dialogue-evaluator/README.md diff --git a/examples/javascript/dialogue-evaluator/capsule.toml b/examples/javascript/dialogue-evaluator/capsule.toml new file mode 100644 index 0000000..ad4fa18 --- /dev/null +++ b/examples/javascript/dialogue-evaluator/capsule.toml @@ -0,0 +1,7 @@ +[workflow] +name = "dialogue-evaluator" +version = "0.1.0" +entrypoint = "main.ts" + +[tasks] +default_ram = "main" diff --git a/examples/javascript/dialogue-evaluator/main.ts b/examples/javascript/dialogue-evaluator/main.ts new file mode 100644 index 0000000..9eeeea0 --- /dev/null +++ b/examples/javascript/dialogue-evaluator/main.ts @@ -0,0 +1,105 @@ +import { task, files, env } from "@capsule-run/sdk"; +import OpenAI from "openai"; + +const getDialogueLines = task({ + name: "getDialogueLines", + compute: "LOW", + allowedFiles: ["source"] +}, async (): Promise<{lines: string[]}> => { + const dialogues = await files.readText('source/dialogue-lines.json') + return JSON.parse(dialogues); +}) + +const insertDialogueLine = task({ + name: "insertDialogueLine", + allowedFiles: ["output"] +}, async (line: string, evaluation: string): Promise => { + const output = await files.readText('output/evaluated-dialogue-lines.csv') + const outputCsv = output.trim().split('\n'); + + outputCsv.push(`"${line}",${evaluation}`); + + await files.writeText('output/evaluated-dialogue-lines.csv', outputCsv.join('\n')); +}) + +const evaluateDialogueLine = task({ + name: "evaluateDialogueLine", + envVariables: ["OPENAI_API_KEY"] +}, async (prompt: string): Promise => { + + const openai = new OpenAI({ + baseURL: 'YOUR BASE URL', + apiKey: env.get('OPENAI_API_KEY'), + }); + + const completion = await openai.chat.completions.create({ + messages: [{ role: "user", content: prompt }], + model: "YOUR MODEL", + }); + + return completion.choices[0].message.content || ""; +}) + +const agent = task({ + name: "agent", + compute: "HIGH", + maxRetries: 2, + envVariables: ["OPENAI_API_KEY"] +}, async (line: string) => { + const prompt = ` + Analyze the emotional tone of the following dialogue line: "${line}" + + Respond with a single lowercase word representing the emotion (e.g., neutral, angry, happy). + Do not include any explanation. + + Examples: + "What's in the suitcase?" -> "neutral" + "You're out of your mind." -> "angry" + "I'll buy it off you." -> "happy" + `; + + const evaluateDialogueLineResponse = await evaluateDialogueLine(prompt); + await insertDialogueLine(line, evaluateDialogueLineResponse.result); + return; +}) + +// entrypoint +task({name: "main", compute: "HIGH"}, async () => { + + const getDialogueLinesResponse = await getDialogueLines(); + const lines = getDialogueLinesResponse.result.lines; + + const failedAttempts = []; + let successfulAttempts = 0; + + for (const [index, line] of lines.entries()) { + const agentResponse = await agent(line); + + if(!agentResponse.success) { + if(failedAttempts.length == 3) { + break; + } + + failedAttempts.push(line); + continue; + } + + successfulAttempts++; + + if(successfulAttempts == 3) { + break; + } + + console.log(`Agent-${index} successfully evaluated line`); + } + + console.log( + "\n ----------\n", + `Agent created: ${lines.length}\n`, + `Successful attempts: ${successfulAttempts}\n`, + `Failed attempts: ${failedAttempts.length}\n`, + "----------\n" + ) + + return; +}) diff --git a/examples/javascript/langchain-dialogue-evaluator/output/evaluated-dialogue-lines.csv b/examples/javascript/dialogue-evaluator/output/evaluated-dialogue-lines.csv similarity index 100% rename from examples/javascript/langchain-dialogue-evaluator/output/evaluated-dialogue-lines.csv rename to examples/javascript/dialogue-evaluator/output/evaluated-dialogue-lines.csv diff --git a/examples/javascript/dialogue-evaluator/package-lock.json b/examples/javascript/dialogue-evaluator/package-lock.json new file mode 100644 index 0000000..f2e8827 --- /dev/null +++ b/examples/javascript/dialogue-evaluator/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "dialogue-evaluator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dialogue-evaluator", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "openai": "^6.16.0" + } + }, + "node_modules/openai": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", + "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + } + } +} diff --git a/examples/javascript/langchain-dialogue-evaluator/package.json b/examples/javascript/dialogue-evaluator/package.json similarity index 63% rename from examples/javascript/langchain-dialogue-evaluator/package.json rename to examples/javascript/dialogue-evaluator/package.json index 61068ac..ce6e219 100644 --- a/examples/javascript/langchain-dialogue-evaluator/package.json +++ b/examples/javascript/dialogue-evaluator/package.json @@ -1,5 +1,5 @@ { - "name": "langchain-dialogue-evaluator", + "name": "dialogue-evaluator", "version": "1.0.0", "description": "", "license": "ISC", @@ -8,5 +8,9 @@ "main": "main.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "openai": "^6.16.0", + "@capsule-run/sdk": "^0.4.2" } } diff --git a/examples/javascript/langchain-dialogue-evaluator/source/dialogue-lines.json b/examples/javascript/dialogue-evaluator/source/dialogue-lines.json similarity index 100% rename from examples/javascript/langchain-dialogue-evaluator/source/dialogue-lines.json rename to examples/javascript/dialogue-evaluator/source/dialogue-lines.json diff --git a/examples/javascript/langchain-dialogue-evaluator/main.ts b/examples/javascript/langchain-dialogue-evaluator/main.ts deleted file mode 100644 index e69de29..0000000