> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Bash

> Run bash commands with `execute()`, per-call `cwd`/`env` overrides, and mid-flight cancellation.

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="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/python-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=cf5b268d330f7db46ea5cf6083f309a1" width="110" height="110" data-path="images/python-logo.svg">
    ```python theme={null}
    # Persistent mutation (no options): like `cd /data; cmd`
    await ws.shell("cd /data")
    await ws.shell("ls")  # sees /data

    # One-shot subshell (with cwd): like `(cd /data && cmd)`
    await ws.shell("ls", cwd="/data")
    # ws.cwd is unchanged; mutations inside don't leak
    ```
  </Tab>

  <Tab title="TypeScript​" icon="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/typescript-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=63a620cb702b16103c53ea361a17504a" width="512" height="512" data-path="images/typescript-logo.svg">
    ```typescript theme={null}
    // Persistent mutation
    await ws.shell("cd /data")
    await ws.shell("ls")  // sees /data

    // One-shot subshell
    await ws.shell("ls", { cwd: "/data" })
    // ws.cwd is unchanged; mutations inside don't leak
    ```
  </Tab>

  <Tab title="CLI​" icon="terminal">
    ```bash theme={null}
    # Use a real bash subshell inside the command string
    mirage shell -w demo -c "(cd /data && ls)"
    mirage shell -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 theme={null}
(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. TypeScript observes the signal at recursion boundaries (LIST, PIPELINE, FOR/WHILE/UNTIL iterations, COMMAND, subshells, command substitution), inside `sleep`, in the readers, and at the op door. Python runs the whole line as one task, which the event cancels at whatever await it is in, and joins before raising. On cancel, the call raises an abort error. A handler you write has to cooperate: let `CancelledError` propagate, and observe the signal in any loop of your own.

<Tabs>
  <Tab title="Python​​" icon="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/python-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=cf5b268d330f7db46ea5cf6083f309a1" width="110" height="110" data-path="images/python-logo.svg">
    ```python theme={null}
    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.shell("sleep 5", cancel=cancel)
    except MirageAbortError:
        print("aborted")
    ```
  </Tab>

  <Tab title="TypeScript​​" icon="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/typescript-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=63a620cb702b16103c53ea361a17504a" width="512" height="512" data-path="images/typescript-logo.svg">
    ```typescript theme={null}
    try {
      await ws.shell("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 theme={null}
    # Background the job, then cancel it
    JOB_ID=$(mirage shell -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 shell -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` |

A session runs one line at a time, the way one bash process does. Two `execute()` calls on the same session share its env, cwd and `$?`, so the second waits for the first to finish rather than interleaving with it; a `for f` loop never reads a value another loop on that session just set. A call with `cwd` / `env` overrides still runs on a clone, so nothing it does leaks back, but it takes its turn on the session like any other line. For independent streams of commands that should run at the same time, give each its own session.

## 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 theme={null}
# 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/vfs/gdrive#pagination))
pipe straight into `jq` with no reshaping.

The program runs on each line of a `.jsonl` file unchanged, as jq runs it:
`jq -c '.[]'` prints each record's values, not each record, and `-s` is the
way to read the lines as one array (`jq -s 'map(.id)'`). Every operand joins
one stream that one parser reads, as in jq, so a value may run on from one
file into the next: files holding `1` and `2` read as `12`, and under `-R` a
file with no final newline joins its last line to the next file's first.

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 theme={null}
jq -c '.name, .age' /data/user.json
"alice"
30

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

Numbers and keys print the way jq prints them. libjq is handed each
document as the text it was read from, so jq's own parser reads it, and each
output comes back as jq's own dump of it. A number keeps its literal, and an
object keeps its key order, on both hosts. A number the program computes
takes jq's spelling.

```bash theme={null}
printf '1.000 1e2 -0 100000000000000000001 {"b":1,"1":2}' | jq -c .
1.000
1E+2
-0
100000000000000000001
{"b":1,"1":2}

printf '1e17 0.00001' | jq -c '. + 0'
1e+17
1e-05
```

### Flags

| Reading input | |
| - | - |
| `-n`, `--null-input` | run once against `null`; `input` and `inputs` still read 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 operands typed after them into `$ARGS.positional`, as strings after `--args` and as JSON values after `--jsonargs`, whichever came last; an operand typed before either is an input file, as in jq |

| 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; a string holding a NUL fails its run, as in jq |
| `-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 with a tab, or with n spaces up to 7 (`--indent -1` is a tab; `--indent 0` breaks the lines without indenting, as jq 1.8.2 does); the last of `-c`, `--tab` and `--indent` wins, as in jq |
| `-e`, `--exit-status` | exit 1 when the last output is `false`/`null`, 4 when there was none (see [Errors](#errors)) |
| `-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 theme={null}
# 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`). jq's option loop reads each operand
the moment it reaches it, and mirage reads the line the same way: the last of
`--args` and `--jsonargs` typed before an operand decides how it is read, and
an operand typed before either one is still an input file.

```bash theme={null}
jq -n -c '$ARGS.positional' --args a --jsonargs 1 --args b
["a",1,"b"]

printf '{"k":1}' > /data/f.json
jq -c '[., $ARGS.positional]' /data/f.json --args a
[{"k":1},["a"]]
```

jq reads a word as options only when a letter or a second dash follows its
dash, and so does mirage. Any other dash word (`-1`, `-.5`, `- x`, a lone
`-`) is an operand, so a program, an input file or a positional value may
start with a dash, while `-nan` is still `-n -a -n`.

```bash theme={null}
jq -n -c '$ARGS.positional' --jsonargs -1 -.5 --args -1a
[-1,-0.5,"-1a"]

echo '{"a":3}' | jq '-.a'
-3
```

### Errors

An error that no `try` catches ends that document's run, the way jq's own
main loop handles it: what the run printed before the error stays printed,
the report goes to stderr, and the next document runs.

```bash theme={null}
printf '1 "a" 3' | jq '. + 1'
2
4
# stderr: jq: error (at <stdin>:0): string ("a") and number (1) cannot be added
```

The report names the input as the command line spelled it (`<stdin>` for a
pipe) and the lines jq's reader had read by then. It reads a line at a
time, and a long line in 4,091-byte pieces. Under `-n`, a program that
reads no input reports `<unknown>`, and a message that is not a string is
dumped after `(not a string)`. The exit status is the last document's: 5
when its run failed, otherwise 0, or 1 and 4 under `-e` as above. `halt`
ends the whole command with status 0. `halt_error` writes its input to
stderr (a string as it is, anything else as JSON on a line of its own) and
exits with its code, 5 by default. A program that does not compile is
refused with status 3 before any input is opened. Both hosts evaluate with
libjq 1.8.2, so the messages are jq 1.8.2's own.

Input that is not JSON ends the stream the way jq's main loop ends it: every
document before the bad one runs, and then `jq: parse error: <message>` goes
to stderr with status 5.

```bash theme={null}
printf '1 [' | jq .
1
# stderr: jq: parse error: Unfinished JSON term at EOF at line 1, column 3
```

mirage reads its input with a port of jq 1.8.2's own parser, so it takes what
jq takes (`nan`, `Infinity`, a leading `+`, `.5`) and refuses where jq
refuses, with jq's message, line and column. The line and column count bytes,
across every operand. Under `-s` nothing prints, `--stream` hands over the
events before the error, and `--seq` reports `jq: ignoring parse error: <message>` and reads on. `input` and `inputs` raise the parse error as a
runtime error, which `try` can catch, and the next run starts past it, with
or without `--seq`. A `--slurpfile` holding bad JSON is refused with
`jq: Bad JSON in --slurpfile <name> <file>: <message>` and status 2, and
`--argjson` and `--jsonargs` accept what jq's parser accepts. A `--jsonargs`
operand is parsed where it was typed, so `jq -n . --jsonargs '{' --indent 9`
reports the bad JSON, not the bad width.

Options are read the way jq's option loop reads them: one word at a time,
stopping at the first one it cannot take, in jq's words and with status 2.
A long option is a whole word, with no abbreviation and no `=value`, so
`--nul` and `--indent=3` are refused as `jq: Unknown option --nul` and
`jq: Unknown option --indent=3`. An option the line ends too early for says
what it takes (`jq: --arg takes two parameters (e.g. --arg varname value)`),
and each refusal ends with jq's two-line `Use jq --help` hint. Whatever comes
first on the line is what answers: a bad `--jsonargs` value typed before an
unknown option is the one reported, `-h`/`--help` and `-V`/`--version`
answer where they are typed, and the `-f` file is read after every option,
so `jq -n -f missing.jq --bogus` reports `--bogus`. jq opens a flag's file by
name: `/dev/stdin` reads stdin, and `-` is a file named `-`.

```bash theme={null}
jq -n . --jsonargs '{' --bogus
# stderr: jq: invalid JSON text passed to --jsonargs
# stderr: Use jq --help for help with command-line options,
# stderr: or see the jq manpage, or online docs at https://jqlang.org
```

A file that cannot be opened is reported when the reader reaches it, and
the reader goes on to the next one. jq's main loop checks for such a failure
before each document it reads, so the document the reader went on to still
runs, nothing after it does, and the status is 2 whatever the runs answered.

```bash theme={null}
printf '1 2' > /data/a.json
jq . /data/missing.json /data/a.json
1
# stderr: jq: error: Could not open file /data/missing.json: No such file or directory
```

A directory opens and then fails at its read, so jq reports it as
`jq: error: Is a directory`, without its name. `-n` opens nothing its
program does not read. A `--rawfile` or `--slurpfile` that cannot be read
is refused with `jq: Bad JSON in --rawfile <name> <file>: Could not open <file>: <reason>` (`It's a directory` for a directory) and status 2.

A few limits worth knowing. `input` and `inputs` read the documents
still unread: `input` takes the next one (and fails with `break` when none
is left, as jq does), `inputs` yields every one after it. mirage cannot see
how many a run took, so it assumes the idioms: a program that calls
`inputs` drains the stream in one run (`[., inputs]`, `reduce inputs as
$x`, `input as $header | inputs`), and one that calls only `input` takes one
document per run (`[., input]` pairs them up). A partial drain
(`first(inputs)`), a second `input` in one run, or an `input` in a branch
not taken leaves jq a different remainder than mirage. Error positions
follow the same model, so a run that takes some other count than the idioms
above reports a different position than jq, and a `try input` that catches a
parse error cannot read on past it. A halt ends the command wherever it is
called, a `try` of the program's own included. Its message and code are read
by running the program again with `halt` and `halt_error` redefined, which
reads the clock again and which the program's own error handling can defeat:
a halt whose message or choice rests on `now` reports what the rerun read, a
halt inside a `try` inside a collector (`[try halt_error(2) catch .]`)
reports status 5 with no message, and a halt that a `try ... catch empty`
swallows can report the message and code of a later halt instead.
Under `--stream`, input nested more than 10,000 levels deep is refused
with `Exceeds depth limit for parsing`, the limit jq's ordinary parser
has. jq's streaming parser has no limit, but every event carries a copy
of its path, so input nested n deep costs time in n squared.

Not implemented, and reported as an unknown option (`jq: Unknown option -C`)
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` (which hands a parse error over as an array
value instead of reporting it), and `--build-configuration`. `-V` prints
mirage's version line, as `--version` does.

`find -exec` runs each invocation in an isolated child shell, and it looks
the head word up the way findutils' `execvp` does: only an installed program
runs there (`echo`, `printf`, `true`, a registered CLI), so a shell function
or a shell-only builtin such as `cd` or `export` is refused with
`find: 'name': No such file or directory`, and a function or alias that
shadows a program name is bypassed, the program running as it would under
`command`, and a builtin that doubles as a program answers as the program
(`printf -v` is a format string there, as under coreutils printf). Changes
to variables, working directory, arguments, and shell options stay in the
child. `-ls` renders the stat find already holds, as GNU's does: a start
point, or a row a `-size`, `-mtime`, `-newer` or `-empty` test statted,
still lists after `-delete`, while a row only `-name` or `-type` selected is
reported gone.
Predicates must precede actions: `find d -name '*.txt' -exec echo {} \;`
works, while a test following `-exec`, `-print`, `-delete`, or another action
is refused because backend filtering cannot preserve that evaluation order.
Under `-o` or parentheses an action ends its `-a` chain and the whole
expression holds one action, so `find . -path ./skip -prune -o -type f -print`
and `-name a -print -o -name b -print` run as GNU runs them, while `-print`
on one arm and `-print0` or a different `-exec` on the other, an action that
is not last in its chain, or an action under `!` is refused. `-exec ... \;`
is true only when its command exits 0, which is learned after the walk, so
under `-o` it may stand only where nothing follows it (`-type d -exec false
\; -o -prune` is refused), while `-exec ... {} +` is true regardless, as in
GNU, and sits anywhere an action may. `-prune` skips a
directory's contents, a mount nested under that directory included (and a
file it reaches skips nothing); it does nothing under `-depth`, and GNU's
refusal of `-prune` with `-delete` is kept unless `-depth` is spelled out. `-path` matches the row as it prints, under the
operand as typed. Action operands retain whole filenames, including embedded
newlines, across mount boundaries. `-printf` must be the only action;
combining it with other actions or repeating it is refused.
`-delete` runs where it is written, so a later `-exec` sees the row gone, and
it turns on `-depth`, which lists a directory after its contents. `-newer FILE`
reads a symlink reference itself under the default `-P` and its target under
`-H` or `-L`, as GNU find does.
`-newermt` accepts GNU date expressions such as `yesterday`, `24 hours ago`,
and `@1700000000`, with unzoned dates interpreted as UTC. Invalid calendar
dates are refused. `-newer` and `-newermt` must be in a top-level AND chain;
placing them under OR, negation, or parentheses is refused. Repeated time
tests in that chain intersect, so `-newer old -newer new` keeps only what is
newer than both; `-mtime` tests under `-o` widen to the union of their
windows, which can match more than GNU, while a `-prune` on the other arm
still fires exactly where GNU's does. A time test keeps its place before
or after `-prune`: `find d -newermt X -prune` skips the contents of the
directories newer than X alone, and `-prune -newermt X` skips every
directory's contents and prints the newer ones, as GNU orders them. A
repeated `-print` prints each row once per occurrence, as in GNU.

`grep`, `zgrep`, `sed` and `expr` follow the locale the command's environment
names, as GNU does: the first of `LC_ALL`, `LC_CTYPE` and `LANG` that is set
and not empty. In the C locale, the default, they match, count and index
bytes. When that variable names a UTF-8 codeset (`C.UTF-8`, `en_US.utf8`),
they work on whole characters: `.` and a bracket expression match one
character and never a byte that is not part of one, `expr length` and
`substr` count characters, and grep leaves out a line or match holding such a
byte and reports `binary file matches`, unless `-a` is given. Every UTF-8 name
counts as installed, where glibc falls back to the C locale for a missing
one. Character classes, `\w`, word boundaries, `-i` and sed's `\U` and `\L`
keep their ASCII meaning under a UTF-8 locale, and diagnostics keep the C
locale's quoting. `awk` and `tr` work on bytes under any locale, as mawk and
GNU `tr` do.

## 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`, `>&2`, `&>`, `&>>`, `>&-` (a closed stdout fails the write, as GNU echo reports), heredoc `<<`, herestring `<<<`. The shell models descriptors 0, 1 and 2 only: a redirect naming any other (`3>f`, `>&3`, `exec 3>&-`) is refused with `3: Bad file descriptor` and exit `1`, the line continuing as it would after any redirect error. Duplications involving fd 0 follow the same left-to-right rules. Writing to a read-only descriptor fails with `write error: Bad file descriptor`, and reading a closed or write-only one fails with `Bad file descriptor` once the command reads (`cat 0<&1` exits `1`; `true 0<&1` is untouched). A standard descriptor may be opened in the other direction, as bash allows: `exec 0>file` makes fd 0 the file's write end (a read is refused, and `>&0` writes there), and `exec 1<file` makes fd 1 its read end (`exec 0<&1` and `<&1` read the file, a write to it fails). A read end keeps its offset and a dup shares it, as bash's do: after `exec 1<file`, `read a <&1; read b <&1` reads two lines and `exec 0<&1` reads on from there, while a new open starts at the file's beginning. Mirage keeps no offset on a write-open descriptor, so every write through one appends.
* **Substitutions:** command substitution `` `cmd` `` and `$(cmd)`; arithmetic `$((expr))` (an invalid expression such as `$((1/0))` aborts the line with exit `1` and `bash: 1/0: division by 0`, as a non-interactive bash does); parameter expansion `${VAR}`, `${VAR:-default}`, `${VAR%suffix}`, etc.; input-direction process substitution `<(cmd)`.
* **Shell variables:** `$?`, `$#`, `$@`, `$*`, `$0`..`$9`, `$$`, `$!`, `$RANDOM` (bash 5.2's generator, so `RANDOM=42` draws bash's own sequence; bare `RANDOM` in arithmetic draws lazily too; assigning seeds it immediately; invalid arithmetic seeds emit a diagnostic without changing the sequence or failing the assignment; `unset RANDOM` strips it, as in bash) `${PIPESTATUS[@]}` (the per-segment statuses of the last pipeline) and `${FUNCNAME[@]}` (the running functions, innermost first, a sourced file as `source`; empty outside every function, and an assignment to it is ignored).
* **Control flow:** `if`/`elif`/`else`/`fi`, `for`, `while`, `until`, `case`, `select`, `function name() {}`, `break`, `continue`, `return`.
* **Grouping:** subshells `(cmd)`, compound `{ cmd; }`, negation `! cmd`.
* **Standard input:** the commands of a group, loop, list, function, subshell, `eval`, `source` or nested shell read one standard input in turn, as bash's share one descriptor: `read` takes its line (or its `-n`/`-N`/`-d` amount) and the next command reads on from there, so `printf 'a\nb\n' | { read x; cat; }` prints `b`. `exec < file` rebinds that input for every statement after it, inside the group or subshell that runs it too (`printf 'z\n' | { exec < f; read a; }` reads `f`), and the next line reads on from where the last one stopped; opening the file again starts at its beginning. A command substitution and a background job read no standard input, where bash's read the shell's.
* **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `declare` (including `-A`), `let`, `set`, `shopt`, `alias`, `umask`, `mapfile`, `shift`, `exec` (redirect-only form), `disown`, `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`, every GNU `xargs` option (batching by words, lines (`-L`/`-l`) or bytes (`-s`, 128 KiB by default, with GNU's `-x` and too-long errors), `-I`/`-i` runs once per input line with the line in place of the string, input quotes and backslashes processed as GNU does, `-E`/`-e` end-of-file words, `-0`/`-d` with GNU's escapes, `-a FILE`, `-t` traces, `--show-limits` and `-s` bounded by the environment's size against a 2 MiB `ARG_MAX`, `-P N` runs up to N commands at once, each in its own copy of the session, with output kept in input order and `--process-slot-var` numbering them; there is no terminal, so `-p` and `-o` fail as GNU does without one; long options may be abbreviated as GNU's getopt\_long allows; GNU exit codes: `123` when an invocation fails, `124` after one exits `255`, `127` for a command nothing provides (`xargs: cd: No such file or directory`); a function runs too), every GNU `timeout` option (`timeout DURATION` takes a C float with an `s`/`m`/`h`/`d` suffix; the command reads timeout's stdin; at the deadline `-s`'s signal, TERM by default, does what its default action does to a process: most end the command (exit `124`, or `128+N` with `-p`), `CONT`/`CHLD`/`URG`/`WINCH`/`0` let it finish, a stop halts it, and `-k`'s `KILL` ends what is left with `137`; `-f` and `-v` as in GNU; usage errors exit `125` with GNU's `Try` line). `shift` and `return` report bash's `numeric argument required` errors, and `shift` past `$#` is bash's silent exit `1`. `test`/`[`/`[[` support the file-pair operators `-nt`, `-ot` and `-ef` (`-ef` means the same resolved path: a mount has no device or inode).
* **Name lookup:** every program a session can run (a mount command, an installed CLI, a builtin a real system also ships as a file, such as `echo` or `xargs`) has an executable file under the read-only `/usr/bin`, and `$PATH` is `/usr/bin`. `which name` prints that file (`/usr/bin/ls`) and reports a miss (`cd`, a function, an unknown word) through exit `1` alone; `command -v` prints the file, or the bare name for a builtin or function; `type name` says `ls is /usr/bin/ls` or `cd is a shell builtin` (`type -t` prints one of `alias`, `keyword`, `function`, `builtin`, `file`; `type -a` lists every layer holding the name; `type -p`/`-P` print the file). Reading a file (`cat /usr/bin/ls`) shows a short script whose comment says what runs the command (`ls is built into mirage. Help: ls --help`, or the runtime a captured program runs on), so running `/usr/bin/ls` by path works too; a write into `/usr/bin` is refused as read-only. `man name` renders a page: a command's spec, or an installed CLI's own `--help` tree (`man linear issue create`).
* **Program runs:** `env`, `xargs` and `timeout` run their command the way `find -exec` does, as the program a real system would exec, so a builtin that doubles as a program answers as the program. `printf` there is coreutils': `-v` is a format string, `--` ends its options, and a format that takes no argument warns `printf: warning: ignoring excess arguments, starting with 'a'` (the builtin drops them silently, as bash's does).
* **Globs:** `*`, `?`, `[...]` classes and `[!...]` negation (Python `fnmatch` semantics in both implementations), resolved by the shell or pushed down to the VFS.
* **Comments:** `#`.

### Unsupported (returns clear error)

* **Job control:** `bg`. (`fg`, `jobs`, `wait`, `kill`, `disown`, `ps` work; use the `--background` flag and `mirage job` CLI for long-running work.)
* **Shell internals:** `exec CMD` (process replacement; the redirect-only `exec > file` works), `complete`, `compgen`, `ulimit`, file descriptors above 2.
* **Output process substitution:** `>(cmd)` (the `<(cmd)` direction works).
* **timeout without processes:** a command is a task, not a process, so a signal is modeled by its default action rather than delivered; a command cannot trap one. timeout's own process group is modeled too: without `-f`, `KILL`, `32` and `33` kill timeout itself (exit `128+N` even with `-p`), `STOP` stops it for good and `CHLD` leaves it waiting until `-k`'s `KILL` (or forever), as GNU does. What a nested `sh -c` printed before the deadline is lost, because a nested line hands back its output only when it finishes.

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 named prefix carries a mode 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 exceeding the session's mode fails exactly like it would on a read-only mount.

`exec` adds one thing: running code in a language runtime. The interpreter commands (`python3`, `python`, `js`, `node`) hand their program to a runtime that runs it outside the shell. A script file named as their operand (`python3 /data/job.py`) must sit on an `exec` mount; inline code (`-c`, `-e`, stdin) and `-m` need only an `exec` mount somewhere in the workspace, and the modules a program imports are not checked by path. Without one they exit 126 with `not in EXEC mode`. Shell scripts need no `exec`: `bash script.sh`, `source script.sh`, and `./script.sh` when it has no shebang or one naming `sh` or `bash`, run in the shell itself, which checks each of their commands like a typed line, so a script can do nothing its lines could not. A `./script` whose shebang names another interpreter is handed to that command, so one starting `#!/usr/bin/env python3` needs `exec` just as `python3` does. To keep a path's scripts from running at all, deny the path in the session's [profile](/home/permissions): that refuses every command on it, `cat script.sh | bash` included.

**Naming a mount narrows it; it is not an allowlist.** A mount a session does not name keeps its own configured mode, and 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, so `rw` on a `READ` mount is still read-only.

Keeping a session away from a mount entirely is a **hide**, written in the role the session runs under. A hidden path answers `No such file or directory` rather than a permission error, so a refusal never names something the agent was not meant to know is there. Creating under a hidden path is the one case that answers out loud (`Permission denied`), because silently succeeding would leave a file the session cannot see.

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 its role hides. A FUSE mountpoint is served under the default unrestricted view unless it was added for a session (`add_fuse_mount(prefix, session_id=...)` in Python, `addFuseMount(prefix, mountpoint, sessionId)` in TypeScript), in which case every op through it runs under that session's grants.

Both halves fire 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.vfs.read/write/...` API. 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) stay reachable unless a role hides them.

<Tabs>
  <Tab title="Python" icon="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/python-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=cf5b268d330f7db46ea5cf6083f309a1" width="110" height="110" data-path="images/python-logo.svg">
    ```python theme={null}
    ws = Workspace({
        "/s3": s3,
        "/slack": slack,
        "/linear": linear,
    })

    # A role is the whole permission document a session runs under.
    ws.create_session("slack-agent",
                      profile={"paths": {"hide": ["/linear", "/s3"]}})
    ws.create_session("data-agent", mounts={"/s3": "rw", "/github": "r"})

    await ws.shell("ls /slack", session_id="slack-agent")  # ok
    await ws.shell("cat /linear/issues/SEC-42",
                     session_id="slack-agent")
    # exit_code=1, stderr=b"cat: /linear/issues/SEC-42: "
    #                     b"No such file or directory\n"
    ```
  </Tab>

  <Tab title="CLI" icon="terminal">
    ```bash theme={null}
    # Repeat --mount (or -m) to narrow a mount's mode; cap it with
    # :read/:write/:exec or the aliases :r/:rw/:rwx
    mirage session create demo --id data-agent -m /s3:rw -m /github:r

    # Confinement is a role, named with --profile (roles live in the
    # workspace's `profiles:` block)
    mirage session create demo --id slack-agent --profile slack-only

    mirage shell -w demo -s slack-agent -c "cat /linear/issues/SEC-42"
    # mirage: cat: /linear/issues/SEC-42: No such file or directory
    ```
  </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="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/python-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=cf5b268d330f7db46ea5cf6083f309a1" width="110" height="110" data-path="images/python-logo.svg">
    ```python theme={null}
    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.shell(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="https://mintcdn.com/struktoai/F9Lv_nTaj6By6crk/images/typescript-logo.svg?fit=max&auto=format&n=F9Lv_nTaj6By6crk&q=85&s=63a620cb702b16103c53ea361a17504a" width="512" height="512" data-path="images/typescript-logo.svg">
    ```typescript theme={null}
    async function toolCall(
      cmd: string,
      cwd: string,
      env: Record<string, string>,
      timeoutMs: number,
    ) {
      return ws.shell(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 theme={null}
    # No --cwd/--env flags: isolate each parallel call in a subshell
    mirage shell -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>

## Timing pipelines

`time pipeline` measures the entire pipeline, including output consumption,
and writes the report to stderr. It preserves the pipeline's exit status and
session changes. `time -p` uses the POSIX `real`, `user`, and `sys` layout;
otherwise `TIMEFORMAT` controls the report (`%R`, `%U`, `%S`, `%P`, precision,
and the `l` minute format). An empty `TIMEFORMAT` suppresses it.

Elapsed time uses a monotonic clock. User and system CPU times are zero in both
hosts: browser runtimes have no per-job CPU counters, and process-wide counters
would include unrelated sessions.

## HTTP command compatibility

`curl -4` / `--ipv4` and `-6` / `--ipv6` are accepted as compatibility flags;
the host HTTP transport chooses the address family. `-w` / `--write-out` renders
`http_code`, `response_code`, `url_effective`, `num_redirects`, `size_download`,
`content_type`, `method`, `exitcode`, and `time_total` after the transfer, including
failed transfers. Templates accept `@file`, `@-`, escaped newlines, `%%`, and
`%{stdout}`, `%{stderr}`, and `%{onerror}`. Unsupported variables produce a
diagnostic. Output selected by `-o` contains only the response body.

`wget -T` / `--timeout` sets the request timeout in seconds; zero disables it.
Timeouts return network-error status 4. The shared HTTP transport uses one
request deadline, rather than GNU Wget's separate DNS, connect, and read timers.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.