diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3257c..f351927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Shell Emulation in Agent Exec API**: built-in shell commands for familiar terminal patterns inside sandbox sessions + - `POST /api/v1/sessions/:id/exec` accepts a new `command` field with a shell-style command line + - Built-ins: `echo`, `cat`, `ls`, `pwd`, `cd`, `mkdir` (`-p`), `rm` (`-r`/`-rf`), `cp`, `mv`, `env`, `export` + - Pipes (`|`), redirection (`>`, `>>`, `<`), and sequencing (`&&` short-circuit, `;`) + - Single and double quoted strings + - `command` takes precedence over `files`/`source`/`wasm_path` when present + - `export KEY=value` writes through to the session's `WasiEnv`, so exported variables persist across `command`/`exec` calls and are visible to subsequent WASM executions + - CWD scoped to a single shell invocation (each `command` request starts at `/`); `cd` mutates the in-invocation CWD so chains like `cd sub && pwd` work + - All shell file operations resolved through the session work_dir with path-traversal prevention + - All in-process built-ins — no subprocess execution, no access to the host shell or native binaries + - `execute_code` tool schema documents the `command` field for LLM agents +- **Multi-file JavaScript Project Execution**: agents can upload and run an entire JS project in a single exec request + - `POST /api/v1/sessions/:id/exec` accepts `files` (map of filename → content) and `entry` (entry filename) + - All files written to the session work_dir (with intermediate directories created) before execution + - Filename validation rejects empty names, absolute paths, and `..` traversal + - Dispatch order in `handle_exec`: `command` → `files` → `source` → `wasm_path`; language/entry validation runs synchronously so callers get HTTP 400 immediately + - Sibling files visible to the runtime via the preopened WASI directory (`require()` of siblings depends on runtime-side support — the wasmhub `nodejs` runtime v0.2.0 does not yet implement CommonJS `require()`) + - `execute_code` tool schema documents `files`/`entry` for LLM agents +- **JavaScript Source Execution in Agent Exec API**: run JS without precompiling to WASM + - `POST /api/v1/sessions/:id/exec` accepts `source` + `language` as an alternative to `wasm_path` + - Supported language aliases: `javascript`, `js`, `nodejs` — all map to the wasmhub nodejs runtime + - Unsupported languages (e.g. `python`) return HTTP 400 with a clear message, before any thread spawn or network I/O + - Runtime fetched from wasmhub via `runtime_cache` and cached after first download + - Source written to `_run_.js` in the session work_dir and executed with the runtime + - `source` without `language` defaults to JavaScript + +### Changed +- Exec threads (both source and WASM execution paths) now run with a 64 MB stack via `std::thread::Builder::stack_size` — language runtimes like QuickJS generate deep call chains that overflow the default 8 MB stack +- wasmhub runtime renamed `quickjs` → `nodejs`; manifest URL pinned to v0.2.0 +- Removed six WASI debug `eprintln!` calls that leaked to host stderr + +## [0.19.0](https://github.com/anistark/wasmrun/releases/tag/v0.19.0) - 2026-05-20 + ### Added - **Agent Tool Schemas for LLM Agents**: Function-calling definitions for AI agent integration - `GET /api/v1/tools` — returns tool definitions for LLM function calling diff --git a/docs/docs/exec/agent.md b/docs/docs/exec/agent.md index 0dfde8b..7cf77a4 100644 --- a/docs/docs/exec/agent.md +++ b/docs/docs/exec/agent.md @@ -33,7 +33,7 @@ The agent API manages **sessions** — each session is an isolated exec mode san - **Output buffers** — stdout/stderr captured per execution - **Timeout** — auto-cleanup after idle expiry -When you call the exec endpoint, the server loads the WASM file from the session's filesystem, runs it through the same interpreter used by `wasmrun exec`, and returns the captured output as JSON. +The exec endpoint accepts four input modes — a shell command line, a JavaScript source snippet, a multi-file JS project, or a pre-compiled `.wasm` file — and returns captured stdout/stderr/exit code as JSON. JavaScript runs through a wasmhub-hosted language runtime; WASM modules run through the same interpreter used by `wasmrun exec`. Shell commands are handled by an in-process built-in shell with no subprocess or host shell access. ``` ┌─ wasmrun agent ─────────────────────────────────────────┐ @@ -66,12 +66,22 @@ wasmrun agent --port 8430 curl -X POST http://localhost:8430/api/v1/sessions # → {"session_id": "a1b2c3...", "created_at": "..."} -# Upload a WASM file +# Run a shell command in the session +curl -X POST http://localhost:8430/api/v1/sessions/a1b2c3.../exec \ + -H "Content-Type: application/json" \ + -d '{"command": "echo hello > out.txt && cat out.txt"}' +# → {"stdout": "hello\n", "stderr": "", "exit_code": 0, ...} + +# Or run JavaScript inline +curl -X POST http://localhost:8430/api/v1/sessions/a1b2c3.../exec \ + -H "Content-Type: application/json" \ + -d '{"source": "console.log(1+1)", "language": "javascript"}' +# → {"stdout": "2\n", "exit_code": 0, ...} + +# Or run a pre-compiled WASM file curl -X POST http://localhost:8430/api/v1/sessions/a1b2c3.../files \ -H "Content-Type: application/json" \ -d '{"path": "hello.wasm", "content": "..."}' - -# Execute it curl -X POST http://localhost:8430/api/v1/sessions/a1b2c3.../exec \ -H "Content-Type: application/json" \ -d '{"wasm_path": "hello.wasm"}' @@ -81,6 +91,8 @@ curl -X POST http://localhost:8430/api/v1/sessions/a1b2c3.../exec \ curl -X DELETE http://localhost:8430/api/v1/sessions/a1b2c3... ``` +See the [Agent Execution](./usage/agent-exec.md) reference for all four input modes (shell `command`, JS `source`, multi-file `files`+`entry`, `wasm_path`). + ## Tool Schemas for LLM Agents The server exposes tool definitions that can be passed directly to OpenAI or Anthropic APIs for function calling: diff --git a/docs/docs/exec/usage/agent-exec.md b/docs/docs/exec/usage/agent-exec.md index 1adb095..4472366 100644 --- a/docs/docs/exec/usage/agent-exec.md +++ b/docs/docs/exec/usage/agent-exec.md @@ -3,38 +3,34 @@ sidebar_position: 6 title: Agent Execution --- -# Agent WASM Execution +# Agent Execution -Execute a `.wasm` file within a session's sandbox. +Run code inside a session's sandbox. The exec endpoint accepts four mutually exclusive input modes: -## Execute WASM +| Mode | Request field(s) | Use when | +|------|------------------|----------| +| Shell command | `command` | You want a familiar terminal-style one-liner over the session FS | +| JavaScript source | `source` + `language` | You have a single JS snippet to evaluate | +| Multi-file JS project | `files` + `entry` (+ `language`) | You have several source files that need to live on disk together | +| Pre-compiled WASM | `wasm_path` (+ `function`, `args`) | You already have a `.wasm` file in the session FS | + +If more than one is provided, dispatch follows that priority order (`command` → `files` → `source` → `wasm_path`). ``` POST /api/v1/sessions/:id/exec ``` -**Request body:** -```json -{ - "wasm_path": "hello.wasm", - "function": "_start", - "args": ["arg1", "arg2"], - "timeout": 30, - "env": { - "MY_VAR": "value" - } -} -``` +## Common Fields + +These apply to every mode: | Field | Required | Default | Description | |-------|----------|---------|-------------| -| `wasm_path` | yes | — | Path to `.wasm` file relative to session root | -| `function` | no | auto-detect | Exported function to call (defaults to `_start`, `main`, or start section) | -| `args` | no | `[]` | Arguments passed to the WASM program | | `timeout` | no | `30` | Execution timeout in seconds | | `env` | no | `{}` | Environment variables to set before execution | -**Response** (200): +## Common Response + ```json { "stdout": "Hello, World!\n", @@ -56,6 +52,117 @@ If execution fails (parse error, trap, etc.), the response still returns 200 wit } ``` +Output buffers are cleared between calls — each response contains only the output of that invocation. + +--- + +## Shell Command + +Run a built-in shell command line against the session filesystem. No language runtime, no WASM module, no subprocess. + +```json +{ + "command": "echo hello > out.txt && cat out.txt" +} +``` + +**Supported built-ins:** `echo`, `cat`, `ls`, `pwd`, `cd`, `mkdir` (`-p`), `rm` (`-r`/`-rf`), `cp`, `mv`, `env`, `export`. + +**Operators:** pipes (`|`), redirection (`>`, `>>`, `<`), sequencing (`&&` short-circuit on failure, `;` always continue), single and double quoted strings. + +**Scope:** +- CWD starts at `/` for every request. `cd` mutates the in-invocation CWD, so chains like `cd sub && pwd && ls` work, but the CWD is not carried across requests. +- `export KEY=value` writes through to the session's environment, so the variable is visible to subsequent `command`/`source`/`files`/`wasm_path` executions in the same session. +- Path traversal that escapes the session root is rejected. + +Unknown commands return exit code `127` with `command not found` on stderr — there is no fallback to the host shell. + +**Example:** +```sh +curl -X POST .../exec -H "Content-Type: application/json" -d '{ + "command": "mkdir -p logs && echo started > logs/run.log && ls logs" +}' +# → {"stdout": "run.log\n", "stderr": "", "exit_code": 0, ...} +``` + +--- + +## JavaScript Source + +Evaluate a single source string with the wasmhub JavaScript runtime. The runtime is fetched once and cached. + +```json +{ + "source": "console.log(1 + 1)", + "language": "javascript" +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `source` | yes | — | Source code to execute | +| `language` | no | `javascript` | One of `javascript`, `js`, `nodejs` | + +Unsupported languages return HTTP `400` with a clear message before any thread is spawned. + +**Example:** +```sh +curl -X POST .../exec -H "Content-Type: application/json" -d '{ + "source": "console.log(1+1)", + "language": "javascript" +}' +# → {"stdout": "2\n", "exit_code": 0, ...} +``` + +--- + +## Multi-file JavaScript Project + +Upload an entire project in one request. All files are written to the session root (creating intermediate directories) and the entry file is run. + +```json +{ + "files": { + "main.js": "console.log('hi');", + "lib/util.js": "exports.greet = n => 'hi ' + n;" + }, + "entry": "main.js", + "language": "javascript" +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `files` | yes | — | Map of filename → file content. Filenames must be relative and free of `..` | +| `entry` | yes | — | Entry filename; must be a key in `files` | +| `language` | no | `javascript` | One of `javascript`, `js`, `nodejs` | + +Validation (missing entry, unknown language, absolute/traversal paths) runs synchronously and returns HTTP `400` immediately. + +Sibling files are visible to the runtime via the preopened WASI directory, but loading them from JS requires runtime-side support. The wasmhub `nodejs` runtime (v0.2.0) does not yet implement CommonJS `require()` — files can be uploaded today and will become loadable once the runtime ships that. + +--- + +## Pre-compiled WASM + +Execute a `.wasm` file already present in the session filesystem. + +```json +{ + "wasm_path": "hello.wasm", + "function": "_start", + "args": ["arg1", "arg2"] +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `wasm_path` | yes | — | Path to `.wasm` file relative to session root | +| `function` | no | auto-detect | Exported function to call (defaults to `_start`, `main`, or start section) | +| `args` | no | `[]` | Arguments passed to the WASM program | + +--- + ## Timeout If execution exceeds the timeout, the response includes: @@ -70,27 +177,13 @@ If execution exceeds the timeout, the response includes: } ``` -## Multiple Executions - -A session supports multiple sequential executions. Output buffers are cleared between each call — you always get only the output from the current execution. - -```sh -# First exec -curl -X POST .../exec -d '{"wasm_path": "a.wasm"}' -# → {"stdout": "output from a", ...} - -# Second exec (does NOT include output from a) -curl -X POST .../exec -d '{"wasm_path": "b.wasm"}' -# → {"stdout": "output from b", ...} -``` - ## Workflow -A typical agent workflow: +A typical agent loop: -1. Create session -2. Write `.wasm` file via file upload endpoint -3. Execute it via `/exec` -4. Read the structured response -5. Optionally run more executions -6. Destroy session when done +1. Create a session. +2. Either upload files explicitly (file endpoints) or pass them inline via `files`/`source`/`command`. +3. Execute via `/exec`. +4. Read the structured response. +5. Optionally run more executions in the same session — the filesystem and exported env vars persist. +6. Destroy the session when done. diff --git a/src/agent/api.rs b/src/agent/api.rs index c7029d2..cd3a076 100644 --- a/src/agent/api.rs +++ b/src/agent/api.rs @@ -19,6 +19,11 @@ pub struct ExecRequest { pub entry: Option, /// Language for source execution: "javascript", "js", or "nodejs". pub language: Option, + /// Shell command line to execute via the built-in shell emulator. + /// Supports pipes (`|`), redirection (`>`, `>>`, `<`), and sequencing + /// (`&&`, `;`) with built-ins for common file/env operations. + /// Takes precedence over `files`/`source`/`wasm_path` when present. + pub command: Option, pub function: Option, #[serde(default)] pub args: Vec, diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 6d2f1d3..12aa9f5 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -8,4 +8,5 @@ pub mod api; pub mod executor; pub mod server; pub mod session; +pub mod shell; pub mod tools; diff --git a/src/agent/server.rs b/src/agent/server.rs index ad97090..9c30ba9 100644 --- a/src/agent/server.rs +++ b/src/agent/server.rs @@ -3,6 +3,7 @@ use crate::agent::api::*; use crate::agent::executor; use crate::agent::session::{SessionConfig, SessionError, SessionManager, SessionState}; +use crate::agent::shell; use crate::agent::tools; use crate::error::{Result, WasmrunError}; use crate::runtime::core::native_executor::execute_wasm_bytes_with_env; @@ -294,7 +295,19 @@ impl AgentServer { let (tx, rx) = std::sync::mpsc::channel::>(); - if let Some(files) = req.files { + if let Some(command) = req.command { + // Built-in shell emulation: parse and run the command line + // against the session's filesystem. No WASM module is loaded. + let work_dir_clone = work_dir.clone(); + std::thread::Builder::new() + .stack_size(EXEC_THREAD_STACK_BYTES) + .spawn(move || { + let result = shell::run_command(&command, &work_dir_clone, exec_env) + .map_err(|e| ApiError::BadRequest(e.to_string())); + let _ = tx.send(result); + }) + .map_err(|e| ApiError::Internal(format!("Failed to spawn exec thread: {e}")))?; + } else if let Some(files) = req.files { // Multi-file source project: write all files and run entry through runtime let lang = req.language.unwrap_or_else(|| "javascript".into()); executor::resolve_runtime(&lang)?; @@ -364,7 +377,7 @@ impl AgentServer { .map_err(|e| ApiError::Internal(format!("Failed to spawn exec thread: {e}")))?; } else { return Err(ApiError::BadRequest( - "Missing wasm_path, source, or files".into(), + "Missing command, wasm_path, source, or files".into(), )); } @@ -1087,6 +1100,94 @@ mod tests { server.session_manager.destroy_all().unwrap(); } + // ── Shell command exec ──────────────────────────────────────── + + #[test] + fn test_exec_command_echo() { + let server = test_server(); + let id = server.handle_create_session().unwrap().session_id; + + let resp = server + .handle_exec(&id, r#"{"command": "echo hello"}"#) + .unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(resp.stdout, "hello\n"); + assert!(resp.error.is_none()); + + server.session_manager.destroy_all().unwrap(); + } + + #[test] + fn test_exec_command_redirect_then_cat() { + let server = test_server(); + let id = server.handle_create_session().unwrap().session_id; + + let resp = server + .handle_exec( + &id, + r#"{"command": "echo persisted > log.txt && cat log.txt"}"#, + ) + .unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(resp.stdout, "persisted\n"); + + // Verify the file is actually in the session work_dir + let content = server.handle_read_file(&id, "log.txt").unwrap(); + assert_eq!(content.content, "persisted\n"); + + server.session_manager.destroy_all().unwrap(); + } + + #[test] + fn test_exec_command_takes_precedence_over_wasm_path() { + let server = test_server(); + let id = server.handle_create_session().unwrap().session_id; + + // wasm_path points at a nonexistent file but command should win. + let resp = server + .handle_exec( + &id, + r#"{"command": "echo first", "wasm_path": "nope.wasm"}"#, + ) + .unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(resp.stdout, "first\n"); + + server.session_manager.destroy_all().unwrap(); + } + + #[test] + fn test_exec_command_export_persists_in_session() { + let server = test_server(); + let id = server.handle_create_session().unwrap().session_id; + + // Export via shell, then verify it shows up through the env endpoint. + server + .handle_exec(&id, r#"{"command": "export GREETING=hi"}"#) + .unwrap(); + + let env = server.handle_get_env(&id).unwrap(); + assert_eq!(env.env.get("GREETING").map(|s| s.as_str()), Some("hi")); + + server.session_manager.destroy_all().unwrap(); + } + + #[test] + fn test_exec_command_parse_error_returns_400() { + let server = test_server(); + let id = server.handle_create_session().unwrap().session_id; + + // Unclosed quote → parse error → BadRequest + let resp = server + .handle_exec(&id, r#"{"command": "echo \"oops"}"#) + .unwrap(); + // Parse error is surfaced via ExecResponse.error from the exec thread. + assert_eq!(resp.exit_code, -1); + assert!(resp.error.is_some()); + + server.session_manager.destroy_all().unwrap(); + } + #[test] fn test_exec_clears_output_between_calls() { let server = test_server(); diff --git a/src/agent/shell.rs b/src/agent/shell.rs new file mode 100644 index 0000000..d257efc --- /dev/null +++ b/src/agent/shell.rs @@ -0,0 +1,1231 @@ +//! Agent mode: Basic shell emulation for sandboxed sessions. +//! +//! Lets LLM agents use familiar `command arg1 arg2` patterns operating on the +//! session's WASI filesystem. All commands are in-process built-ins — there is +//! no subprocess execution and no access to the host shell. +//! +//! Supported features: +//! - Built-ins: `echo`, `cat`, `ls`, `pwd`, `cd`, `mkdir`, `rm`, `cp`, `mv`, +//! `env`, `export` +//! - Pipes: `echo hello | cat` +//! - Redirection: `>`, `>>`, `<` +//! - Sequencing: `&&` (short-circuit on failure), `;` +//! - Single and double quoted strings +//! +//! CWD is scoped to a single shell invocation (each `command` request starts at +//! `/`). `export KEY=value` writes to the session's `WasiEnv` so the variable +//! is visible to subsequent WASM executions in the same session. + +use crate::runtime::wasi::WasiEnv; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug)] +pub enum ShellError { + Parse(String), +} + +impl std::fmt::Display for ShellError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ShellError::Parse(m) => write!(f, "Parse error: {m}"), + } + } +} + +/// Run a shell command line in the given session work_dir, capturing output +/// into the session's `WasiEnv` stdout/stderr buffers. Returns the exit code +/// of the final pipeline. +pub fn run_command( + line: &str, + work_dir: &Path, + wasi_env: Arc>, +) -> Result { + let statement = parse(line)?; + let mut shell = Shell::new(work_dir.to_path_buf(), wasi_env); + shell.execute_statement(&statement) +} + +// ── Tokens & AST ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Word(String), + Pipe, + RedirOut, + RedirAppend, + RedirIn, + AndAnd, + Semi, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum RedirKind { + Out, + Append, + In, +} + +#[derive(Debug, Clone)] +struct Redirect { + kind: RedirKind, + target: String, +} + +#[derive(Debug, Clone)] +struct Command { + args: Vec, + redirects: Vec, +} + +#[derive(Debug, Clone)] +struct Pipeline { + commands: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Connector { + And, + Semi, +} + +/// A list of pipelines joined by sequencing operators. The Vec entries are +/// `(pipeline, connector_to_next)`, with the last entry's connector ignored. +#[derive(Debug, Clone)] +struct Statement { + pipelines: Vec<(Pipeline, Connector)>, +} + +// ── Tokenizer ───────────────────────────────────────────────────────── + +fn tokenize(line: &str) -> Result, ShellError> { + let mut tokens = Vec::new(); + let mut chars = line.chars().peekable(); + while let Some(&c) = chars.peek() { + match c { + ' ' | '\t' | '\n' | '\r' => { + chars.next(); + } + '|' => { + chars.next(); + tokens.push(Token::Pipe); + } + '>' => { + chars.next(); + if chars.peek() == Some(&'>') { + chars.next(); + tokens.push(Token::RedirAppend); + } else { + tokens.push(Token::RedirOut); + } + } + '<' => { + chars.next(); + tokens.push(Token::RedirIn); + } + '&' => { + chars.next(); + if chars.peek() == Some(&'&') { + chars.next(); + tokens.push(Token::AndAnd); + } else { + return Err(ShellError::Parse( + "Background '&' is not supported; use '&&' for sequencing".into(), + )); + } + } + ';' => { + chars.next(); + tokens.push(Token::Semi); + } + '"' | '\'' => { + let quote = c; + chars.next(); + let mut word = String::new(); + let mut closed = false; + while let Some(&ch) = chars.peek() { + if ch == quote { + chars.next(); + closed = true; + break; + } + word.push(ch); + chars.next(); + } + if !closed { + return Err(ShellError::Parse(format!("Unclosed {quote} quote"))); + } + tokens.push(Token::Word(word)); + } + _ => { + let mut word = String::new(); + while let Some(&ch) = chars.peek() { + if ch.is_whitespace() || matches!(ch, '|' | '>' | '<' | '&' | ';') { + break; + } + word.push(ch); + chars.next(); + } + tokens.push(Token::Word(word)); + } + } + } + Ok(tokens) +} + +// ── Parser ──────────────────────────────────────────────────────────── + +fn parse(line: &str) -> Result { + let tokens = tokenize(line)?; + if tokens.is_empty() { + return Ok(Statement { pipelines: vec![] }); + } + + // Split by sequencing operators (&& / ;) into pipelines. + let mut pipelines: Vec<(Pipeline, Connector)> = Vec::new(); + let mut current: Vec = Vec::new(); + for tok in tokens { + match tok { + Token::AndAnd | Token::Semi => { + let conn = if matches!(tok, Token::AndAnd) { + Connector::And + } else { + Connector::Semi + }; + if current.is_empty() { + return Err(ShellError::Parse( + "Empty command before sequencing operator".into(), + )); + } + pipelines.push((parse_pipeline(¤t)?, conn)); + current.clear(); + } + other => current.push(other), + } + } + if !current.is_empty() { + pipelines.push((parse_pipeline(¤t)?, Connector::Semi)); + } + + Ok(Statement { pipelines }) +} + +fn parse_pipeline(tokens: &[Token]) -> Result { + let mut commands = Vec::new(); + let mut current: Vec = Vec::new(); + for tok in tokens { + match tok { + Token::Pipe => { + if current.is_empty() { + return Err(ShellError::Parse("Empty command before '|'".into())); + } + commands.push(parse_command(¤t)?); + current.clear(); + } + other => current.push(other.clone()), + } + } + if current.is_empty() { + return Err(ShellError::Parse("Empty command after '|'".into())); + } + commands.push(parse_command(¤t)?); + Ok(Pipeline { commands }) +} + +fn parse_command(tokens: &[Token]) -> Result { + let mut args = Vec::new(); + let mut redirects = Vec::new(); + let mut i = 0; + while i < tokens.len() { + match &tokens[i] { + Token::Word(w) => { + args.push(w.clone()); + i += 1; + } + Token::RedirOut | Token::RedirAppend | Token::RedirIn => { + let kind = match &tokens[i] { + Token::RedirOut => RedirKind::Out, + Token::RedirAppend => RedirKind::Append, + Token::RedirIn => RedirKind::In, + _ => unreachable!(), + }; + let target = match tokens.get(i + 1) { + Some(Token::Word(w)) => w.clone(), + _ => { + return Err(ShellError::Parse( + "Redirection requires a target filename".into(), + )); + } + }; + redirects.push(Redirect { kind, target }); + i += 2; + } + _ => { + return Err(ShellError::Parse(format!( + "Unexpected token in command: {:?}", + tokens[i] + ))); + } + } + } + if args.is_empty() { + return Err(ShellError::Parse("Empty command".into())); + } + Ok(Command { args, redirects }) +} + +// ── Shell execution state ───────────────────────────────────────────── + +struct Shell { + work_dir: PathBuf, + /// Guest-visible CWD, always starts with `/` and is normalized. + cwd: String, + wasi_env: Arc>, +} + +/// Per-command result. Captured stdout/stderr are returned so pipelines can +/// stitch them together before flushing to the session buffers. +struct CmdOutput { + stdout: Vec, + stderr: Vec, + exit_code: i32, +} + +impl Shell { + fn new(work_dir: PathBuf, wasi_env: Arc>) -> Self { + Self { + work_dir, + cwd: "/".to_string(), + wasi_env, + } + } + + fn execute_statement(&mut self, stmt: &Statement) -> Result { + let mut last_code = 0; + for (i, (pipeline, conn)) in stmt.pipelines.iter().enumerate() { + last_code = self.execute_pipeline(pipeline)?; + // Short-circuit on `&&` if previous failed and there is a next one. + let is_last = i + 1 == stmt.pipelines.len(); + if !is_last && *conn == Connector::And && last_code != 0 { + break; + } + } + Ok(last_code) + } + + fn execute_pipeline(&mut self, pipeline: &Pipeline) -> Result { + // Determine pipeline stdin from any leading `<` redirect on the first + // command, and the final stdout destination from the last command's + // `>`/`>>` redirect. Stderr from every command is flushed to the + // session's WASI buffer regardless. + let mut stdin_bytes: Vec = Vec::new(); + let first = &pipeline.commands[0]; + for r in &first.redirects { + if r.kind == RedirKind::In { + let host = self.resolve_host(&r.target)?; + match std::fs::read(&host) { + Ok(b) => stdin_bytes = b, + Err(e) => { + self.append_stderr(&format!("{}: {e}\n", r.target)); + return Ok(1); + } + } + } + } + + let last_idx = pipeline.commands.len() - 1; + let mut current_stdin = stdin_bytes; + let mut exit_code = 0; + + for (i, cmd) in pipeline.commands.iter().enumerate() { + let out = self.run_builtin(cmd, ¤t_stdin)?; + exit_code = out.exit_code; + if !out.stderr.is_empty() { + self.append_stderr_bytes(&out.stderr); + } + + let is_last = i == last_idx; + if is_last { + // Apply the last command's output redirection, or flush. + let mut wrote_to_file = false; + for r in &cmd.redirects { + if matches!(r.kind, RedirKind::Out | RedirKind::Append) { + let host = self.resolve_host(&r.target)?; + if let Some(parent) = host.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + self.append_stderr(&format!("{}: {e}\n", r.target)); + return Ok(1); + } + } + let write_res = match r.kind { + RedirKind::Append => std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&host) + .and_then(|mut f| { + use std::io::Write; + f.write_all(&out.stdout) + }), + RedirKind::Out => std::fs::write(&host, &out.stdout), + RedirKind::In => unreachable!(), + }; + if let Err(e) = write_res { + self.append_stderr(&format!("{}: {e}\n", r.target)); + return Ok(1); + } + wrote_to_file = true; + } + } + if !wrote_to_file { + self.append_stdout_bytes(&out.stdout); + } + } else { + current_stdin = out.stdout; + } + } + + Ok(exit_code) + } + + fn run_builtin(&mut self, cmd: &Command, stdin: &[u8]) -> Result { + let name = cmd.args[0].as_str(); + let args = &cmd.args[1..]; + let out = match name { + "echo" => builtin_echo(args), + "cat" => self.builtin_cat(args, stdin), + "ls" => self.builtin_ls(args), + "pwd" => self.builtin_pwd(), + "cd" => self.builtin_cd(args), + "mkdir" => self.builtin_mkdir(args), + "rm" => self.builtin_rm(args), + "cp" => self.builtin_cp(args), + "mv" => self.builtin_mv(args), + "env" => self.builtin_env(), + "export" => self.builtin_export(args), + other => CmdOutput { + stdout: Vec::new(), + stderr: format!("{other}: command not found\n").into_bytes(), + exit_code: 127, + }, + }; + Ok(out) + } + + // ── Path helpers ────────────────────────────────────────────── + + /// Normalize a guest path (absolute or relative to cwd) to an absolute + /// guest path that stays within `/`. + fn resolve_guest(&self, path: &str) -> Result { + let combined = if path.starts_with('/') { + PathBuf::from(path) + } else { + PathBuf::from(&self.cwd).join(path) + }; + let mut parts: Vec = Vec::new(); + for c in combined.components() { + match c { + Component::RootDir => parts.clear(), + Component::CurDir => {} + Component::ParentDir => { + if parts.is_empty() { + return Err(ShellError::Parse(format!( + "Path escapes session root: {path}" + ))); + } + parts.pop(); + } + Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()), + Component::Prefix(_) => { + return Err(ShellError::Parse(format!( + "Path prefix not supported: {path}" + ))); + } + } + } + let mut out = String::from("/"); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + out.push('/'); + } + out.push_str(p); + } + Ok(out) + } + + fn resolve_host(&self, path: &str) -> Result { + let guest = self.resolve_guest(path)?; + let stripped = guest.trim_start_matches('/'); + Ok(if stripped.is_empty() { + self.work_dir.clone() + } else { + self.work_dir.join(stripped) + }) + } + + // ── stdout/stderr plumbing ──────────────────────────────────── + + fn append_stdout_bytes(&self, bytes: &[u8]) { + if let Ok(mut env) = self.wasi_env.lock() { + env.stdout_mut().extend_from_slice(bytes); + } + } + + fn append_stderr_bytes(&self, bytes: &[u8]) { + if let Ok(mut env) = self.wasi_env.lock() { + env.stderr_mut().extend_from_slice(bytes); + } + } + + fn append_stderr(&self, s: &str) { + self.append_stderr_bytes(s.as_bytes()); + } + + // ── Builtins ────────────────────────────────────────────────── + + fn builtin_cat(&self, args: &[String], stdin: &[u8]) -> CmdOutput { + if args.is_empty() { + return CmdOutput { + stdout: stdin.to_vec(), + stderr: Vec::new(), + exit_code: 0, + }; + } + let mut out: Vec = Vec::new(); + let mut err: Vec = Vec::new(); + let mut code = 0; + for path in args { + let host = match self.resolve_host(path) { + Ok(p) => p, + Err(e) => { + err.extend_from_slice(format!("cat: {path}: {e}\n").as_bytes()); + code = 1; + continue; + } + }; + match std::fs::read(&host) { + Ok(b) => out.extend_from_slice(&b), + Err(e) => { + err.extend_from_slice(format!("cat: {path}: {e}\n").as_bytes()); + code = 1; + } + } + } + CmdOutput { + stdout: out, + stderr: err, + exit_code: code, + } + } + + fn builtin_ls(&self, args: &[String]) -> CmdOutput { + let target = args.first().map(|s| s.as_str()).unwrap_or("."); + let host = match self.resolve_host(target) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("ls: {target}: {e}\n").into_bytes(), + exit_code: 1, + }; + } + }; + let entries = match std::fs::read_dir(&host) { + Ok(e) => e, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("ls: {target}: {e}\n").into_bytes(), + exit_code: 1, + }; + } + }; + let mut names: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + let mut out = String::new(); + for n in &names { + out.push_str(n); + out.push('\n'); + } + CmdOutput { + stdout: out.into_bytes(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_pwd(&self) -> CmdOutput { + let mut s = self.cwd.clone(); + s.push('\n'); + CmdOutput { + stdout: s.into_bytes(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_cd(&mut self, args: &[String]) -> CmdOutput { + let target = args.first().map(|s| s.as_str()).unwrap_or("/"); + let resolved = match self.resolve_guest(target) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("cd: {target}: {e}\n").into_bytes(), + exit_code: 1, + }; + } + }; + let host = if resolved == "/" { + self.work_dir.clone() + } else { + self.work_dir.join(resolved.trim_start_matches('/')) + }; + if !host.is_dir() { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("cd: {target}: Not a directory\n").into_bytes(), + exit_code: 1, + }; + } + self.cwd = resolved; + CmdOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_mkdir(&self, args: &[String]) -> CmdOutput { + let mut err = Vec::new(); + let mut code = 0; + // Support `-p` flag to silently succeed when the directory exists. + let (recursive, paths): (bool, Vec<&String>) = + if args.first().map(|s| s.as_str()) == Some("-p") { + (true, args[1..].iter().collect()) + } else { + (false, args.iter().collect()) + }; + if paths.is_empty() { + return CmdOutput { + stdout: Vec::new(), + stderr: b"mkdir: missing operand\n".to_vec(), + exit_code: 1, + }; + } + for p in paths { + let host = match self.resolve_host(p) { + Ok(h) => h, + Err(e) => { + err.extend_from_slice(format!("mkdir: {p}: {e}\n").as_bytes()); + code = 1; + continue; + } + }; + let res = if recursive { + std::fs::create_dir_all(&host) + } else { + std::fs::create_dir(&host) + }; + if let Err(e) = res { + err.extend_from_slice(format!("mkdir: {p}: {e}\n").as_bytes()); + code = 1; + } + } + CmdOutput { + stdout: Vec::new(), + stderr: err, + exit_code: code, + } + } + + fn builtin_rm(&self, args: &[String]) -> CmdOutput { + let (recursive, paths): (bool, Vec<&String>) = match args.first().map(|s| s.as_str()) { + Some("-r") | Some("-rf") | Some("-fr") => (true, args[1..].iter().collect()), + _ => (false, args.iter().collect()), + }; + if paths.is_empty() { + return CmdOutput { + stdout: Vec::new(), + stderr: b"rm: missing operand\n".to_vec(), + exit_code: 1, + }; + } + let mut err = Vec::new(); + let mut code = 0; + for p in paths { + let host = match self.resolve_host(p) { + Ok(h) => h, + Err(e) => { + err.extend_from_slice(format!("rm: {p}: {e}\n").as_bytes()); + code = 1; + continue; + } + }; + let res = if host.is_dir() { + if recursive { + std::fs::remove_dir_all(&host) + } else { + err.extend_from_slice(format!("rm: {p}: is a directory\n").as_bytes()); + code = 1; + continue; + } + } else { + std::fs::remove_file(&host) + }; + if let Err(e) = res { + err.extend_from_slice(format!("rm: {p}: {e}\n").as_bytes()); + code = 1; + } + } + CmdOutput { + stdout: Vec::new(), + stderr: err, + exit_code: code, + } + } + + fn builtin_cp(&self, args: &[String]) -> CmdOutput { + if args.len() != 2 { + return CmdOutput { + stdout: Vec::new(), + stderr: b"cp: expected exactly 2 arguments (source, dest)\n".to_vec(), + exit_code: 1, + }; + } + let src = match self.resolve_host(&args[0]) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("cp: {}: {e}\n", args[0]).into_bytes(), + exit_code: 1, + }; + } + }; + let dst = match self.resolve_host(&args[1]) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("cp: {}: {e}\n", args[1]).into_bytes(), + exit_code: 1, + }; + } + }; + if let Err(e) = std::fs::copy(&src, &dst) { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("cp: {} -> {}: {e}\n", args[0], args[1]).into_bytes(), + exit_code: 1, + }; + } + CmdOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_mv(&self, args: &[String]) -> CmdOutput { + if args.len() != 2 { + return CmdOutput { + stdout: Vec::new(), + stderr: b"mv: expected exactly 2 arguments (source, dest)\n".to_vec(), + exit_code: 1, + }; + } + let src = match self.resolve_host(&args[0]) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("mv: {}: {e}\n", args[0]).into_bytes(), + exit_code: 1, + }; + } + }; + let dst = match self.resolve_host(&args[1]) { + Ok(p) => p, + Err(e) => { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("mv: {}: {e}\n", args[1]).into_bytes(), + exit_code: 1, + }; + } + }; + if let Err(e) = std::fs::rename(&src, &dst) { + return CmdOutput { + stdout: Vec::new(), + stderr: format!("mv: {} -> {}: {e}\n", args[0], args[1]).into_bytes(), + exit_code: 1, + }; + } + CmdOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_env(&self) -> CmdOutput { + let env = match self.wasi_env.lock() { + Ok(e) => e, + Err(_) => { + return CmdOutput { + stdout: Vec::new(), + stderr: b"env: lock error\n".to_vec(), + exit_code: 1, + }; + } + }; + let mut pairs: Vec<(String, String)> = env + .env_vars() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + drop(env); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + let mut out = String::new(); + for (k, v) in pairs { + out.push_str(&k); + out.push('='); + out.push_str(&v); + out.push('\n'); + } + CmdOutput { + stdout: out.into_bytes(), + stderr: Vec::new(), + exit_code: 0, + } + } + + fn builtin_export(&mut self, args: &[String]) -> CmdOutput { + if args.is_empty() { + // Same behavior as `env` when called with no arguments. + return self.builtin_env(); + } + let mut err = Vec::new(); + let mut code = 0; + for a in args { + match a.split_once('=') { + Some((k, v)) if !k.is_empty() => { + if let Ok(mut env) = self.wasi_env.lock() { + env.add_env(k.to_string(), v.to_string()); + } else { + return CmdOutput { + stdout: Vec::new(), + stderr: b"export: lock error\n".to_vec(), + exit_code: 1, + }; + } + } + _ => { + err.extend_from_slice( + format!("export: '{a}': not a valid KEY=value assignment\n").as_bytes(), + ); + code = 1; + } + } + } + CmdOutput { + stdout: Vec::new(), + stderr: err, + exit_code: code, + } + } +} + +fn builtin_echo(args: &[String]) -> CmdOutput { + let mut out = args.join(" "); + out.push('\n'); + CmdOutput { + stdout: out.into_bytes(), + stderr: Vec::new(), + exit_code: 0, + } +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_env() -> Arc> { + Arc::new(Mutex::new(WasiEnv::new())) + } + + fn make_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "wasmrun_shell_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn stdout(env: &Arc>) -> String { + String::from_utf8(env.lock().unwrap().get_stdout()).unwrap() + } + + fn stderr(env: &Arc>) -> String { + String::from_utf8(env.lock().unwrap().get_stderr()).unwrap() + } + + // ── Tokenizer ──────────────────────────────────────────────── + + #[test] + fn tokenize_simple() { + let toks = tokenize("echo hello world").unwrap(); + assert_eq!( + toks, + vec![ + Token::Word("echo".into()), + Token::Word("hello".into()), + Token::Word("world".into()), + ] + ); + } + + #[test] + fn tokenize_double_quotes() { + let toks = tokenize(r#"echo "hello world""#).unwrap(); + assert_eq!( + toks, + vec![ + Token::Word("echo".into()), + Token::Word("hello world".into()) + ] + ); + } + + #[test] + fn tokenize_single_quotes() { + let toks = tokenize("echo 'a b'").unwrap(); + assert_eq!( + toks, + vec![Token::Word("echo".into()), Token::Word("a b".into())] + ); + } + + #[test] + fn tokenize_pipe_and_redirect() { + let toks = tokenize("echo a | cat > out.txt").unwrap(); + assert_eq!( + toks, + vec![ + Token::Word("echo".into()), + Token::Word("a".into()), + Token::Pipe, + Token::Word("cat".into()), + Token::RedirOut, + Token::Word("out.txt".into()), + ] + ); + } + + #[test] + fn tokenize_append_and_and() { + let toks = tokenize("a >> b && c").unwrap(); + assert_eq!( + toks, + vec![ + Token::Word("a".into()), + Token::RedirAppend, + Token::Word("b".into()), + Token::AndAnd, + Token::Word("c".into()), + ] + ); + } + + #[test] + fn tokenize_unclosed_quote() { + assert!(tokenize(r#"echo "oops"#).is_err()); + } + + #[test] + fn tokenize_lone_ampersand_rejected() { + assert!(tokenize("foo &").is_err()); + } + + // ── Builtins (plan-spec tests) ─────────────────────────────── + + #[test] + fn plan_test_echo_hello() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("echo hello", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "hello\n"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn plan_test_ls_root_lists_dirs() { + let work = make_dir(); + std::fs::create_dir(work.join("sub1")).unwrap(); + std::fs::create_dir(work.join("sub2")).unwrap(); + let env = fresh_env(); + let code = run_command("ls /", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + let out = stdout(&env); + assert!(out.contains("sub1"), "missing sub1 in {out:?}"); + assert!(out.contains("sub2"), "missing sub2 in {out:?}"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn plan_test_echo_redirect_then_cat() { + let work = make_dir(); + let env = fresh_env(); + let code = + run_command("echo hello > test.txt && cat test.txt", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "hello\n"); + let on_disk = std::fs::read_to_string(work.join("test.txt")).unwrap(); + assert_eq!(on_disk, "hello\n"); + let _ = std::fs::remove_dir_all(&work); + } + + // ── Additional builtin behavior ────────────────────────────── + + #[test] + fn pwd_is_root_by_default() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("pwd", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "/\n"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cd_then_pwd() { + let work = make_dir(); + std::fs::create_dir(work.join("sub")).unwrap(); + let env = fresh_env(); + let code = run_command("cd sub && pwd", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "/sub\n"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cd_into_nonexistent_fails() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("cd nowhere", &work, env.clone()).unwrap(); + assert_eq!(code, 1); + assert!(stderr(&env).contains("Not a directory")); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cd_escape_is_rejected() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("cd ..", &work, env.clone()).unwrap(); + assert_eq!(code, 1); + assert!(stderr(&env).contains("escapes")); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cat_pipe_echo() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("echo hello | cat", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "hello\n"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cat_with_redirect_in() { + let work = make_dir(); + std::fs::write(work.join("in.txt"), "from-file").unwrap(); + let env = fresh_env(); + let code = run_command("cat < in.txt", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), "from-file"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn redirect_append() { + let work = make_dir(); + let env = fresh_env(); + run_command("echo one > log.txt", &work, env.clone()).unwrap(); + run_command("echo two >> log.txt", &work, env.clone()).unwrap(); + let on_disk = std::fs::read_to_string(work.join("log.txt")).unwrap(); + assert_eq!(on_disk, "one\ntwo\n"); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn mkdir_and_ls() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("mkdir foo && ls", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert!(stdout(&env).contains("foo")); + assert!(work.join("foo").is_dir()); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn mkdir_p_idempotent() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("mkdir -p a/b/c", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert!(work.join("a/b/c").is_dir()); + // Second invocation succeeds with -p + let env2 = fresh_env(); + let code2 = run_command("mkdir -p a/b/c", &work, env2).unwrap(); + assert_eq!(code2, 0); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn rm_file() { + let work = make_dir(); + std::fs::write(work.join("x.txt"), "x").unwrap(); + let env = fresh_env(); + let code = run_command("rm x.txt", &work, env).unwrap(); + assert_eq!(code, 0); + assert!(!work.join("x.txt").exists()); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn rm_dir_requires_recursive() { + let work = make_dir(); + std::fs::create_dir(work.join("d")).unwrap(); + let env = fresh_env(); + let code = run_command("rm d", &work, env.clone()).unwrap(); + assert_eq!(code, 1); + assert!(stderr(&env).contains("is a directory")); + + let env2 = fresh_env(); + let code2 = run_command("rm -r d", &work, env2).unwrap(); + assert_eq!(code2, 0); + assert!(!work.join("d").exists()); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn cp_and_mv() { + let work = make_dir(); + std::fs::write(work.join("a.txt"), "hello").unwrap(); + let env = fresh_env(); + let code = run_command("cp a.txt b.txt && mv b.txt c.txt", &work, env).unwrap(); + assert_eq!(code, 0); + assert_eq!( + std::fs::read_to_string(work.join("a.txt")).unwrap(), + "hello" + ); + assert!(!work.join("b.txt").exists()); + assert_eq!( + std::fs::read_to_string(work.join("c.txt")).unwrap(), + "hello" + ); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn export_and_env() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("export FOO=bar && env", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert!(stdout(&env).contains("FOO=bar")); + // Also verify it's actually in the WasiEnv (visible to subsequent execs) + let pairs = env.lock().unwrap().env_vars().to_vec(); + assert!(pairs.iter().any(|(k, v)| k == "FOO" && v == "bar")); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn export_rejects_bare_name() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("export NOEQUALS", &work, env.clone()).unwrap(); + assert_eq!(code, 1); + assert!(stderr(&env).contains("not a valid KEY=value")); + let _ = std::fs::remove_dir_all(&work); + } + + // ── Chaining & short-circuit ───────────────────────────────── + + #[test] + fn and_short_circuits_on_failure() { + let work = make_dir(); + let env = fresh_env(); + // First command fails (no such file), second must not run. + let code = + run_command("cat missing.txt && echo should-not-run", &work, env.clone()).unwrap(); + assert_eq!(code, 1); + assert!(!stdout(&env).contains("should-not-run")); + assert!(stderr(&env).contains("missing.txt")); + let _ = std::fs::remove_dir_all(&work); + } + + #[test] + fn semi_runs_both_regardless() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("cat missing.txt ; echo after", &work, env.clone()).unwrap(); + // Final command's exit code wins + assert_eq!(code, 0); + assert!(stdout(&env).contains("after")); + let _ = std::fs::remove_dir_all(&work); + } + + // ── Unknown command ────────────────────────────────────────── + + #[test] + fn unknown_command_returns_127() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("does-not-exist", &work, env.clone()).unwrap(); + assert_eq!(code, 127); + assert!(stderr(&env).contains("command not found")); + let _ = std::fs::remove_dir_all(&work); + } + + // ── Path resolution ────────────────────────────────────────── + + #[test] + fn resolve_guest_normalizes() { + let work = make_dir(); + let s = Shell::new(work.clone(), fresh_env()); + assert_eq!(s.resolve_guest("/").unwrap(), "/"); + assert_eq!(s.resolve_guest("/a/b").unwrap(), "/a/b"); + assert_eq!(s.resolve_guest("/a/./b").unwrap(), "/a/b"); + assert_eq!(s.resolve_guest("/a/b/../c").unwrap(), "/a/c"); + assert!(s.resolve_guest("/..").is_err()); + let _ = std::fs::remove_dir_all(&work); + } + + // ── Empty input ────────────────────────────────────────────── + + #[test] + fn empty_input_is_no_op() { + let work = make_dir(); + let env = fresh_env(); + let code = run_command("", &work, env.clone()).unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout(&env), ""); + let _ = std::fs::remove_dir_all(&work); + } +} diff --git a/src/agent/tools.rs b/src/agent/tools.rs index a221568..8b85404 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -45,7 +45,7 @@ pub fn openai_tools() -> Vec { r#type: "function", function: OpenAiFunction { name: "execute_code", - description: "Execute JavaScript source code or a pre-compiled WASM file inside a sandbox session. Provide one of: 'source'+'language' (single snippet), 'files'+'entry'+'language' (multi-file project with relative require() support), or 'wasm_path' (pre-compiled WASM). Returns stdout, stderr, exit code, and duration.", + description: "Execute code inside a sandbox session. Provide one of: 'command' (shell-style command line with pipes, redirection, and built-ins like echo/cat/ls/pwd/cd/mkdir/rm/cp/mv/env/export), 'source'+'language' (single snippet), 'files'+'entry'+'language' (multi-file project with relative require() support), or 'wasm_path' (pre-compiled WASM). Returns stdout, stderr, exit code, and duration.", parameters: json!({ "type": "object", "properties": { @@ -57,6 +57,10 @@ pub fn openai_tools() -> Vec { "type": "string", "description": "Source code to execute (use with 'language'). Alternative to wasm_path or files." }, + "command": { + "type": "string", + "description": "Shell command line to run via the built-in shell emulator (e.g. \"echo hello > out.txt && cat out.txt\"). Supports pipes, redirection, and &&/;. No language runtime needed." + }, "files": { "type": "object", "additionalProperties": { "type": "string" },