Agent shell

There is no process and no host file system. The command is a POSIX shell subset interpreted inside the server, and every command is a builtin that works on your app's own stores. Nothing outside your app is reachable from it: there is no `..` past a mount, no symlink, no device, no network.

Docs menu

substrio_shell runs one shell command over your app, the way you work on a checkout: grep -rn, sed -n 10,40p, find ... | xargs sed -i, a heredoc, a pipe. It is the operation source.shell (POST /v1/_source/shell, MCP tool substrio_shell).

Mounts

Path What it is Writable
/src The branch's tree (branch, default main): the same files source.tree lists yes: every change of one call lands as one commit
/site The site/ folder of the version live runs no: SHELL_READ_ONLY
/files The app's stored files (file.list) yes: each write is a file.upload; rm is refused (SHELL_FILES_DELETE), file.delete does it

A command starts in /src (or cwd). Any other path, / itself included, is refused with SHELL_PATH_OUTSIDE.

One call, one commit

  • Reads see the stores as they are, and every write of the call is staged: a later command in the same call reads what an earlier one wrote (sed -i ... && grep ... sees the edit).
  • When the whole command ends with status 0, every /src change lands as ONE commit on the branch (its message: message, else what commit -m set, else the command itself), then each /files write is stored.
  • Any other status lands nothing. A refusal ends the call at once and lands nothing.
  • A command that changes nothing (a sed -i that matched nothing) makes no commit.
  • Nothing is kept between calls: no variables, no working directory, no history. Each call stands alone.

The call

{"command": "grep -rn 'TODO' /src", "branch": "main", "cwd": "/src", "offset": 0, "message": "..."}
Field Meaning
command The command line (required).
branch The branch /src is (default main; main before its first commit is an empty /src, made by the first commit). Any other branch is made by branch.create.
cwd Where the command starts (default /src).
offset Continue a cut output from this byte (the last answer's next_offset). Refused for a command that writes (SHELL_OFFSET_WRITES): running it again would write twice.
message The commit's message.
expected The version the branch must still be at (64 hex); 409 when it moved.

The answer

{"stdout": "...", "stderr": "", "exit_code": 0, "offset": 0, "truncated": false, "next_offset": 812,
 "commit": {"branch": "main", "version": "<64 hex>", "number": 14, "changed": ["src/a.js", "src/lib/b.js"]},
 "files": ["exports/report.csv"]}

commit is there only when the call committed; files only when it stored files. exit_code is the last pipeline's status, as in a shell (grep's 1 for no match is an answer, not a refusal).

Output past the output budget is never dropped in silence: the answer carries the first part, truncated: true, and next_offset; call again with offset: next_offset for the next part. stderr_truncated: true says the errors were cut at the same budget.

Budgets

Each is a server setting:

Flag Default Past it
--shell-cpu-ms 2000 SHELL_CPU_BUDGET: the call ends and lands nothing (a js script shares the same budget)
--shell-output-bytes 1048576 the answer is cut: truncated and next_offset
--shell-pipe-bytes 67108864 SHELL_PIPE_BUDGET: one stage wrote more than this into the next

Who may run it

source.shell is a read operation: every caller that may read the app's source may run it, a read-only grant included. A write is checked at the write against the operation it stands for: a /src write as source.edit, a /files write as file.upload. A caller whose grant does not reach it gets SHELL_WRITE_NOT_GRANTED at the first write, and nothing lands.

Grammar

  • Pipes |, lists ;, &&, ||, and ! before a pipeline.
  • Redirections >, >>, <, 2>, 2>&1, >&2, &>, here-strings <<< and heredocs <<EOF (expanded) and <<'EOF' (literal), <<- strips leading tabs.
  • Single quotes, double quotes, backslash escapes.
  • Globs *, ? and [...] on unquoted words (a name starting with . is matched only by a pattern that starts with .).
  • Variables, a fixed set: $PWD, $HOME (/src), $SRC, $SITE, $FILES, $BRANCH, $?. Any other is SHELL_UNKNOWN_VARIABLE.
  • Not in the grammar (SHELL_SYNTAX): $(...) and backticks, subshells ( ), if, for, while, case, functions, NAME=value assignments, background &.

Builtins

Command Options
grep -r -R -n -l -L -c -i -v -w -x -o -q -s -H -h -a -E -F -G -e PAT -m N -A N -B N -C N --include=GLOB --exclude=GLOB --exclude-dir=GLOB --color=never. A binary file answers Binary file X matches.
sed -n -E -r -s -i[SUFFIX] -e SCRIPT -f FILE; addresses N, $, /re/, \cREc, first~step, a,b, a,+N, !; commands s/// (flags g p N i, & and \1..\9), p P d D q Q = a i c y n N h H g G x { }. -i edits in place in the call (no backup file is written).
find -name -iname -path -ipath -wholename -type f/d -maxdepth -mindepth -empty -true -false -print -delete -exec CMD {} ; -exec CMD {} +, !, -a, -o, ( ).
xargs -n N -I REPL -0 -d DELIM -r.
cat -n.
head, tail -n N, -c N, -N, +N (tail), -q.
wc -l -w -c -m.
sort -r -n -u -f -b -s -k KEYDEF -t SEP -o FILE.
uniq -c -d -u -i.
cut -d DELIM -f LIST -c LIST -b LIST -s.
tr ranges, classes ([:upper:] and the like), escapes; -d -s -c.
diff unified output, -u, -U N, -q.
ls -a -l -R -d -1 -F.
cd, pwd within the mounts.
mv, cp -r; an unchanged file is moved or copied as its stored object (0 bytes copied).
rm -r -f; never a mount itself; never under /files.
mkdir, touch, tee mkdir -p, tee -a.
echo, printf echo -n -e; printf with %s %b %c %d %i %u %x %X %o, widths and precisions, the format reused for extra arguments.
test, [ -e -f -d -s -z -n -r -w -x, = == !=, -eq -ne -lt -le -gt -ge.
basename, dirname, true, false
commit -m MESSAGE names the call's commit.
js a script in the app's own runtime (below).

An unknown command is refused with SHELL_UNKNOWN_COMMAND, whose detail lists the builtins.

js

js 'SCRIPT' [ARGS], js -e 'SCRIPT', js FILE [ARGS], or js <<'EOF' ... EOF (the heredoc is the script). The script runs in the app's own JavaScript runtime, as a function body with these in scope:

Name What it is
fs readFile(path), writeFile(path, data), appendFile, readdir(path), stat(path) (size, isFile(), isDirectory()), exists(path), rm(path), rename(from, to), mkdir(path), each also as ...Sync. Paths are the shell's: relative to the cwd, under the three mounts, with the same refusals. Writes are the call's staged writes.
require require('fs') (or 'node:fs') is fs.
console log and info write stdout, error and warn stderr.
input the command's standard input as a string.
args the arguments after the script.

A value the script returns is printed. An uncaught error is status 1 with the error on stderr. A refusal inside fs (a path outside the mounts, a write the grant does not reach) refuses the whole call even when the script catches it.

Refusals

Code HTTP When
SHELL_SYNTAX 400 the command is not in the grammar above
SHELL_UNKNOWN_COMMAND 400 not a builtin (the detail lists them)
SHELL_UNKNOWN_VARIABLE 400 a variable outside the fixed set
SHELL_PATH_OUTSIDE 400 a path under no mount, .. past a mount, a mount itself removed
SHELL_READ_ONLY 400 a write to /site
SHELL_WRITE_NOT_GRANTED 403 a write the caller's grant does not reach
SHELL_FILES_DELETE 403 rm (or mv out of) /files: use file.delete
SHELL_OFFSET_WRITES 400 offset on a command that writes
SHELL_CPU_BUDGET 408 past --shell-cpu-ms
SHELL_PIPE_BUDGET 413 one stage past --shell-pipe-bytes

Every refusal carries field: "command" and a detail naming the word, path or budget. Nothing lands on any refusal.

What it costs

Per call: one parse of the command, one head read and one read of what live runs. Each directory is read once per call and each file once (later reads in the call are a hash probe), and every builtin works on a view of those bytes: nothing is copied until the answer is written. A pipe hands one buffer to the next stage (two buffers, reused). A write stages one copy of the new bytes (none for mv or cp of an unchanged file). The commit is one History::commit of every changed unit. A js script takes the app's runtime once per script.

Examples

Find every TODO with line numbers:

{"name": "substrio_shell", "arguments": {"command": "grep -rn TODO /src"}}

Read lines 10 to 40 of a file:

{"name": "substrio_shell", "arguments": {"command": "sed -n 10,40p functions/orders.js"}}

Rename a function across every file, one commit:

{"name": "substrio_shell", "arguments": {"command": "grep -rl getOrders functions | xargs sed -i 's/getOrders/listOrders/g'"}}

Write a new page from a heredoc:

{"name": "substrio_shell", "arguments": {"command": "cat > site/about.html <<'EOF'\n<h1>About</h1>\n<p>We make todo lists.</p>\nEOF"}}

Transform a file with a script:

{"name": "substrio_shell", "arguments": {"command": "js 'const rows = JSON.parse(fs.readFile(\"data/todos.json\")); fs.writeFile(\"data/todos.json\", JSON.stringify(rows.filter(r => !r.done), null, 2)); rows.length'"}}

Compare the source with what is live:

{"name": "substrio_shell", "arguments": {"command": "diff -u /site/index.html site/index.html"}}

Page through a long output:

{"name": "substrio_shell", "arguments": {"command": "cat data/big.csv", "offset": 1048576}}