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
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.
gws list calls)
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.
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). 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.
-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.
Errors
An error that notry 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.
<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.
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 -.
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 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). A read end keeps its offset and a dup shares it, as bash’s do: afterexec 1<file,read a <&1; read b <&1reads two lines andexec 0<&1reads 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 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)${PIPESTATUS[@]}(the per-segment statuses of the last pipeline) and${FUNCNAME[@]}(the running functions, innermost first, a sourced file assource; 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,sourceor nested shell read one standard input in turn, as bash’s share one descriptor:readtakes its line (or its-n/-N/-damount) and the next command reads on from there, soprintf 'a\nb\n' | { read x; cat; }printsb.exec < filerebinds that input for every statement after it, inside the group or subshell that runs it too (printf 'z\n' | { exec < f; read a; }readsf), 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 -nprintshi -n),read -r, every GNUxargsoption (batching by words, lines (-L/-l) or bytes (-s, 128 KiB by default, with GNU’s-xand too-long errors),-I/-iruns once per input line with the line in place of the string, input quotes and backslashes processed as GNU does,-E/-eend-of-file words,-0/-dwith GNU’s escapes,-a FILE,-ttraces,--show-limitsand-sbounded by the environment’s size against a 2 MiBARG_MAX,-P Nruns up to N commands at once, each in its own copy of the session, with output kept in input order and--process-slot-varnumbering them; there is no terminal, so-pand-ofail as GNU does without one; long options may be abbreviated as GNU’s getopt_long allows; GNU exit codes:123when an invocation fails,124after one exits255,127for a command nothing provides (xargs: cd: No such file or directory); a function runs too), every GNUtimeoutoption (timeout DURATIONtakes a C float with ans/m/h/dsuffix; 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 (exit124, or128+Nwith-p),CONT/CHLD/URG/WINCH/0let it finish, a stop halts it, and-k’sKILLends what is left with137;-fand-vas in GNU; usage errors exit125with GNU’sTryline).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: every program a session can run (a mount command, an installed CLI, a builtin a real system also ships as a file, such as
echoorxargs) has an executable file under the read-only/usr/bin, and$PATHis/usr/bin.which nameprints that file (/usr/bin/ls) and reports a miss (cd, a function, an unknown word) through exit1alone;command -vprints the file, or the bare name for a builtin or function;type namesaysls is /usr/bin/lsorcd is a shell builtin(type -tprints one ofalias,keyword,function,builtin,file;type -alists every layer holding the name;type -p/-Pprint 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/lsby path works too; a write into/usr/binis refused as read-only.man namerenders a page: a command’s spec, or an installed CLI’s own--helptree (man linear issue create). - Program runs:
env,xargsandtimeoutrun their command the wayfind -execdoes, as the program a real system would exec, so a builtin that doubles as a program answers as the program.printfthere is coreutils’:-vis a format string,--ends its options, and a format that takes no argument warnsprintf: warning: ignoring excess arguments, starting with 'a'(the builtin drops them silently, as bash’s does). - Globs:
*,?,[...]classes and[!...]negation (Pythonfnmatchsemantics 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,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). - 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,32and33kill timeout itself (exit128+Neven with-p),STOPstops it for good andCHLDleaves it waiting until-k’sKILL(or forever), as GNU does. What a nestedsh -cprinted before the deadline is lost, because a nested line hands back its output only when it finishes.
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.
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: 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.
- 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
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.