---
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.

<Tabs>
  <Tab title="Python​" icon="/images/python-logo.svg">
    ```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
    ```
  </Tab>
  <Tab title="TypeScript​" icon="/images/typescript-logo.svg">
    ```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
    ```
  </Tab>
  <Tab title="CLI​" icon="terminal">
    ```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.
  </Tab>
</Tabs>

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.

<Tabs>
  <Tab title="Python​​" icon="/images/python-logo.svg">
    ```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")
    ```
  </Tab>
  <Tab title="TypeScript​​" icon="/images/typescript-logo.svg">
    ```typescript
    try {
      await ws.execute("sleep 5", { signal: AbortSignal.timeout(100) })
    } catch (e) {
      if (e instanceof DOMException && e.name === "AbortError") {
        console.log("aborted")
      }
    }
    ```
  </Tab>
  <Tab title="CLI​​" icon="terminal">
    ```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 <cmd>"` exits `124` on overrun.
  </Tab>
</Tabs>

## 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: <name>` 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 '<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 modes

A session can be created with its own per-mount modes, like a container that mounts the same volume `ro` while another mounts it `rw`. Each listed prefix carries a mode ceiling on the `read < write < exec` ladder, written as the words `read`/`write`/`exec` or the cumulative filesystem aliases `r`/`rw`/`rwx` (exec implies write implies read, so bit-style forms like a bare `w` are rejected). A command touching a mount the session was not given is rejected with `mirage: session 'agent' not allowed to access mount '/X'` and exit code 1; a command exceeding the session's mode fails exactly like it would on a read-only mount.

If a session is created without `mounts`, it is unrestricted: every mount behaves per its own configured mode. A list of prefixes (instead of a mapping) restricts the session to those mounts but keeps each at its own mode, and a bare `-m /data` on the CLI does the same for one mount. A session's mode can only narrow, never widen: the effective permission is the weaker of the mount's own mode and the session's mode, so `rw` on a `READ` mount is still read-only.

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. Note that FUSE mounts are not scoped by session modes: a FUSE mount is a host-level surface served under the default unrestricted view (mount modes still apply, session narrowing does not).

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. Infrastructure prefixes are always accessible: the history view (`/.bash_history`, which the `history` builtin and the GNU histfile render from) and the implicit scratch root (`/`, where stateless text-processing commands like `wc` resolve when given no path). A user-defined `/` mount is not infrastructure; sessions must be given `/` explicitly to touch it.

<Tabs>
  <Tab title="Python" icon="/images/python-logo.svg">
    ```python
    ws = Workspace({
        "/s3": s3,
        "/slack": slack,
        "/linear": linear,
    })

    ws.create_session("slack-agent", mounts=["/slack"])
    ws.create_session("data-agent", mounts={"/s3": "rw", "/github": "r"})

    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"
    ```
  </Tab>
  <Tab title="CLI" icon="terminal">
    ```bash
    # Repeat --mount (or -m) per allowed prefix; cap the mode with
    # :read/:write/:exec or the aliases :r/:rw/:rwx
    mirage session create demo --id slack-agent --mount /slack
    mirage session create demo --id data-agent  -m /s3:rw -m /github:r

    mirage execute -w demo -s slack-agent -c "cat /linear/issues/SEC-42"
    # mirage: session 'slack-agent' not allowed to access mount '/linear'
    ```
  </Tab>
</Tabs>

The modes are a property of the session, so they cover every command issued under that `session_id`, including subshells, pipelines, and recursive `bash -c '...'`. They do not change the mount's own `MountMode`: a write to a session-writable mount is still rejected if the mount itself 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.

<Tabs>
  <Tab title="Python​​​" icon="/images/python-logo.svg">
    ```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),
    )
    ```
  </Tab>
  <Tab title="TypeScript​​​" icon="/images/typescript-logo.svg">
    ```typescript
    async function toolCall(
      cmd: string,
      cwd: string,
      env: Record<string, string>,
      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),
    ])
    ```
  </Tab>
  <Tab title="CLI" icon="terminal">
    ```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.
  </Tab>
</Tabs>
