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

## JSON with `jq`

`jq` reads a **stream of JSON values** and runs the program once per
value, matching the real `jq` binary. The filename is irrelevant: a
`.json` file holding several concatenated or pretty-printed values is a
stream just like a `.jsonl` file, and so is multi-document input arriving
on stdin.

```bash
# Two documents in one file -> the program runs twice, one line each
cat /data/events.json
{"id": 1}
{"id": 2}
jq -c '.id' /data/events.json
1
2

# -s slurps the whole stream into a single array first
jq -c -s 'map(.id)' /data/events.json
[1,2]
```

Because evaluation is per document, commands that emit newline-delimited
JSON (such as [paginated `gws` list calls](/python/resource/gdrive#pagination))
pipe straight into `jq` with no reshaping.

Output arity follows the program, not the input. A jq program emits a
stream of values and each one prints on its own line, so `.a[]`, `.a, .b`
and `range(3)` all print several lines, while a program that collects
into an array (`[.a[] | .t]`) emits one value and prints one line.

```bash
jq -c '.name, .age' /data/user.json
"alice"
30

jq -c '[.name, .age]' /data/user.json
["alice",30]
```

### Flags

| Reading input | |
| --- | --- |
| `-n`, `--null-input` | run once against `null`; `inputs` still reads the operands |
| `-R`, `--raw-input` | each line is a string, not a JSON document |
| `-s`, `--slurp` | one value for the whole stream, spanning every operand |
| `--stream` | read each document as its `[path, leaf]` events |
| `--seq` | read and write RFC 7464 sequences (RS before each value) |
| `-f`, `--from-file` | read the program from a file |
| `--arg name value` | bind `$name` to a string |
| `--argjson name value` | bind `$name` to a JSON value |
| `--rawfile name file` | bind `$name` to a file's text |
| `--slurpfile name file` | bind `$name` to a file's documents, as an array |
| `--args`, `--jsonargs` | read the remaining operands into `$ARGS.positional` |

| Writing output | |
| --- | --- |
| `-r`, `--raw-output` | print string outputs unquoted |
| `-j`, `--join-output` | `-r` with no separator |
| `--raw-output0` | `-r` with a NUL after each output |
| `-c`, `--compact-output` | one line per output |
| `-a`, `--ascii-output` | escape non-ASCII (and keep strings quoted, as jq does) |
| `-S`, `--sort-keys` | sort object keys |
| `--tab`, `--indent n` | indent width (`--indent -1` is a tab) |
| `-e`, `--exit-status` | exit 1 when the last output is `false`/`null`, 4 when there was none |
| `-M`, `--monochrome-output`, `--unbuffered` | accepted; already how mirage writes |

Build JSON with a binding rather than by hand: the value arrives as a
value, so quotes and newlines in it need no escaping.

```bash
# text -> JSON array, no string surgery
printf 'alpha\nbeta\n' > /data/lines.txt
jq -Rn -c '[inputs]' /data/lines.txt
["alpha","beta"]

# a shell value that contains quotes
jq -n -c --arg v 'a"b' '{msg: $v}'
{"msg":"a\"b"}

# a whole file as one JSON string
jq -n -c --rawfile body /data/lines.txt '{text: $body}'
{"text":"alpha\nbeta\n"}
```

`$ARGS` is always defined, carrying `named` (the `--arg` family) and
`positional` (`--args` / `--jsonargs`).

Three limits worth knowing. `inputs` is bound to whatever is still
unread, so a program that drains it (`[., inputs]`, `reduce inputs as
$x`) runs once and sees everything, but the stateful single `input` and a
partial drain (`first(inputs)`) are not modeled. `--stream` reads whole
documents and expands them, which matches jq except that jq's incremental
parser splits the closing event of an input with no trailing newline into
its own `-s` group. And `--seq` reads and writes the separator, but drops
text before the first one silently where jq names it on stderr.

Not implemented, and reported as an unknown option rather than quietly
ignored: `-C` (colorized output, which an agent would only have to strip
again), `-L` (no module system, so `include` has nothing to search),
`--stream-errors` (it reports truncated-parse errors, which whole-value
reads never produce), and `--build-configuration`.

## 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`, `man`, `command`, `type`, `which`.
- **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.
- **Name lookup:** `type name` reports what a name resolves to (`type -t` prints one of `keyword`, `function`, `cli`, `builtin`; `type -a` lists every layer holding the name), `which name` prints the name of anything runnable (there is no PATH, so there is no path to print) and reports a miss through exit `1` alone, and `man name` renders a page: a command's spec, or an installed CLI's own `--help` tree (`man linear issue create`).
- **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>
