--- title: Bash description: Run bash commands with `execute()`, per-call `cwd`/`env` overrides, and mid-flight cancellation. icon: terminal --- Mirage Bash is how agents act on the workspace. `execute()` parses a bash-style command, looks up the target session, resolves mounts, runs the executor, applies I/O side effects, and records history through the [Observer](/home/observer). ## Per-call overrides: `cwd`, `env` Providing `cwd` or `env` runs the command in an ephemeral session clone, like a bash subshell `(cd /data && cmd)`. Mutations like `cd` or `export` inside the call do NOT persist back to the workspace's session. To change persistent state, run the command without these options. ```python # Persistent mutation (no options): like `cd /data; cmd` await ws.execute("cd /data") await ws.execute("ls") # sees /data # One-shot subshell (with cwd): like `(cd /data && cmd)` await ws.execute("ls", cwd="/data") # ws.cwd is unchanged; mutations inside don't leak ``` ```typescript // Persistent mutation await ws.execute("cd /data") await ws.execute("ls") // sees /data // One-shot subshell await ws.execute("ls", { cwd: "/data" }) // ws.cwd is unchanged; mutations inside don't leak ``` ```bash # Use a real bash subshell inside the command string mirage execute -w demo -c "(cd /data && ls)" mirage execute -w demo -c "(export FOO=bar; printenv FOO)" ``` The CLI doesn't have `--cwd` / `--env` flags, but bash subshell syntax `(cd ... && cmd)` and `(export FOO=bar; cmd)` give the same per-call isolation. Mutations inside the parens don't leak. This makes per-call overrides safe under concurrent calls on the same session. Two parallel `execute()` calls with different `cwd` see their own cwd without cross-contamination, even on the same session. ## Subshells `(...)` Wrapping commands in `( ... )` runs them in an isolated copy of the session: `cd`, `export`, and other mutations inside the parens do not leak back. It is the same isolation as the `cwd` / `env` overrides above, and the CLI's stand-in for them (there are no `--cwd` / `--env` flags). ```bash (cd /data && ls) # cwd change scoped to the subshell (export TOKEN=xyz; printenv) # env var gone once the parens close ``` The isolation holds under concurrency, and subshells are still covered by the per-session mount allowlist and the cancellation boundaries below. ## Mid-flight cancellation: `cancel` / `signal` Both bindings support cooperative cancellation observed at recursion boundaries (LIST, PIPELINE, FOR/WHILE/UNTIL iterations, COMMAND, subshells, command substitution) and inside `sleep`. On cancel, the call raises an abort error. ```python import asyncio from mirage.workspace.abort import MirageAbortError cancel = asyncio.Event() async def trigger(): await asyncio.sleep(0.1) cancel.set() asyncio.create_task(trigger()) try: await ws.execute("sleep 5", cancel=cancel) except MirageAbortError: print("aborted") ``` ```typescript try { await ws.execute("sleep 5", { signal: AbortSignal.timeout(100) }) } catch (e) { if (e instanceof DOMException && e.name === "AbortError") { console.log("aborted") } } ``` ```bash # Background the job, then cancel it JOB_ID=$(mirage execute -w demo -c "sleep 60" --bg) mirage job cancel "$JOB_ID" ``` Per-call timeout is not a CLI flag yet. Use `--bg` to get a job id and `mirage job cancel` to terminate, or wrap the command in the `timeout` builtin: `mirage execute -w demo -c "timeout 30 "` exits `124` on overrun. ## Three Scopes for State | Need | API | Bash equivalent | | --- | --- | --- | | One isolated command | `execute(cmd, cwd=..., env=...)` | `(cd /data && cmd)` | | Many isolated commands sharing scoped state | `session_id=...` (Py) / `sessionId` (TS) | a separate terminal | | Persistent shell mutations | run without options | `cd /data; cmd` | ## Supported bash syntax Mirage Bash is a tree-sitter-bash parser plus a custom executor. It implements the constructs LLMs reach for most often. What is not supported returns a clear, parseable error so an agent can self-correct on its next turn. ### Supported - **Operators:** pipes `|`, `|&`; lists `&&`, `||`, `;`; background `&`. - **Redirects:** `>`, `>>`, `<`, `2>`, `2>&1`, `&>`, `&>>`, heredoc `<<`, herestring `<<<`. - **Substitutions:** command substitution `` `cmd` `` and `$(cmd)`; arithmetic `$((expr))`; parameter expansion `${VAR}`, `${VAR:-default}`, `${VAR%suffix}`, etc.; input-direction process substitution `<(cmd)`. - **Control flow:** `if`/`elif`/`else`/`fi`, `for`, `while`, `until`, `case`, `select`, `function name() {}`, `break`, `continue`, `return`. - **Grouping:** subshells `(cmd)`, compound `{ cmd; }`, negation `! cmd`. - **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `set`, `shift`, `trap` (no-op), `test`, `[`, `[[`, `true`, `false`, `sleep`, `xargs`, `timeout`, `bash`, `sh`, `python`, `python3`. - **Builtin options (GNU semantics):** `echo -n/-e/-E` (leading-word option rule: `echo hi -n` prints `hi -n`), `read -r`, `xargs -n/-0/-d/-r/--` (batching, GNU exit codes: `123` when an invocation fails, `126`/`127` stop the run), `timeout DURATION` with `s`/`m`/`h`/`d` suffixes (kills at the deadline with exit `124`, usage errors exit `125`). `shift` and `return` report bash's `numeric argument required` errors. - **Globs:** `*`, `?`, `[...]` classes and `[!...]` negation (Python `fnmatch` semantics in both implementations), resolved by the shell or pushed down to the resource. - **Comments:** `#`. ### Unsupported (returns clear error) - **Job control:** `bg`, `disown`. (`fg`, `jobs`, `wait`, `kill`, `ps` work; use the `--background` flag and `mirage job` CLI for long-running work.) - **Shell internals:** `exec`, `complete`, `compgen`, `ulimit`. - **Output process substitution:** `>(cmd)` (the `<(cmd)` direction works). - **Builtin options with no process backing:** `xargs -I`/`-P` (exit `1`) and `timeout -s`/`-k`/`--preserve-status` (exit `125`) return an `unsupported option` error: commands run as coroutines inside the workspace, so there is no process to signal or parallelize. Each returns `exit_code 2` with stderr `mirage: unsupported builtin: ` or `mirage: unsupported: process substitution >(...)`, except the builtin options above, which use the listed GNU-shaped exit codes. ### Syntax errors Commands the parser cannot make sense of return `exit_code 2` with stderr `mirage: syntax error near ''`. Earlier versions silently ran whatever fragment did parse; that no longer happens. ### What `--background` is and isn't The daemon's `--background` flag detaches a job and returns a job id. It is not the same as the bash `&` operator, which the shell does support inline (`sleep 30 &`). Use `&` for in-shell job parallelism, `--background` (or `mirage job`) for long-lived work that should outlive the request. ## Per-session mount capability A session can be created with an explicit allowlist of mount prefixes. Any command whose path resolves to a mount outside that list is rejected with `mirage: session 'agent' not allowed to access mount '/X'` and exit code 1. Default sessions (no allowlist) keep their current unrestricted behavior, so existing code is unaffected. This is a soft boundary, enforced inside the daemon process, not an OS or process-level isolation. Use it to shrink the blast radius of prompt-injection in multi-agent workspaces: a Slack-only agent cannot pivot to read `/linear`, `/github`, or any other mount it was not given. The check fires for every code path that reaches a mount: shell commands (`cat`, `ls`, ...), redirects (`>`, `<`), cross-mount `cp`/`mv`, `wget -O`, `curl -o`, command substitution `$(...)`, subshells `(...)`, pipes, `&&`/`||` chains, background jobs, and the programmatic `ws.ops.read/write/...` API. Two infrastructure prefixes are always allowed regardless of the allowlist: the history view (`/.bash_history`, which the `history` builtin and the GNU histfile render from) and the virtual root (`/`, where stateless text-processing commands like `wc` resolve when given no path). ```python ws = Workspace({ "/s3": s3, "/slack": slack, "/linear": linear, }) ws.create_session("slack-agent", allowed_mounts={"/slack"}) ws.create_session("data-agent", allowed_mounts={"/s3"}) await ws.execute("ls /slack", session_id="slack-agent") # ok await ws.execute("cat /linear/issues/SEC-42", session_id="slack-agent") # exit_code=1, stderr=b"session 'slack-agent' not allowed to " # b"access mount '/linear'\n" ``` ```bash # Repeat --mount (or -m) per allowed prefix mirage session create demo --id slack-agent --mount /slack mirage session create demo --id data-agent -m /s3 -m /github mirage execute -w demo -s slack-agent -c "cat /linear/issues/SEC-42" # mirage: session 'slack-agent' not allowed to access mount '/linear' ``` The allowlist is a property of the session, so it covers every command issued under that `session_id`, including subshells, pipelines, and recursive `bash -c '...'`. It does not change `MountMode`: a write to a mount in the allowlist is still rejected if the mount is `READ`. The two checks compose. ## Agent Pattern Agent harnesses commonly fan out tool calls in parallel, each with its own `cwd`/`env`/`cancel`. The clone semantics make this race-free without per-call boilerplate. From the CLI, a subshell per call gives the same isolation. ```python async def tool_call(cmd: str, cwd: str, env: dict[str, str], timeout: float): cancel = asyncio.Event() asyncio.get_event_loop().call_later(timeout, cancel.set) return await ws.execute(cmd, cwd=cwd, env=env, cancel=cancel) results = await asyncio.gather( tool_call("ls", "/data", {"DEBUG": "1"}, 5.0), tool_call("grep foo *.log", "/logs", {"DEBUG": "1"}, 5.0), ) ``` ```typescript async function toolCall( cmd: string, cwd: string, env: Record, timeoutMs: number, ) { return ws.execute(cmd, { cwd, env, signal: AbortSignal.timeout(timeoutMs) }) } const results = await Promise.all([ toolCall("ls", "/data", { DEBUG: "1" }, 5000), toolCall("grep foo *.log", "/logs", { DEBUG: "1" }, 5000), ]) ``` ```bash # No --cwd/--env flags: isolate each parallel call in a subshell mirage execute -w demo -c "(cd /data && export DEBUG=1 && ls) & (cd /logs && export DEBUG=1 && grep foo *.log) & wait" ``` Each `( ... )` runs in its own scope, so the parallel `cd` / `export` don't collide. `&` backgrounds them inside Mirage Bash and `wait` joins.