kosa8 docs

Guides / Ruby / Developer guide

Ruby developer guide

Everything the kosa8 gem can do, one capability at a time. Each section starts with what the thing is, in plain words, then goes as deep as the code goes: what is sent, what comes back, and the edges you can trip on. Every output on this page is from a real run.

How this page was tested

Each example was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with the system Ruby 2.6.10 and the kosa8 gem 0.1.2 (tag v0.1.2, loaded with ruby -I lib; the same files as gem install kosa8 -v 0.1.2 from RubyGems). The examples were first run with 0.1.0; every section whose behaviour changed in 0.1.1 or 0.1.2 (stdin, timeouts, callbacks, checkpoints, the agent loop) was run again with 0.1.1 or 0.1.2, and the other outputs are from the 0.1.0 runs of code that did not change. The output is pasted from those runs; long hashes are cut where marked. The machine was running other people's sandboxes at the same time, so boot and fork times are slower than on an idle Mac. Every sandbox and snapshot the examples created was deleted afterwards. Not tested here: approvals (they need an org policy that asks for them), mounts:, capture:, broker:, encrypted snapshots, and remote workers over https (the endpoint rules were checked without a worker).

What this is, in plain words

kosa8 gives you sandboxes: small virtual computers that run on your own machine, each with its own Linux kernel, disk and network rules. Code inside one cannot reach your files or your network unless you let it. This gem is how a Ruby program drives them.

Six words come up on every page:

WordPlain meaning
daemonThe kosa8 background service on your machine. It does the work; the gem sends it requests.
sandboxOne virtual computer (a microVM). It has a name, such as doc-rb-demo.
image and containerPrograms run inside a sandbox as containers, started from an image (a packaged set of files, such as alpine:latest, a tiny Linux).
egressNetwork traffic going out of the sandbox. You decide whether any is allowed, and to where.
snapshot and checkpointA saved copy of a running sandbox: memory, running programs and disk. A checkpoint is a snapshot kept on a per-sandbox "undo" list.
forkMaking several live copies of a running sandbox, so you can try different things in each.

For developers: the gem is a thin, dependency-free client for the daemon's native HTTP API (/kosa8/v1), spoken over a unix socket with Net::HTTP. Answers are the daemon's JSON, parsed into hashes with string keys. Process output streams back as NDJSON frames. Checkpoints are bookkeeping on the client side, in the same file the CLI uses. Method for method it matches the other kosa8 SDKs.

The two objects you use

require "kosa8"

k  = Kosa8::Client.new          # one per program is enough; safe to share across threads
sb = k.sandbox("doc-rb-demo")   # a handle: the same methods, with the sandbox name filled in

k.run("doc-rb-demo", "alpine:latest", ["echo", "hi"])   # these two lines
sb.run("alpine:latest", ["echo", "hi"])                 # do the same thing

Kosa8::Client has every method. Kosa8::Sandbox is a convenience: it holds a client and a name and forwards to the client. Making a handle sends nothing and does not check the sandbox exists. Three snapshot methods are only on the client (restore_snapshot, delete_snapshot, list_snapshots); reach them with sb.client. (Since 0.1.1 the handle also has exec_frames, start_interactive, write_stdin and attach.) The full list is in the API reference.

Connecting

In plain words: by default the gem talks to the kosa8 daemon on the same machine, through a file called a unix socket. Only programs running as you on that computer can use it, so no password is needed. To drive kosa8 on another machine (a "worker"), you give an address and a token.

Where requests go

With no options, requests go to ~/.kosa8/run/kosa8.sock. Otherwise the gem follows the CLI's rules, so the same environment variables work for both:

VariableKeywordEffect
KOSA8_HOSThost:Where the daemon is. https://host[:port][/path], http://…, tcp://host:port (read as http://), a bare host:port (also http://), or unix:///path/to.sock. Unset or empty: the local socket.
KOSA8_TOKENtoken:The worker's bearer token, sent as Authorization: Bearer …. Read from the environment only when the address also came from KOSA8_HOST: pass host: in code and you must pass token: too. Never sent over a unix socket.
KOSA8_INSECUREinsecure:1 allows plain http:// to a host that is not this machine. Without it the gem refuses, because the token would cross the network in clear text.
KOSA8_AGENT_TOKENagent_token:An agent identity (minted with kosa8 agent token create NAME), sent as X-Kosa8-Agent-Token. See Agent identity.
(none)socket_path:A different socket file, when no host is set.

What the rules gave, from Kosa8.resolve_endpoint (home folder shortened):

host=nil env={}
  -> unix:///Users/you/.kosa8/run/kosa8.sock base_path=nil token=nil
host=nil env={"KOSA8_HOST"=>"https://w.example.com/", "KOSA8_TOKEN"=>"t0k"}
  -> https://w.example.com:443 base_path="" token="t0k"
host="tcp://127.0.0.1:7070" env={}
  -> http://127.0.0.1:7070 base_path="" token=nil
host="localhost:7070" env={}
  -> http://localhost:7070 base_path="" token=nil
host="unix:///tmp/other.sock" env={}
  -> unix:///tmp/other.sock base_path=nil token=nil
host="https://w.example.com/kosa8-proxy" env={"KOSA8_TOKEN"=>"t0k"}
  -> https://w.example.com:443 base_path="/kosa8-proxy" token=nil
host=nil env={"KOSA8_HOST"=>"http://10.0.0.5:7440", "KOSA8_INSECURE"=>"1"}
  -> http://10.0.0.5:7440 base_path="" token=nil

Note the sixth line: an explicit host: does not pick up KOSA8_TOKEN. A path after the host (/kosa8-proxy) is kept and put in front of every request, for a worker behind a proxy. And the refusals, raised by Kosa8::Client.new before anything is sent:

refusing to talk to 10.0.0.5:7440 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7440 (a worker behind a TLS-terminating tunnel or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted
KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix://

Pass env: {} to make a client ignore the environment entirely (useful in tests), or any hash to supply your own values.

Who is calling: the actor

Every request carries X-Kosa8-Actor, recorded in the audit log (kosa8 audit ls). It is "tool" unless you pass actor: "agent" or actor: "human"; anything else raises Kosa8::Error. An org policy can treat the three differently. headers: adds your own headers to every request.

Sandboxes

In plain words: create a sandbox, use it, delete it. You can also stop one (its disk is kept, its programs end) and start it again later.

c = k.create_sandbox(name: "doc-rb-guide", cpus: 2, mem_mb: 1024,
                     egress_allow: ["example.com"], labels: { "team" => "docs" })
puts "create keys: #{c.keys.inspect}"
puts "sandbox: #{c['sandbox'].inspect}"
create keys: ["boot_ms", "sandbox"]
sandbox: {"id"=>"doc-rb-guide", "name"=>"doc-rb-guide", "created"=>"2026-11-03T01:39:22.091122-05:00", "state"=>"running", "cpus"=>2, "mem_mb"=>1024, "disk_gb"=>32, "egress"=>"allow:example.com", "ip"=>"192.168.127.2", "labels"=>{"team"=>"docs"}}

Every keyword is optional; leave one out and the daemon's default applies (here, a 32 GB disk). The name is also the sandbox's id; it must be unused, or the daemon answers ConflictError. labels are classification tags that an org policy can check. The lifecycle calls:

CallWhat happens
sb.stopShuts the machine down; disk kept. Answered {"ok"=>true, "sandbox"=>{… "state"=>"stopped" …}, "warning"=>""}.
sb.startBoots it from its own disk (741 ms in our test). Files written to the sandbox's disk survive; running containers do not: sb.containers was [] afterwards.
sb.deleteRemoves it and, by default, its checkpoints. wipe: true overwrites its data first; keep_checkpoints: true keeps the checkpoints (and their snapshots).
sb.info / k.get_sandbox(name)The sandbox's record, or nil when there is none (no exception).
k.list_sandboxesEvery sandbox's record.

Network rules (egress)

In plain words: you decide whether code in the sandbox can reach the internet. The rule is enforced by your computer, outside the sandbox, so nothing inside can switch it off.

You passSent asMeaning
egress: "deny"denyNo network at all.
egress_allow: ["pypi.org", "github.com"]allow:pypi.org,github.comOnly these hosts; the safe way to let an agent install packages.
egress: { allow: [...] }allow:…The same, as a hash.
egress: "any"anyAnything. An org policy may forbid or require approval for it.
egress: "fakenet"fakenetA simulated internet, for watching what code tries to reach. See Network rules.

With egress_allow: ["example.com"], from inside the sandbox:

r = sb.run("alpine:latest", ["sh", "-c", "wget -q -T 5 -O- http://example.com | head -c 60; echo; " \
  "wget -q -T 5 -O- http://github.com >/dev/null 2>&1 && echo github ok || echo github blocked"])
puts r.stdout
puts JSON.generate(sb.egress)
<!doctype html><html lang=en><head><meta charset=utf-8><link
github blocked
{"policy":"allow:example.com","stats":{"passed":9,"denied":8,"dropped":7,"dns_answered":6,"dns_refused":8}}

sb.egress reports the policy the host enforces and what it decided: connections passed, denied and dropped, and DNS lookups answered or refused. The daemon's log records each refusal (egress denied sandbox=doc-rb-guide proto=dns … name=github.com … reason="name not in egress policy allow"). Two checks happen in the gem before anything is sent: egress_allow with an egress other than "deny", and an empty allow-list, both raise Kosa8::Error. The daemon checks the rest; a wildcard such as *.com is refused with BadRequestError. More in Network rules.

Running code

In plain words: there are two ways to run a command. run starts a fresh, clean container, runs one command and throws the container away. exec runs a command inside a container you started earlier and left running, so files and programs carry over from one command to the next.

Images first

A sandbox starts with no images. sb.pull(ref) fetches one on the host and copies it in, so it works even with egress: "deny":

pull: {"image"=>"docker.io/library/alpine:latest", "ok"=>true}
images: ["docker.io/library/alpine:latest"]

(The client's own k.pull(ref) is different: it pulls into the host's shared engine, not into a sandbox.)

run: output as it happens

t = Time.now
r = sb.run("alpine:latest",
           ["sh", "-c", "for i in 1 2 3; do echo out $i; echo err $i >&2; sleep 0.3; done; echo $GREETING"],
           env: { "GREETING" => "hi from env" },
           on_stdout: ->(b) { puts "  [live stdout #{(Time.now - t).round(1)}s] #{b.inspect}" },
           on_stderr: ->(b) { puts "  [live stderr] #{b.inspect}" },
           on_status: ->(b) { puts "  [status] #{b.inspect}" })
p r
  [live stdout 16.8s] "out 1\n"
  [live stderr] "err 1\n"
  [live stdout 17.1s] "out 2\n"
  [live stderr] "err 2\n"
  [live stderr] "err 3\n"
  [live stdout 17.4s] "out 3\n"
  [live stdout 17.7s] "hi from env\n"
#<struct Kosa8::ExecResult exit_code=0, stdout="out 1\nout 2\nout 3\nhi from env\n", stderr="err 1\nerr 2\nerr 3\n">

What to know, in depth:

start_container and exec: state that carries over

cid = sb.start_container("alpine:latest", ["sleep", "3600"])
puts "cid: #{cid}"
puts "containers: #{sb.containers.inspect}"
cid: k3-bea6352b
containers: [{"id"=>"k3-bea6352b", "image"=>"docker.io/library/alpine:latest", "status"=>"running", "ip"=>"10.88.0.4"}]

Then sb.exec(cid, ["sh", "-c", "…"]) runs commands in it and returns the same ExecResult, with the same callbacks (no env:). The container's main command must keep running, which is why the examples use sleep 3600 or a loop.

For the raw stream, exec_frames yields every frame, including the last. It does not raise on an error frame, so it suits code that wants to see exactly what the daemon said:

k.exec_frames("doc-rb-guide", cid, ["sh", "-c", "echo a; echo b >&2; exit 4"]) { |f| p f }
{"stream"=>"stdout", "data"=>"a\n"}
{"stream"=>"stderr", "data"=>"b\n"}
{"done"=>true, "ok"=>true, "exit"=>4}

Feeding input: start_interactive, write_stdin, attach

In plain words: some programs read input, the way cat or a REPL does. Start the program with its input held open, send it the input, then collect what it printed.

icid = k.start_interactive("doc-rb-guide", "alpine:latest", ["sh", "-c", "cat | tr a-z A-Z"])
p k.write_stdin("doc-rb-guide", icid, "hello stdin\n")
p k.attach("doc-rb-guide", icid, timeout: 30)

icid2 = k.start_interactive("doc-rb-guide", "alpine:latest", ["wc", "-c"])
p k.write_stdin("doc-rb-guide", icid2, ["abc", "def", "\n"])       # an Enumerable: sent chunked
p k.attach("doc-rb-guide", icid2, timeout: 30)

icid3 = k.start_interactive("doc-rb-guide", "alpine:latest", ["sh", "-c", "wc -l"])
File.write("lines.txt", "1\n2\n3\n")
File.open("lines.txt") { |f| p k.write_stdin("doc-rb-guide", icid3, f) }  # an IO: streamed
p k.attach("doc-rb-guide", icid3, timeout: 30)
{"ok"=>true}
#<struct Kosa8::ExecResult exit_code=0, stdout="HELLO STDIN\n", stderr="">
{"ok"=>true}
#<struct Kosa8::ExecResult exit_code=0, stdout="7\n", stderr="">
{"ok"=>true}
#<struct Kosa8::ExecResult exit_code=0, stdout="3\n", stderr="">

One write is the whole of stdin. The daemon closes the program's input as soon as the body of a write_stdin is finished. So the gem allows one write_stdin per container: since 0.1.1 a second one raises instead of pretending to work. With 0.1.1, through the handle (which also has these methods since 0.1.1):

icid = sb.start_interactive("alpine:latest", ["cat"])
p sb.write_stdin(icid, "one\n")
begin
  sb.write_stdin(icid, "two\n")
rescue Kosa8::Error => e
  puts "second write_stdin: #{e.class}: #{e.message}"
end
p sb.attach(icid, timeout: 30)
{"ok"=>true}
second write_stdin: Kosa8::Error: stdin of container k1-b998c4e0 in doc-rb-v11 was already sent and is closed: the daemon treats one write_stdin body as the whole of stdin. Pass an IO or an Enumerable to stream input
#<struct Kosa8::ExecResult exit_code=0, stdout="one\n", stderr="">

(With 0.1.0 the second call answered {"ok"=>true} and "two" was silently lost.) To send input over time, pass one IO or Enumerator that yields chunks as you produce them; the gem sends it chunked, and the program reads each chunk as it arrives. The client remembers which containers it has written to, so a different Kosa8::Client object would not stop a second write; and the container is marked before the request goes out, so if the first write fails, start a new container rather than retrying. attach follows the container until it exits and returns its output; tty: true on start_interactive asks for a terminal (not tested here).

Snapshots and restore

In plain words: a snapshot is a saved copy of a running sandbox, taken without stopping it. Restoring one starts a new sandbox exactly where the copy was, with its programs still running.

s = sb.snapshot(id: "doc-rb-g2-snap")
puts "snapshot keys: #{s.keys.inspect} in #{s['snapshot_ms']}ms"
r = k.restore_snapshot("doc-rb-g2-snap", name: "doc-rb-g2-copy")
puts "restore keys: #{r.keys.inspect} restore_ms=#{r['restore_ms']} parent=#{r['sandbox']['parent']}"
puts "copy exec: #{k.sandbox('doc-rb-g2-copy').exec(cid, ['cat', '/n']).stdout.inspect}"
snapshot keys: ["snapshot", "snapshot_ms"] in 365ms
restore keys: ["restore_ms", "sandbox"] restore_ms=434 parent=doc-rb-g2
copy exec: "0\n"

The copy has the original's containers, running, under the same ids: the container id cid from the original worked in the copy. A snapshot record carries its size (size_bytes: 100,667,392 for this idle 512 MiB sandbox) and where its files are. Snapshot ids must be unused: taking doc-rb-g2-snap again raised ConflictError (snapshot already exists: doc-rb-g2-snap (choose another id, or delete the existing snapshot first)). k.delete_snapshot(id) answered {"encrypted"=>false, "ok"=>true, "removed"=>"doc-rb-g2-snap"}. Deleting a sandbox does not delete snapshots you took with snapshot; only its checkpoints.

Fork

In plain words: make several live copies of a running sandbox at once. Each copy starts exactly where the original was and then goes its own way.

f = sb.fork(2, prefix: "doc-rb-g2-f")
puts JSON.generate(f)
{"count":2,"fork_ms":3432,"forks":[{"id":"doc-rb-g2-f1","name":"doc-rb-g2-f1",…,"state":"running","parent":"doc-rb-g2",…},{"id":"doc-rb-g2-f2",…}],"snapshot":"doc-rb-g2-fork-1793688072352650000"}

Copies are named prefix plus a number. Each one is an ordinary sandbox: run in it, delete it. snapshot is the id of the snapshot the copies were made from; once both copies were deleted, it had gone too. Disks are copy-on-write, so a copy costs disk only for what it changes, but each running copy uses its own memory.

Explore: try several ideas, keep the best

In plain words: give explore two or more shell commands. It forks the sandbox once per command, runs them all at the same time, keeps the copy where the fastest successful one ran, and deletes the others.

ex = sb.explore(["sleep 2; echo slow", "echo fast", "echo broken; exit 1"], prefix: "doc-rb-g2-e")
p ex.winner, ex.removed
ex.branches.each { |b| p b }
"doc-rb-g2-e2"
2
#<struct Kosa8::ExploreBranch name="doc-rb-g2-e1", variant="sleep 2; echo slow", exit=0, output="slow", error=nil, duration_ms=2508>
#<struct Kosa8::ExploreBranch name="doc-rb-g2-e2", variant="echo fast", exit=0, output="fast", error=nil, duration_ms=506>
#<struct Kosa8::ExploreBranch name="doc-rb-g2-e3", variant="echo broken; exit 1", exit=1, output="broken", error=nil, duration_ms=484>

Afterwards the parent and doc-rb-g2-e2 were left; the winner is yours to use and to delete. In depth:

Checkpoints and rewind

In plain words: a checkpoint is an undo point. Take one before something risky; if it goes wrong, rewind puts the sandbox back exactly as it was, programs still running, under the same name.

Shared with the command line

Checkpoints live in ~/.kosa8/checkpoints.json, the file the kosa8 CLI, its MCP server and the Mac app use. A checkpoint taken in Ruby shows in kosa8 checkpoint ls, and one taken with the CLI shows in Ruby:

c1 = sb.checkpoint(label: "base")
puts "ruby checkpoint: #{c1['checkpoint'].inspect} evicted=#{c1['evicted'].inspect}"
puts "CLI sees:\n" + `kosa8 checkpoint ls doc-rb-g3 2>&1`
puts "CLI checkpoint: " + `kosa8 checkpoint doc-rb-g3 --label deps 2>&1`
puts "ruby sees: #{sb.checkpoints.map { |c| [c['id'], c['label']] }.inspect}"
rw = sb.rewind(to: "BASE")
puts "rewind: checkpoint=#{rw['checkpoint']['id']} dropped=#{rw['dropped'].inspect} restore_ms=#{rw['restore_ms']}"
puts "after rewind ruby sees: #{sb.checkpoints.map { |c| c['id'] }.inspect}"
ruby checkpoint: {"id"=>"doc-rb-g3-ckpt-1", "sandbox"=>"doc-rb-g3", "label"=>"base", "created"=>"2026-11-03T06:46:25.129669000Z"} evicted=[]
CLI sees:
CHECKPOINT          WHEN       TAKEN   WHY
*doc-rb-g3-ckpt-1   just now   you     base

* is where `kosa8 rewind doc-rb-g3` goes
CLI checkpoint: checkpoint doc-rb-g3-ckpt-2 (deps) saved in 902ms
  rewind with `kosa8 rewind doc-rb-g3`
ruby sees: [["doc-rb-g3-ckpt-1", "base"], ["doc-rb-g3-ckpt-2", "deps"]]
rewind: checkpoint=doc-rb-g3-ckpt-1 dropped=["doc-rb-g3-ckpt-2"] restore_ms=421
after rewind ruby sees: ["doc-rb-g3-ckpt-1"]

How it works, in depth

A rewind that fails halfway

The sandbox is deleted before the restore, because the restored machine needs its name. If the restore then fails (on a Mac, most often because the screen is locked), the sandbox is gone and the checkpoint is intact. rewind raises Kosa8::CheckpointError whose message says so (restore failed and doc-rb-g3 no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with restore_snapshot("doc-rb-g3-ckpt-1", name: "doc-rb-g3")); run that call once the cause is fixed. e.cause holds the daemon's error.

Threads

In plain words: you can do several things at once from one Ruby program, such as running commands in many sandboxes in parallel, with one shared client.

t0 = Time.now
rs = 4.times.map { |i| Thread.new { sb.run("alpine:latest", ["sh", "-c", "sleep 2; echo thread #{i}"]).stdout.strip } }.map(&:value)
puts "threads: #{rs.inspect} in #{(Time.now - t0).round(1)}s"
threads: ["thread 0", "thread 1", "thread 2", "thread 3"] in 3.6s

Four 2-second commands took 3.6 seconds in all, not 8. In depth:

Timeouts

In plain words: a timeout is how long the gem waits for the daemon before giving up. By default, a running command is waited for as long as it takes; you set a timeout only when you want your program to stop waiting.

The gem uses Ruby's Net::HTTP. When you set timeout: (on the client, or on one call, which wins), it becomes the read timeout, and the connect timeout is the smaller of it and 30 seconds. When you set none:

During a streaming call, each read waits for the next piece of output, so a timeout you set restarts whenever the command prints: it measures the longest silence, not the whole run.

sb.run("alpine:latest", ["sleep", "30"], timeout: 3)               # gives up after 3 silent seconds
r = sb.run("alpine:latest", ["sh", "-c", "sleep 70; echo done"])   # no timeout: waits

What those two printed, through the test script's own puts lines (0.1.1):

TimeoutError: POST /kosa8/v1/sandboxes/doc-rb-v11/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock: Net::ReadTimeout with #<UNIXSocket:(closed)>
containers after timeout: [["docker.io/library/alpine:latest", "exited"], ["docker.io/library/alpine:latest", "running"], ["docker.io/library/alpine:latest", "running"]]
70s silent run, no timeout: exit 0 "done\n" after 71s

When your own callback fails

An exception raised inside your on_stdout, on_stderr or on_status callback (or an exec_frames block) ends the call and comes out as itself, since 0.1.1:

sb.run("alpine:latest", ["echo", "hi"], on_stdout: ->(b) { raise IOError, "closed stream (mine)" })
callback: IOError: closed stream (mine)

In 0.1.0 an IOError or system error there was relabelled Kosa8::ConnectionError, as if the daemon had gone away. The command in the sandbox is not stopped either way.

Approvals and policy

In plain words: a company can set rules for kosa8 (an org policy): which images may be pulled, whether a sandbox may have open network, and actions that named people must approve first. When a rule says no, the gem raises an exception that carries the daemon's reason.

Methods that can be gated take approval:, an approval id, sent as the X-Kosa8-Approval header. A refusal that an approval would lift is Kosa8::ApprovalRequiredError, whose approval hash says why and who can sign; a plain refusal is Kosa8::PolicyError. Neither was reproduced for this page (it needs a policy installed). The flow is on Company rules and Ruby errors.

Recipes

An agent loop with undo

An AI agent proposes commands one at a time. Take a checkpoint before each, run it, judge the result, and rewind when a step does harm. Here the "agent" is a fixed list, and its second step is destructive:

require "kosa8"

# The commands an AI agent proposes, one per step. In a real loop these come from a model.
STEPS = [
  "mkdir -p /work && echo 'print(6 * 7)' > /work/app.py",
  "rm -rf /work",                      # a bad step: it destroys the work
  "cat /work/app.py && echo step3 ok",
].freeze

k = Kosa8::Client.new(actor: "agent")
sb = k.sandbox("doc-rb-agent")
k.create_sandbox(name: sb.id, egress: "deny", mem_mb: 512)
begin
  sb.pull("alpine:latest")
  shell = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"])
  STEPS.each_with_index do |cmd, i|
    sb.checkpoint(label: "before step #{i + 1}")
    r = sb.exec(shell, ["sh", "-c", cmd], timeout: 300)
    # Judge the step. Here: a step must leave /work/app.py in place.
    check = sb.exec(shell, ["test", "-f", "/work/app.py"])
    if r.ok? && check.ok?
      puts "step #{i + 1} kept: #{cmd}"
    else
      back = sb.rewind  # the latest checkpoint: just before this step
      puts "step #{i + 1} undone (#{back['checkpoint']['label']}, #{back['restore_ms']}ms): #{cmd}"
    end
  end
  puts "final: #{sb.exec(shell, ['cat', '/work/app.py']).stdout.strip}"
  puts "checkpoints: #{sb.checkpoints.map { |c| c['label'] }.inspect}"
ensure
  out = sb.delete
  puts "cleanup: #{out.inspect}"
end
$ ruby agent.rb
step 1 kept: mkdir -p /work && echo 'print(6 * 7)' > /work/app.py
step 2 undone (before step 2, 388ms): rm -rf /work
step 3 kept: cat /work/app.py && echo step3 ok
final: print(6 * 7)
checkpoints: ["before step 1", "before step 2", "before step 3"]
cleanup: {"ok"=>true, "checkpoints_dropped"=>3}

Because a rewind brings back running programs, the shell container id keeps working after it. actor: "agent" makes the audit log show these calls as an agent's. (This output is from a run against the v0.1.1 source; 0.1.2 changed only how simultaneous checkpoints pick ids, which this one-at-a-time loop never does. That run also did a 70-second silent run before clean-up, which is left out of the listing.)

Always clean up

A sandbox keeps running, and using memory, until it is deleted, even after your program exits. Put the work in begin … ensure right after the create, as above, so an exception (or Ctrl-C, which Ruby raises as Interrupt) still deletes it. For clean-up that must not itself fail:

def with_sandbox(k, name, **opts)
  k.create_sandbox(name: name, **opts)
  sb = k.sandbox(name)
  begin
    yield sb
  ensure
    begin
      sb.delete
    rescue Kosa8::NotFoundError
      # already gone (a failed rewind, or someone ran `kosa8 sandbox rm`)
    rescue Kosa8::Error => e
      warn "could not delete #{name}: #{e.message}"
    end
  end
end

with_sandbox(k, "doc-rb-job", egress: "deny") { |sb| sb.pull("alpine:latest"); puts sb.run("alpine:latest", ["true"]).ok? }
begin
  with_sandbox(k, "doc-rb-job2", egress: "deny") { |sb| raise "boom" }
rescue RuntimeError => e
  puts "raised #{e.message}; left: #{k.list_sandboxes.map { |x| x['name'] }.grep(/doc-rb-job/).inspect}"
end
true
raised boom; left: []

The second block raised inside the sandbox's block; the sandbox was still deleted, and the exception still reached the caller.

Leftovers from crashed runs show in k.list_sandboxes and k.list_snapshots (or kosa8 sandbox ls and kosa8 snapshot ls). Fork snapshots are named <sandbox>-fork-….

Install packages through an allow-list

k.create_sandbox(name: "doc-rb-py", egress_allow: ["pypi.org", "files.pythonhosted.org"])
sb = k.sandbox("doc-rb-py")
sb.pull("python:3.12-alpine")
shell = sb.start_container("python:3.12-alpine", ["sleep", "3600"])
r = sb.exec(shell, ["pip", "install", "requests"], timeout: 600, on_stdout: ->(b) { $stdout.write(b) })

Only the two package hosts are reachable; anything the package tries to fetch from elsewhere is refused and counted in sb.egress["stats"]. (This recipe was not run for this page; the allow-list behaviour it relies on was, above.)

Next

Errors and troubleshooting: every exception, with real messages, and what to do. API reference: every method and keyword. Securing an agent: the wider picture.