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.
- Python
- TypeScript
- CLI
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).
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.
- Python
- TypeScript
- CLI
Three Scopes for State
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.
gws list calls)
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.
Flags
Build JSON with a binding rather than by hand: the value arrives as a
value, so quotes and newlines in it need no escaping.
$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.
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.
All actions (-exec, -print, -print0, -printf, -ls, and -delete)
refuse placement under -o, negation, or parentheses. Tests may be grouped
before a trailing action. 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. A repeated -print prints each row
once per occurrence, as in GNU.
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 with3: Bad file descriptorand exit1, 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 withwrite error: Bad file descriptor, and reading a closed or write-only one fails withBad file descriptoronce the command reads (cat 0<&1exits1;true 0<&1is untouched). A standard descriptor may be opened in the other direction, as bash allows:exec 0>filemakes fd 0 the file’s write end (a read is refused, and>&0writes there), andexec 1<filemakes fd 1 its read end (exec 0<&1and<&1read the file, a write to it fails). Mirage keeps no offset on a descriptor, so every read through a read-open stream starts at the file’s beginning and every write through a write-open one appends. - Substitutions: command substitution
`cmd`and$(cmd); arithmetic$((expr))(an invalid expression such as$((1/0))aborts the line with exit1andbash: 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, soRANDOM=42draws bash’s own sequence; bareRANDOMin arithmetic draws lazily too; assigning seeds it immediately; invalid arithmetic seeds emit a diagnostic without changing the sequence or failing the assignment;unset RANDOMstrips it, as in bash) and${PIPESTATUS[@]}(the per-segment statuses of the last pipeline). - 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,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 -nprintshi -n),read -r,xargs -n/-0/-d/-r/--(batching, GNU exit codes:123when an invocation fails,126/127stop the run),timeout DURATIONwiths/m/h/dsuffixes (kills at the deadline with exit124, usage errors exit125).shiftandreturnreport bash’snumeric argument requirederrors, andshiftpast$#is bash’s silent exit1.test/[/[[support the file-pair operators-nt,-otand-ef(-efmeans the same resolved path: a mount has no device or inode). - Name lookup:
type namereports what a name resolves to (type -tprints one ofkeyword,function,cli,builtin;type -alists every layer holding the name),which nameprints the name of anything runnable (there is no PATH, so there is no path to print) and reports a miss through exit1alone, andman namerenders a page: a command’s spec, or an installed CLI’s own--helptree (man linear issue create). - Globs:
*,?,[...]classes and[!...]negation (Pythonfnmatchsemantics in both implementations), resolved by the shell or pushed down to the resource. - Comments:
#.
Unsupported (returns clear error)
- Job control:
bg. (fg,jobs,wait,kill,disown,pswork; use the--backgroundflag andmirage jobCLI for long-running work.) - Shell internals:
exec CMD(process replacement; the redirect-onlyexec > fileworks),complete,compgen,ulimit, file descriptors above 2. - Output process substitution:
>(cmd)(the<(cmd)direction works). - Builtin options with no process backing:
xargs -I/-P(exit1) andtimeout -s/-k/--preserve-status(exit125) return anunsupported optionerror: commands run as coroutines inside the workspace, so there is no process to signal or parallelize.
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 returnexit_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 volumero 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.
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.fs.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.
- Python
- CLI
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 owncwd/env/cancel. The clone semantics make this race-free without per-call boilerplate. From the CLI, a subshell per call gives the same isolation.
- Python
- TypeScript
- CLI