Skip to main content
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.

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.
# 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
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).
(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.
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")

Three Scopes for State

NeedAPIBash equivalent
One isolated commandexecute(cmd, cwd=..., env=...)(cd /data && cmd)
Many isolated commands sharing scoped statesession_id=... (Py) / sessionId (TS)a separate terminal
Persistent shell mutationsrun without optionscd /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.
  • 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).
Each returns exit_code 2 with stderr mirage: unsupported builtin: <name> or mirage: unsupported: process substitution >(...).

Syntax errors

Commands the parser cannot make sense of return exit_code 2 with stderr mirage: syntax error near '<token>'. 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 cache mount (/_default, where stateless text-processing commands like wc live).
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"
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.
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),
)