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.
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:
| Word | Plain meaning |
|---|---|
| daemon | The kosa8 background service on your machine. It does the work; the gem sends it requests. |
| sandbox | One virtual computer (a microVM). It has a name, such as doc-rb-demo. |
| image and container | Programs run inside a sandbox as containers, started from an image (a packaged set of files, such as alpine:latest, a tiny Linux). |
| egress | Network traffic going out of the sandbox. You decide whether any is allowed, and to where. |
| snapshot and checkpoint | A saved copy of a running sandbox: memory, running programs and disk. A checkpoint is a snapshot kept on a per-sandbox "undo" list. |
| fork | Making 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:
| Variable | Keyword | Effect |
|---|---|---|
KOSA8_HOST | host: | 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_TOKEN | token: | 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_INSECURE | insecure: | 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_TOKEN | agent_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:
| Call | What happens |
|---|---|
sb.stop | Shuts the machine down; disk kept. Answered {"ok"=>true, "sandbox"=>{… "state"=>"stopped" …}, "warning"=>""}. |
sb.start | Boots 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.delete | Removes 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_sandboxes | Every 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 pass | Sent as | Meaning |
|---|---|---|
egress: "deny" | deny | No network at all. |
egress_allow: ["pypi.org", "github.com"] | allow:pypi.org,github.com | Only these hosts; the safe way to let an agent install packages. |
egress: { allow: [...] } | allow:… | The same, as a hash. |
egress: "any" | any | Anything. An org policy may forbid or require approval for it. |
egress: "fakenet" | fakenet | A 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:
- Arguments are not a shell command.
["echo", "$HOME"]prints$HOMEliterally. For pipes, variables and&&, run["sh", "-c", "…"]. - Callbacks get each chunk as it arrives, as a binary String, on the thread that called
run. Chunks are whatever the daemon sent, not lines.on_statusgets the daemon's progress lines; none came here because the image was already present. The collectedstdout/stderrin the result are UTF-8, with invalid bytes replaced. - Env is a hash or an array of
"K=V"strings. Onlyrunandstart_interactivetake it. - Exit codes are results.
r.exit_codeandr.ok?tell you how the command ended. An exception means the command could not run at all (see StreamError). - On the wire:
POST /kosa8/v1/sandboxes/{id}/runwith{"image","args","env"}, answered with one JSON frame per line:{"stream":"stdout","data":"<base64>"}… and a final{"done":true,"ok":true,"exit":0}.
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:
- "Successful" means exit code 0 and no error; the winner is the one with the smallest
duration_ms.e3was quicker but failed, so it lost. - Each variant runs as
sh -c VARIANTinimage:(defaultalpine:latest, pulled first unlesspull: false), with atimeout:of 120 seconds per read by default. - Branches run in Ruby threads, one per variant. A freshly made copy can refuse its first connection, so connection-level failures are retried, four attempts in all.
keep_all: truedeletes nothing;keep: "name"keeps that branch instead of the fastest. If nothing succeeds, the winner is""and every branch is deleted.- The parent sandbox is never changed. Exact rules, and two quirks, are in the API reference.
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 stack per sandbox. Ids are
<sandbox>-ckpt-<n>, numbered past the highest used. Each is a real snapshot with that id. At most 10 are kept (Kosa8::MAX_CHECKPOINTS_PER_SANDBOX): the eleventh evicts the oldest, whose snapshot is deleted only once the new one exists.checkpointreturns the record, the evicted ids and the snapshot answer. - Rewind goes to the latest checkpoint, or to
to:, an id or a label (labels ignore case:"BASE"foundbase). It deletes the sandbox, restores the checkpoint's snapshot under the same name, and drops every checkpoint taken after the target, deleting their snapshots, because they describe a future that no longer happened. The target itself stays, so you can rewind to it again. - The file and its lock. Every change rereads the file, edits it and writes it back
atomically (a temporary file, mode 0600, renamed into place), under an exclusive
flockon~/.kosa8/checkpoints.json.lock, the same lock the CLI takes. So the gem and the CLI never overwrite each other's changes. Inside one process aMutexserialises threads too. The lock is waited for up to 10 seconds, thenCheckpointErroris raised (timed out after 10s waiting for …/checkpoints.json.lock; another kosa8 process is holding it); change it withFileCheckpointStore.new(path, lock_timeout: 30). (0.1.0 waited for ever.) A missing or corrupt file reads as empty rather than failing. - Clean-up.
deleteremoves the sandbox's stack and its snapshots ("checkpoints_dropped"=>1above), unlesskeep_checkpoints: true. - Other stores.
Kosa8::Client.new(checkpoint_store: Kosa8::FileCheckpointStore.new("/path/checkpoints.json"))keeps a separate file (no longer shared with the CLI).Kosa8::MemoryCheckpointStore.newkeeps them in memory, for tests. Any object withloadandupdate { |data| … }works.
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:
- The client keeps no connection open: every request opens its own socket connection, so one
Kosa8::Clientcan be used from many threads. Ruby's global lock does not get in the way, because the threads spend their time waiting on the socket. - Callbacks run on the thread that made the call. If several threads print, guard shared
state yourself (a
Mutexor aQueue). - Checkpoints of the same sandbox taken at the same moment can pick the same next id.
Since 0.1.2 the one the daemon refuses steps to the next number and tries again (up to 10
times), so all of them succeed. Three threads, twice, against v0.1.2:
2.times do |round| rs = 3.times.map { |i| Thread.new { sb.checkpoint(label: "r#{round}t#{i}")["checkpoint"]["id"] } }.map(&:value) puts "round #{round}: #{rs.inspect}" end puts "stack: #{sb.checkpoints.map { |c| c['id'] }.inspect}"round 0: ["doc-rb-race-ckpt-3", "doc-rb-race-ckpt-2", "doc-rb-race-ckpt-1"] round 1: ["doc-rb-race-ckpt-6", "doc-rb-race-ckpt-4", "doc-rb-race-ckpt-5"] stack: ["doc-rb-race-ckpt-1", "doc-rb-race-ckpt-2", "doc-rb-race-ckpt-3", "doc-rb-race-ckpt-4", "doc-rb-race-ckpt-5", "doc-rb-race-ckpt-6"]
The test also logged each refusal: six 409s were resolved this way. In 0.1.0 the loser raisedConflictError; 0.1.1 retried only when the winner's snapshot already showed inlist_snapshots, which it often did not yet, and in our test one of three threads still failed. Different sandboxes never collide. explorestarts its own threads; its result is ready when it returns.
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:
- Streaming calls (
run,exec,exec_frames,attach) have no read timeout, since 0.1.1: they wait as long as the command runs, however long it is silent. - Every other call keeps Net::HTTP's defaults: 60 seconds to connect and 60 seconds for each read. Creating a sandbox, snapshots, forks and restores usually answer within seconds.
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
- A timeout stops the waiting, not the command. After the 3-second timeout, the
sleep 30container was still listed asrunning(the other running one is the example's long-lived shell). The daemon has no way to cancel a run in progress. Stop it by deleting or rewinding the sandbox, or make commands end on their own (timeout 30 cmdinside the sandbox). - In 0.1.0, streaming calls kept the 60-second default, so the 70-second silent run
above failed with
TimeoutErrorat 60 s unless you passed a largertimeout:. If you must run on 0.1.0, pass one, such asKosa8::Client.new(timeout: 3600). Kosa8::TimeoutErroris the gem's own class, not Ruby'sTimeout::Error.
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.)
Errors and troubleshooting: every exception, with real messages, and what to do. API reference: every method and keyword. Securing an agent: the wider picture.