# joulebox > A sandboxed Linux shell over HTTP, for coding agents. Each session is a home of its own; go, python3, git and the GNU text tools answer as on a Linux box, and what the box does not run is refused by name. A line is billed by its CPU time. ## What this box is The user is cleo, not root, and file permissions apply. A file the owner cannot read or write is refused, and a directory the owner cannot search is refused. The shell is bash 5's behaviour and messages. An unsupported option or builtin is refused by name, never ignored. go answers as gc 1.25. python3 answers as CPython 3.9. git is read-only, plus clone. The GNU text tools, jq and curl answer as they do on Linux. The home is /home/cleo, 4 MiB, counted in pages. Egress is only to the session's allowlisted hosts. A refusal is one or more lines on stderr and a nonzero status. A tool's own complaint stays that tool's line, such as `cat: f: Permission denied` with status 1. A thing this box does not run is refused by name, such as `go run: this program is not supported at the moment`. The word `quota` with status 1 means the home is full. A refusal is not redirected. Known differences, kept current: - no PTY. `< file` reads a file; `<&`, `<>`, and a here-string (`<<<`) are refused by name; - python3 is one file, not `python3 -c`, `python3 -m`, or pip; - `$$`, `$!`, `$-`, `$_`, `$'...'`, arrays, functions, and a subshell are refused by name; - stdout or stderr past 65536 bytes is cut there, and truncated is true; - git is clone, status, diff, log, show, grep, branch, and rev-parse; - `mv` between the home and /tmp fails with `Invalid cross-device link`. They are separate mounts, and mv does not copy across them; - a program the toolchain has not learned is refused by name. ## API - POST https://demo.joulebox.dev/v1/session: a new session. The answer has id, allow (the hosts it may reach) and ttl_s. - POST https://demo.joulebox.dev/v1/exec with {"session": ID, "line": LINE}: runs one shell line. The answer has stdout, stderr, status, ms, cpu_ms, usd, total_usd, cwd and truncated. truncated is true when stdout or stderr was cut at 65536 bytes. - POST https://demo.joulebox.dev/v1/fs with {"session": ID, "op": "W", "path": PATH, "data": TEXT}: writes a file; op "R" reads one. - GET https://demo.joulebox.dev/v1/session/ID/ledger: the newest 64 lines and the cost of each. omitted is how many earlier lines are not listed. total_j and total_usd add every line the session ran, including those. The list stays under 32 KiB, and no line is written into the session's files. - DELETE https://demo.joulebox.dev/v1/session/ID: ends the session. ## Network A program reaches the network only through the box's proxy: https only, host names, not IP literals. An anonymous session may reach: example.com. ## Examples Each block runs as it is in bash or zsh, after the setup block, against this box. A session is one sandboxed home. The user is cleo, not root, and file permissions apply. run sends one shell line to POST /v1/exec, which answers JSON with stdout, stderr, status and what the line cost; put writes a file through POST /v1/fs. The working directory carries over from one line to the next, as in a shell, so a block that changes it goes home at its end. GET /v1/session/ID/ledger lists the newest 64 lines and the cost of each. omitted is how many earlier lines are not in that list. total_j and total_usd add every line, including those left out. go run, go test, python3, git (clone, status, diff, log, show, grep, branch, rev-parse), jq, diff, curl and the GNU text tools answer as they do on a Linux box; what this box does not run is refused by name on stderr. If stdout or stderr ran past 65536 bytes, the answer keeps the first 65536 and truncated is true. ### A fix loop, in curl ```sh O=https://demo.joulebox.dev SID=$(curl -sS -X POST $O/v1/session | jq -r .id) put() { jq -Rs --arg sid "$SID" --arg p "$1" '{session:$sid,op:"W",path:$p,data:.}' | curl -sS --fail-with-body $O/v1/fs --json @- > /dev/null; } run() { jq -n --arg sid "$SID" --arg l "$1" '{session:$sid,line:$l}' | curl -sS --fail-with-body $O/v1/exec --json @-; } echo '1. The first try has a bug.' put fib.go <<'EOF' package main import "fmt" func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) } func main() { fmt.Println(fib("30")) } EOF run 'go run fib.go' | jq -r .stderr echo '2. The error names line 13. Fix it in the session, and run again.' run "sed -i 's/fib(\"30\")/fib(30)/' fib.go" > /dev/null run 'go run fib.go' | jq -c '{stdout, status}' echo '3. The receipt: what each command took, and what it cost.' curl -sS $O/v1/session/$SID/ledger | jq -c '(.lines[] | {line, ms, usd}), {omitted, total_usd}' ``` ### A fix loop, in Python ```python import json, urllib.request base = "https://demo.joulebox.dev" def call(method, path, obj=None): data = None if obj is None else json.dumps(obj).encode() req = urllib.request.Request(base + path, data=data, method=method, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req) as r: body = r.read() return json.loads(body) if body else None sid = call("POST", "/v1/session", {})["id"] def write(path, src): call("POST", "/v1/fs", {"session": sid, "op": "W", "path": path, "data": src}) def run(line): return call("POST", "/v1/exec", {"session": sid, "line": line}) # 1. the first try has a bug src = '''package main import "fmt" func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) } func main() { fmt.Println(fib("30")) } ''' write("fib.go", src) r = run("go run fib.go") print(r["status"], r["stderr"]) # 1 # command-line-arguments # ./fib.go:13: cannot use "30" (untyped string constant) as int value in argument to fib # 2. the error names the line: fix it, run again write("fib.go", src.replace('fib("30")', 'fib(30)')) r = run("go run fib.go") print(r["status"], r["stdout"]) # 0 832040 # 3. the receipt: the newest lines, and how many earlier ones were left out ledger = call("GET", "/v1/session/" + sid + "/ledger") for l in ledger["lines"]: print(f'{l["ms"]:7.2f} ms ${l["usd"]:.2e} {l["line"]}') print("total", ledger["total_usd"], "USD;", ledger["omitted"], "earlier lines left out") # optional: drop the files now. Otherwise they go when the session ends. # call("DELETE", "/v1/session/" + sid) ``` ### A fix loop, in TypeScript ```typescript const base = "https://demo.joulebox.dev"; const call = async (method, path, obj) => { const r = await fetch(base + path, {method, headers: {"Content-Type": "application/json"}, body: obj === undefined ? undefined : JSON.stringify(obj)}); const text = await r.text(); return text ? JSON.parse(text) : null; }; const {id} = await call("POST", "/v1/session", {}); const write = (path, data) => call("POST", "/v1/fs", {session: id, op: "W", path, data}); const run = (line) => call("POST", "/v1/exec", {session: id, line}); // 1. the first try has a bug const src = [ "package main", "", "import \"fmt\"", "", "func fib(n int) int {", "\tif n < 2 {", "\t\treturn n", "\t}", "\treturn fib(n-1) + fib(n-2)", "}", "", "func main() {", "\tfmt.Println(fib(\"30\"))", "}", ].join("\n") + "\n"; await write("fib.go", src); let r = await run("go run fib.go"); console.log(r.status, r.stderr); // 1 # command-line-arguments // ./fib.go:13: cannot use "30" (untyped string constant) as int value in argument to fib // 2. the error names the line: fix it, run again await write("fib.go", src.replace('fib("30")', "fib(30)")); r = await run("go run fib.go"); console.log(r.status, r.stdout); // 0 832040 // 3. the receipt: the newest lines, and how many earlier ones were left out const ledger = await call("GET", "/v1/session/" + id + "/ledger"); for (const l of ledger.lines) console.log(l.ms.toFixed(2).padStart(7), "ms $" + l.usd.toExponential(2), "", l.line); console.log("total", ledger.total_usd, "USD;", ledger.omitted, "earlier lines left out"); // optional: drop the files now. Otherwise they go when the session ends. // await call("DELETE", "/v1/session/" + id); ``` ### Setup: a session, and two helpers ```sh O=https://demo.joulebox.dev # title go: hello.go and examples/, a small codebase to look around in SID=$(curl -sS -X POST $O/v1/session --json '{"title":"go"}' | jq -r .id) put() { jq -Rs --arg sid "$SID" --arg p "$1" '{session:$sid,op:"W",path:$p,data:.}' | curl -sS --fail-with-body $O/v1/fs --json @- > /dev/null; } run() { jq -n --arg sid "$SID" --arg l "$1" '{session:$sid,line:$l}' | curl -sS --fail-with-body $O/v1/exec --json @-; } ``` ### 1. Look around a codebase ```sh run 'ls -la' | jq -r .stdout run 'grep -rn "func main" examples | head -3' | jq -r .stdout run 'sed -n "1,8p" hello.go' | jq -r .stdout run 'find . -name "*.go" | wc -l' | jq -r .stdout ``` ### 2. A failing test, the fix, the test again ```sh run 'mkdir -p calc' > /dev/null put calc/go.mod <<'EOF' module example.com/calc go 1.25 EOF put calc/calc.go <<'EOF' package calc func Add(a, b int) int { return a - b } EOF put calc/calc_test.go <<'EOF' package calc import "testing" func TestAdd(t *testing.T) { if got := Add(2, 3); got != 5 { t.Fatalf("Add(2, 3) = %d, want 5", got) } } EOF run 'cd ~/calc && go test ./... 2>&1 | tail -4' | jq -r .stdout run "sed -i 's/a - b/a + b/' ~/calc/calc.go" > /dev/null run 'cd ~/calc && go test -v ./...' | jq -r .stdout run 'cd' > /dev/null ``` ### 3. JSON with jq, counted with sort | uniq -c ```sh put users.json <<'EOF' {"users":[{"name":"ada","lang":"go"},{"name":"bob","lang":"python"},{"name":"cy","lang":"go"}]} EOF run "jq -r '.users[].lang' users.json | sort | uniq -c | sort -rn" | jq -r .stdout run "jq -c '[.users[] | select(.lang == \"go\") | .name]' users.json" | jq -r .stdout ``` ### 4. Shell: substitution, loops, arithmetic ```sh run 'n=$(ls examples | wc -l); echo "examples: $n"' | jq -r .stdout run 'for f in examples/0*.go; do wc -l $f; done | sort -rn | head -3' | jq -r .stdout run 'x=7; if [ $((x * 6)) -eq 42 ]; then echo yes; else echo no; fi' | jq -r .stdout ``` ### 5. Python with arguments ```sh put words.py <<'EOF' import sys words = sys.argv[1:] lengths = {w: len(w) for w in words} for w in sorted(words, key=len): print(f"{w:>8} {lengths[w]}") print("total", sum([lengths[w] for w in words])) EOF run 'python3 words.py go python shell' | jq -r .stdout ``` ### 6. git: clone, status, diff ```sh run 'git clone https://github.com/octocat/Hello-World.git' | jq -r .stderr run 'cd ~/Hello-World && git status' | jq -r .stdout run "cd ~/Hello-World && sed -i 's/World/agents/' README && git diff" | jq -r .stdout run 'cd' > /dev/null ``` ### 7. diff -u and sha256sum ```sh run 'cp hello.go hello2.go && sed -i "s/edit me/edit you/" hello2.go' > /dev/null run 'diff -u hello.go hello2.go | tail -4; sha256sum hello.go hello2.go' | jq -r .stdout ``` ### 8. curl to a host the session may reach ```sh run 'curl -s -o /dev/null -w "%{http_code}\n" https://example.com/' | jq -r .stdout ``` ### 9. Word frequency with tr, grep -E and uniq -c ```sh put story.txt <<'EOF' The quick brown fox jumps over the lazy dog. The dog sleeps; the fox runs. A fox, a dog, and a cat: the cat watches the fox and the dog. EOF run "cat story.txt | tr -cs 'A-Za-z' '\n' | tr 'A-Z' 'a-z' | grep -E '^[a-z]{3,}$' | sort | uniq -c | sort -rn | head -5" | jq -r .stdout ``` ### 10. The receipt, and closing the session ```sh curl -sS $O/v1/session/$SID/ledger | jq -c '{commands: (.lines | length), omitted, total_ms: ([.lines[].ms] | add | floor), total_usd}' curl -sS -X DELETE $O/v1/session/$SID ```