The terminal
One-shot commands as argv, interactive ptys, and why a non-zero exit code is a success.
Two ways to run something: a one-shot exec over HTTP, and a pty you attach to over the
gateway. Neither is gated on the input lease — a shell is a session of its own, and two people
holding separate terminals is normal. The lease governs the desktop, where there is one mouse
and one keyboard.
exec
const result = await machine.terminal.exec(["git", "clone", url]);
result.stdout;
result.stderr;
result.exit_code;
command is argv, not a string. There is no shell and no tokenizing, so an argument
containing a space, a quote or a semicolon is one argument. That is the whole defence against
command injection, and it only works because the API refuses to split a string for you.
| Field | Meaning |
|---|---|
command |
argv, required |
user |
agent (default) or root |
cwd |
working directory |
stdin |
fed to the process |
timeout_ms |
how long it may run |
A non-zero exit code is a successful call
Only a failure to run the command at all is an error. A command that runs and returns 1 is a
200 with exit_code: 1 — the code is data, not a fault.
That distinction is worth holding onto in an agent loop. A model that sees an exception on every failed grep learns to avoid grep; a model that sees an exit code learns to read it.
Asking for a shell by name
exec deliberately does not tokenize, so pipes, globs and redirects need a shell, spelled out:
await machine.terminal.execShell("cd /tmp/site && python3 -m http.server 3000 &");
// which is exactly:
await machine.terminal.exec(["/bin/sh", "-lc", "cd /tmp/site && python3 -m http.server 3000 &"]);
The point is that a reader can see you asked for it. Guessing where a command ends is how injection happens.
Interactive terminals
| Call | What it does |
|---|---|
POST /machines/{id}/terminals |
open a pty |
GET /machines/{id}/terminals |
list open ptys |
DELETE /machines/{id}/terminals/{term_id} |
kill one |
const term = await machine.terminal.connect(); // opens one if you pass no id
term.write("ls -la\n");
term.resize(120, 32);
for await (const bytes of term) process.stdout.write(bytes);
term.close(); // closes the pty inside the machine
open takes command, cwd, user, cols and rows. Bytes go both ways unmodified over
the terminal channel; a terminal already holding a client refuses a
second one at the handshake with close code 4409.
The CLI’s raster shell is this, with your local terminal put into raw mode so ^C reaches
the remote process rather than being handled locally.