bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
228 lines
12 KiB
Plaintext
228 lines
12 KiB
Plaintext
---
|
||
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 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).
|
||
|
||
<Tabs>
|
||
<Tab title="Python" icon="/images/python-logo.svg">
|
||
```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"
|
||
```
|
||
</Tab>
|
||
<Tab title="CLI" icon="terminal">
|
||
```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'
|
||
```
|
||
</Tab>
|
||
</Tabs>
|
||
|
||
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.
|
||
|
||
<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>
|