Guides / Ruby / API reference
Ruby API reference
Everything public in the kosa8 gem, version 0.1.2: every class, method,
keyword argument and default, what each returns and what it raises. Signatures are copied from the
source in lib/. Keys in returned hashes are the daemon's own JSON names, as strings.
Every signature was copied from lib/kosa8/*.rb at tag v0.1.2 of the
source (commit 2b5945e), and the gem published on RubyGems
(gem install kosa8 -v 0.1.2) was checked to contain the same files. The result shapes and timings quoted were printed by real
calls to a kosa8 daemon 0.9.36 on macOS 26.0.1 (Apple silicon), from the system Ruby 2.6.10,
with 0.1.1 or 0.1.2 where the behaviour changed in those versions and 0.1.0 where it did not. mounts:,
capture:, broker:, encrypt:, allow_tampered:
and approvals were not exercised. Differences from 0.1.0 are marked "since 0.1.1" or "since 0.1.2".
What this is, in plain words
This page is the dictionary. The quickstart and the developer guide show how the pieces fit together; this page lists every piece, with the exact names to type and the exact things you get back. You do not need to read it top to bottom: search it for the method you are calling.
A few words used throughout: the daemon is the kosa8 background service on your machine.
The gem sends it requests over a unix socket (a file that acts like a network port, only
reachable from your own computer). A keyword argument is a Ruby argument passed by name, as
in run(…, timeout: 30). A hash is Ruby's dictionary type; most methods return
the daemon's answer as one.
Kosa8::ClientThe constructor and every method, with its endpoint.
Kosa8::SandboxThe handle bound to one sandbox.
StructsExecResult, ExploreBranch, ExploreResult.
Result shapesThe hashes methods return.
TransportEndpoints, raw requests and frames.
Checkpoint storesCheckpointBook and the two stores.
ErrorsThe exception classes and their fields.
The gem
gem "kosa8", "~> 0.1.2" # Gemfile (or: gem install kosa8 -v 0.1.2) require "kosa8" # loads version, errors, transport, checkpoints, client
| Name | Value |
|---|---|
Kosa8::VERSION | "0.1.2" |
Kosa8::MAX_CHECKPOINTS_PER_SANDBOX | 10. Checkpoints kept per sandbox; past it the oldest is evicted. |
Kosa8::APPROVAL_HEADER | "X-Kosa8-Approval" |
Kosa8::ACTOR_HEADER | "X-Kosa8-Actor" |
Kosa8::AGENT_TOKEN_HEADER | "X-Kosa8-Agent-Token" |
Kosa8::BY_STATUS | Frozen hash from HTTP status to error class: 400, 401, 403, 404, 409, 501. |
Kosa8::TRANSIENT | The regexp explore uses to decide a branch failure is worth retrying (see explore). |
Ruby 2.6 or later (required_ruby_version >= 2.6). No dependencies: the gem uses
only the standard library (net/http, json, socket,
openssl, base64, uri, fileutils,
time). MIT licensed.
Kosa8::Client
Kosa8::Client.new(
socket_path: nil,
host: nil,
token: nil,
insecure: false,
actor: "tool",
agent_token: nil,
headers: {},
timeout: nil,
checkpoint_store: nil,
env: ENV
)
All arguments are keyword arguments. Constructing a client sends nothing: it works out where to
connect (and raises Kosa8::Error for a refused address or a bad actor)
and sets up the checkpoint store.
| Keyword | Default | Meaning |
|---|---|---|
socket_path: | ~/.kosa8/run/kosa8.sock | The daemon's unix socket. Used only when no host is set, here or in KOSA8_HOST. |
host: | ENV["KOSA8_HOST"] | A remote worker: https://…, http://…, tcp://h:p, h:p, or unix:///path. An empty string counts as not given. Rules under resolve_endpoint. |
token: | ENV["KOSA8_TOKEN"], only when the host came from KOSA8_HOST | Sent as Authorization: Bearer …, to a TCP endpoint only, never over a unix socket. |
insecure: | false (or KOSA8_INSECURE=1) | Allows plain http:// to a host that is not loopback. |
actor: | "tool" | "human", "agent" or "tool"; sent as X-Kosa8-Actor on every request and recorded in the audit log. Anything else raises Kosa8::Error (actor must be human, agent or tool, not "root"). |
agent_token: | ENV["KOSA8_AGENT_TOKEN"] | Agent identity, sent as X-Kosa8-Agent-Token when non-empty. A token the daemon does not know is refused with AuthError, never treated as anonymous. |
headers: | {} | Extra headers on every request. Set after Accept and X-Kosa8-Actor (so they can replace those), before Content-Type, X-Kosa8-Approval, X-Kosa8-Agent-Token and Authorization (which win when the gem sets them). |
timeout: | nil | Seconds. Sets Net::HTTP's read_timeout to this and open_timeout to the smaller of this and 30, for every request. A per-call timeout: overrides it. With nil: streaming calls (run, exec, exec_frames, attach) wait for output as long as the command runs (since 0.1.1; 0.1.0 cut them off after 60 silent seconds), and every other call keeps Net::HTTP's defaults, 60 seconds to connect and 60 per read. See Timeouts. |
checkpoint_store: | FileCheckpointStore.new | Where checkpoint stacks are kept. Any object with load and update { |data| … }; see Checkpoint stores. |
env: | ENV | Where KOSA8_HOST, KOSA8_TOKEN, KOSA8_INSECURE and KOSA8_AGENT_TOKEN are read. Pass {} to ignore the environment. |
| Reader | Type | Meaning |
|---|---|---|
transport | Kosa8::Transport | The HTTP layer. transport.endpoint.target says where requests go, such as unix:///Users/you/.kosa8/run/kosa8.sock. |
checkpoints | Kosa8::CheckpointBook | The checkpoint stack logic over checkpoint_store. (On a Sandbox handle, checkpoints is a different method: the list.) |
Conventions for every method
approval:Methods that listapproval: nilsend it, stripped, as theX-Kosa8-Approvalheader; a blank string sends nothing. Methods without it (health,version, thelist_*andsandbox_*readers,get_sandbox,list_checkpoints) send none.- Errors. Any method that talks to the daemon can raise
Kosa8::ConnectionError(daemon unreachable),Kosa8::TimeoutError(a connect or read timeout passed), and the HTTP error class matching the daemon's status. Only the extra errors are listed per method. - Paths below are shortened:
…is/kosa8/v1. Every sandbox, container and snapshot id put into a path is percent-encoded withKosa8.enc, soc/1is sent asc%2F1. - Query flags such as
wipe:are sent as?wipe=1whentrueand left out whenfalseornil. - Returned hashes are
JSON.parseoutput: string keys, Integer and Float numbers. Thelist_*methods andsandbox_images/sandbox_containersreturn[]when the daemon answers with an empty body ornull.
Daemon
| Signature | Returns | HTTP |
|---|---|---|
health | Hash: status, version, commit, go, uptime_seconds, phase | GET …/health |
version | health["version"], such as "0.9.36" (nil if absent) | GET …/health |
pull(ref, approval: nil) | The daemon's answer. Pulls into the host's shared engine, not into a sandbox; for a sandbox use pull_into_sandbox. | POST …/pull, body {"ref"} |
Sandboxes
| Signature | Returns | HTTP |
|---|---|---|
list_sandboxes | Array of sandbox records | GET …/sandboxes |
get_sandbox(id_or_name) | The record whose id matches, else whose name matches, else nil. Never raises for a missing sandbox. | GET …/sandboxes, filtered in the client |
create_sandbox(name: nil, cpus: nil, mem_mb: nil, disk_gb: nil, mounts: nil, egress: nil, egress_allow: nil, capture: nil, labels: nil, broker: nil, approval: nil) | Hash: sandbox (a record), boot_ms, and broker when one was asked for | POST …/sandboxes |
start_sandbox(sandbox, approval: nil) | Hash: boot_ms, sandbox, warning. Boots a stopped sandbox from its own disk; processes that were running are not brought back. | POST …/sandboxes/{id}/up |
stop_sandbox(sandbox, approval: nil) | Hash: ok, sandbox (state stopped), warning. The disk is kept; running processes end. | POST …/sandboxes/{id}/down |
delete_sandbox(sandbox, wipe: false, keep_checkpoints: false, approval: nil) | The daemon's answer ({"ok"=>true}) merged with "checkpoints_dropped" (an Integer) | DELETE …/sandboxes/{id} (?wipe=1). Then, unless keep_checkpoints, removes the sandbox's stack from the checkpoint store and sends DELETE …/snapshots/{ckpt} (with the same wipe) for each, ignoring their errors |
pull_into_sandbox(sandbox, ref, approval: nil) | Hash: image (the full reference, such as "docker.io/library/alpine:latest"), ok | POST …/sandboxes/{id}/pull, body {"ref"}. Fetched on the host and copied in, so it works with egress: "deny" |
sandbox_images(sandbox) | Array of image references (Strings) | GET …/sandboxes/{id}/images |
sandbox_containers(sandbox) | Array of container hashes | GET …/sandboxes/{id}/containers |
sandbox_egress(sandbox) | Hash: policy, stats, and more when they apply | GET …/sandboxes/{id}/egress |
create_sandbox keywords
| Keyword | Sent as | Notes |
|---|---|---|
name: | name | Also the sandbox's id. Without it the daemon picks one. Left out of the body when nil, as are cpus, mem_mb, disk_gb, mounts, labels and broker. |
cpus:, mem_mb:, disk_gb: | same names | Integers. Daemon 0.9.36 defaults seen in tests: 2 CPUs, 32 GB disk. |
egress: | egress | A String passed as is: "deny", "any", "fakenet", "allow:host,host". Or a hash { allow: [...] } (symbol or string key), turned into "allow:a,b" with each host stripped; an empty list raises Kosa8::Error (an egress allow-list needs at least one host; use egress: "deny" for no network). nil sends nothing and the daemon's default applies. |
egress_allow: | egress | An array of hosts; non-empty, it becomes "allow:a,b" and wins. Combined with an egress: other than nil or "deny" it raises Kosa8::Error before sending (egress_allow already limits the network to the hosts it lists; do not also set egress). An empty array is ignored. |
mounts: | mounts | Passed through unchanged. Not tested for this page; see Native HTTP API for what the daemon accepts. |
capture: | capture | Sent whenever it is not nil (so false is sent). |
labels: | labels | A hash, such as { "team" => "docs" }; checked against the org policy and echoed in the record. |
broker: | broker | Credential broker configuration, passed through. See Keep API keys out. |
Running code
| Signature | Returns | HTTP |
|---|---|---|
run(sandbox, image, args = [], env: nil, on_stdout: nil, on_stderr: nil, on_status: nil, timeout: nil, approval: nil) | ExecResult | POST …/sandboxes/{id}/run, body {"image","args","env"?}; answer is NDJSON frames. Starts a fresh container, waits for it to exit. The image must already be in the sandbox |
start_container(sandbox, image, args = [], approval: nil) | The container id (String), such as "k3-bea6352b" | POST …/sandboxes/{id}/start, body {"image","args"}. Runs detached |
exec(sandbox, container, args, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil) | ExecResult | POST …/sandboxes/{id}/exec, body {"container","args"}; NDJSON frames. No env:: exec takes none |
exec_frames(sandbox, container, args, timeout: nil, approval: nil) { |frame| … } | nil | POST …/sandboxes/{id}/exec. Yields every frame, including the final one, with data already base64-decoded. Does not raise on an error frame; you see {"done"=>true, "error"=>…} |
start_interactive(sandbox, image, args = [], env: nil, tty: false, approval: nil) | The container id | POST …/sandboxes/{id}/start-interactive, body {"image","args","env","tty"} (env is null when not given). Stdin is held open for write_stdin |
write_stdin(sandbox, container, data, approval: nil) | {"ok"=>true} | POST …/sandboxes/{id}/containers/{cid}/stdin, application/octet-stream. A String is sent with a length; an IO (anything with read) or any other Enumerable of chunks is sent chunked. Once per container per client: a second call raises Kosa8::Error before sending (since 0.1.1) |
attach(sandbox, container, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil) | ExecResult when the container exits | POST …/sandboxes/{id}/containers/{cid}/attach; NDJSON frames |
argsis converted withto_a. There is no shell unless you ask for one:["sh", "-c", "echo $HOME"].env:a hash ({ "A" => "1" }, sent as["A=1"]) or an array of"K=V"strings.- Callbacks (
on_stdout:,on_stderr:,on_status:) are anything withcall, such as a lambda. Each is called with one chunk, a binary String, as it arrives, on the calling thread.on_statusgets daemon status lines (such as image pulling) that are not part of stdout or stderr. Frames with a stream other thanstderrorstatuscount as stdout. timeout:seconds for this call, overriding the client's. It bounds the wait for each read from the daemon, not the whole run: a command that keeps printing can run longer. With no timeout anywhere, these calls wait as long as the command runs (since 0.1.1). A timeout stops the client waiting; it does not stop the command, which keeps running in the sandbox.- Exit codes. A non-zero exit is a result, not an exception:
exit_codeholds it. If the final frame carries no integerexit,exit_codeis0. - Extra errors for
run,execandattach:Kosa8::StreamErrorwhen the stream ends with an error frame (image not in the sandbox, no such container, executable not found) or with no final frame. Itsstdout/stderrhold the output so far. A line that is not valid NDJSON raisesKosa8::ProtocolError(since 0.1.1). An HTTP error before streaming starts raises the typed HTTP error. - One body is the whole of stdin. The daemon closes the process's stdin when the body of
a
write_stdinends. Since 0.1.1 the client remembers each[sandbox, container]it has written to, and a secondwrite_stdinfor the same pair raisesKosa8::Error(stdin of container … was already sent and is closed …) instead of the daemon's silent{"ok"=>true}(0.1.0). The pair is marked before the request is sent, so if that first request fails (daemon unreachable, say), the same client will refuse a retry; use a new container. A differentClientobject does not know about the first write. To stream input, pass one IO or Enumerator. - Your callbacks' exceptions come out unchanged (since 0.1.1). An exception raised in
on_stdout,on_stderr,on_statusor anexec_framesblock ends the call and is re-raised as the same object; 0.1.0 relabelled anIOErrorthere asConnectionError.
Snapshots and fork
| Signature | Returns | HTTP |
|---|---|---|
snapshot(sandbox, id: nil, encrypt: false, approval: nil) | Hash: snapshot (a snapshot record), snapshot_ms | POST …/sandboxes/{id}/snapshot, body {"id"?, "encrypt"?} (encrypt only when true). Captures memory, processes and disks; the sandbox keeps running. A taken id raises ConflictError |
list_snapshots | Array of snapshot records (every sandbox's) | GET …/snapshots |
restore_snapshot(snapshot_id, name: nil, label: nil, allow_tampered: false, approval: nil) | Hash: sandbox (the new record, with parent), restore_ms | POST …/snapshots/{id}/restore, body {"name"?, "label"?, "allow_tampered"?}. Boots a new sandbox with processes resumed |
delete_snapshot(snapshot_id, wipe: false, force: false, approval: nil) | Hash: encrypted, ok, removed | DELETE …/snapshots/{id} (?wipe=1, &force=1) |
fork(sandbox, n = 1, prefix: nil, label: nil, approval: nil) | Hash: count, fork_ms, forks (array of sandbox records), snapshot (the id of the fork's snapshot, a String) | POST …/sandboxes/{id}/fork, body {"n", "prefix"?, "label"?}. n is sent as given. With prefix: "p" the copies are named p1, p2, … |
In our tests, once every copy made by fork had been deleted, its
snapshot had gone too (delete_snapshot on it answered
NotFoundError).
explore
explore(sandbox, variants, image: "alpine:latest", keep_all: false, keep: nil, timeout: 120,
prefix: nil, pull: true, approval: nil) # => Kosa8::ExploreResult
Best-of-N, as kosa8 explore does it. In order:
variants.to_a; fewer than two raisesKosa8::Error(give at least two variants; with one there is nothing to compare) before anything is sent.- If
pull:pull_into_sandbox(sandbox, image). fork(sandbox, variants.length, prefix: …). The default prefix is"#{sandbox}-explore-#{Time.now.to_i % 100_000}". If fewer branches come back than variants, raisesKosa8::Error(forked N branches for M variants; see list_sandboxes for what was created); the branches made are not deleted.- One Ruby
Threadper branch runsrun(branch, image, ["sh", "-c", variant], timeout: timeout). A failure whose message matchesKosa8::TRANSIENT(connection refused, EOF, reset, broken pipe, "no such file or directory", stream ended, unreachable) is retried up to four attempts in all, sleeping 0.5, 1 and 1.5 seconds between them (since 0.1.1 there is no sleep after the last). ATimeoutErroris never retried. Branch failures are recorded in the branch'serror, not raised. - The winner is the successful branch (no error, exit 0) with the smallest
duration_ms, or""if none succeeded.keep:names a branch to keep instead; a name that is not a branch raisesKosa8::Error(keep X is not one of the branches (…); all of them were kept) and nothing is deleted. - Unless
keep_all, every branch other than the winner is deleted withDELETE …/sandboxes/{branch}(errors ignored, counted inremoved). With no winner, that is every branch. The parent sandbox is never touched.
One quirk worth knowing: TRANSIENT also matches "No such file or
directory". Because variants run through sh -c, a command missing from the image is
an ordinary exit 127 (in our test, "python3 -V" on Alpine came back as
exit=127 error=nil, no retry). But in an image with no sh at all, the run
fails with … not found in $PATH: No such file or directory, which the code treats as
transient and retries four times (read from the code; not reproduced).
Checkpoints
Checkpoints are kept on the client side, over snapshots, in the same file and format the kosa8
CLI uses (~/.kosa8/checkpoints.json), so kosa8 checkpoint ls and
kosa8 rewind see what the gem did and the other way round.
| Signature | Returns | How |
|---|---|---|
checkpoint(sandbox, label: "", approval: nil) | Hash: "checkpoint" (the stored record), "evicted" (ids, Array), "snapshot" (the snapshot answer) | Picks the next id <sandbox>-ckpt-<n>; POST …/sandboxes/{id}/snapshot with it (on a 409 "already exists", steps to the next id and retries, up to 10 attempts, since 0.1.2); once that exists, DELETE …/snapshots/{old} for each evicted id past 10 (errors ignored); then writes the record to the store |
list_checkpoints(sandbox) | Array of checkpoint records, oldest first | The store only; sends nothing |
rewind(sandbox, to: nil, approval: nil) | Hash: "sandbox" (the restored record), "checkpoint" (the target record), "dropped" (ids), "restore_ms" | DELETE …/sandboxes/{id}, then POST …/snapshots/{ckpt}/restore with {"name": sandbox}, then drops every later checkpoint from the store and sends DELETE …/snapshots/{later} for each (errors ignored) |
to:a checkpoint id or label. Labels match case-insensitively and the newest match wins. Without it, the latest checkpoint.- Extra errors:
Kosa8::CheckpointErrorfor no checkpoints (no checkpoints for "x" — take one with checkpoint or `kosa8 checkpoint x`), ato:that matches nothing (no checkpoint "nope" for sandbox "x"), or, incheckpoint, an invalid sandbox name (empty, starting with a dot, containing/or\). - A failed restore. The sandbox is deleted first, because the restored machine takes its
name. A refusal of that delete is raised as is and changes nothing. If the restore then fails,
rewindraisesKosa8::CheckpointErrorwith the messagerestore failed and NAME no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with restore_snapshot("ID", name: "NAME")(0.1.0 said "is now stopped", which was wrong), ande.causeis the daemon's error. At that point the sandbox is gone and the checkpoint's snapshot is intact; run the call the message names. - Concurrent checkpoints. The next id is read from the store before the snapshot is
taken and written after, so two
checkpointcalls on the same sandbox at the same moment (two threads, or the gem and the CLI) can pick the same id. Since 0.1.2, when the snapshot is refused with aConflictErrorwhose message contains "already exists", the call steps to the next number (ckpt-n+1) and tries again, up to 10 attempts in all; a 409 for any other reason, or the tenth refusal, is raised. The eviction list is the one computed before the first attempt. 0.1.0 raised on the first collision; 0.1.1 retried only whenlist_snapshotsalready showed the other snapshot, which often it did not.
Helpers
| Signature | Returns |
|---|---|
sandbox(sandbox_id) | A Kosa8::Sandbox bound to that id. Sends nothing, and does not check the sandbox exists. |
Kosa8::Client.egress_string(egress, allow) | The egress string create_sandbox would send, or nil. Raises as described above. |
Kosa8::Client.env_list(env) | nil, or ["K=V", …] from a hash, or env.to_a. |
Kosa8::Client.utf8(bytes) | A copy forced to UTF-8 with invalid bytes replaced (scrub). How ExecResult strings are made. |
Kosa8::Sandbox
Kosa8::Sandbox.new(client, id) # usually: client.sandbox("name")
sb.id # => "doc-rb-demo"
sb.client # => the Kosa8::Client
sb.inspect # => "#<Kosa8::Sandbox doc-rb-demo>"
Each method calls the client method with the sandbox id filled in and passes the remaining keyword arguments through, so the keywords, results and errors are the client's.
| Method | Calls |
|---|---|
info | get_sandbox(id) |
start(**kw) | start_sandbox(id, **kw) |
stop(**kw) | stop_sandbox(id, **kw) |
delete(**kw) | delete_sandbox(id, **kw): wipe:, keep_checkpoints:, approval: |
pull(ref, **kw) | pull_into_sandbox(id, ref, **kw) (not the client's pull) |
images | sandbox_images(id) |
containers | sandbox_containers(id) |
egress | sandbox_egress(id) |
run(image, args = [], **kw) | run(id, image, args, **kw) |
start_container(image, args = [], **kw) | start_container(id, image, args, **kw) |
exec(container, args, **kw) | exec(id, container, args, **kw) |
snapshot(**kw) | snapshot(id, **kw) |
fork(n = 1, **kw) | fork(id, n, **kw) |
explore(variants, **kw) | explore(id, variants, **kw) |
exec_frames(container, args, **kw) { |frame| … } | exec_frames(id, container, args, **kw) (since 0.1.1) |
start_interactive(image, args = [], **kw) | start_interactive(id, image, args, **kw) (since 0.1.1) |
write_stdin(container, data, **kw) | write_stdin(id, container, data, **kw) (since 0.1.1) |
attach(container, **kw) | attach(id, container, **kw) (since 0.1.1) |
checkpoint(**kw) | checkpoint(id, **kw) |
checkpoints | list_checkpoints(id) |
rewind(**kw) | rewind(id, **kw) |
Not on the handle: restore_snapshot, delete_snapshot,
list_snapshots (they are about snapshots, not this sandbox). Call them on
sb.client.
Structs
All three are Structs built with keyword_init: true, so they have
readers and writers for each member, to_h, and value equality.
Kosa8::ExecResult
ExecResult = Struct.new(:exit_code, :stdout, :stderr, keyword_init: true) r.exit_code # Integer r.stdout # String, UTF-8, invalid bytes replaced r.stderr # String, the same r.ok? # exit_code.zero?
Kosa8::ExploreBranch
ExploreBranch = Struct.new(:name, :variant, :exit, :output, :error, :duration_ms, keyword_init: true) b.name # the branch sandbox's id, such as "doc-rb-g2-e2" b.variant # the shell command it ran b.exit # Integer; -1 if it never ran b.output # stdout + stderr, trailing newline removed (chomp) b.error # the last error message, or nil b.duration_ms # Integer, including retries
Kosa8::ExploreResult
ExploreResult = Struct.new(:parent, :branches, :winner, :removed, keyword_init: true) x.parent # the sandbox explored x.branches # Array of ExploreBranch, in variant order x.winner # the kept branch's name, or "" when none succeeded x.removed # Integer: branches deleted
A real one, from three variants ("sleep 2; echo slow", "echo fast",
"echo broken; exit 1"):
"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>
Result shapes
Other methods return the daemon's JSON as plain hashes and arrays. The keys you will meet most, as kosa8 0.9.36 sent them in our tests:
| Shape | Keys |
|---|---|
| sandbox record | id, name, created, state (running, stopped, …), cpus, mem_mb, disk_gb, egress, ip, and when set parent, labels, mounts, capture, broker |
| snapshot record | id, sandbox, created, machine_id, mac, cpus, mem_mb, disk_gb, cmdline, size_bytes, state_path, disk_path, data_path, egress, and optional fields such as sealed, labels, encryption |
| checkpoint record | id, sandbox, label, created (UTC with nine fraction digits, such as 2026-11-03T06:46:25.129669000Z). Records written by the Go tools may also carry auto. |
| container | id, image, status, ip, and exit when known |
| egress status | policy, and stats: passed, denied, dropped, dns_answered, dns_refused |
| exec frame | {"stream"=>"stdout"|"stderr"|"status", "data"=>bytes}, then a final {"done"=>true, "ok"=>true, "exit"=>4} or {"done"=>true, "error"=>"…"} |
A real sandbox record, from create_sandbox(name: "doc-rb-guide", cpus: 2, mem_mb: 1024,
egress_allow: ["example.com"], labels: { "team" => "docs" }):
{"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"}}
Transport and endpoints
Kosa8::Transport
Kosa8::Transport.new(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool",
agent_token: nil, headers: {}, timeout: nil, env: ENV)
The same connection keywords as Client.new, without
checkpoint_store:. It opens a new connection for every request (no keep-alive), so
one transport can be used from several threads at once.
| Member | Meaning |
|---|---|
endpoint | The resolved Kosa8::Endpoint. |
raw(method, path, query: nil, body: nil, raw_body: nil, approval: nil, timeout: nil, stream: false) { |resp| … } | stream: true (what frames passes) sets no read timeout when none is given. Sends one request and yields the Net::HTTPResponse with its body unread; returns the block's value. body is sent as JSON; raw_body as application/octet-stream (a String with a length, an IO or Enumerable chunked). For a TCP endpoint the endpoint's base_path is put before path. Raises TimeoutError for Net::ReadTimeout/Net::OpenTimeout, and ConnectionError for any SystemCallError, IOError, SocketError or OpenSSL::SSL::SSLError, adding the "start it with `kosa8 daemon start`" hint for ENOENT and ECONNREFUSED. |
json(method, path, **kw) | raw, then the body parsed as JSON (nil for an empty body, the text itself if it is not JSON). A status of 400 or more raises Kosa8.error_from_response(…). |
frames(method, path, **kw) { |frame| … } | raw with stream: true, then yields one frame per NDJSON line, with data base64-decoded. An HTTP error before streaming raises the typed error; a line that is not JSON (or bad base64) raises ProtocolError. Returns nil. |
An exception raised by the block given to frames (and so by your callbacks) passes
through raw's network rescue unchanged: a Kosa8::Error is re-raised as
is, and any other StandardError is carried past the rescue inside the internal
Kosa8::CallbackFailure (an Exception subclass) and re-raised as the
original object. You never see CallbackFailure yourself. In 0.1.0 such an
IOError became a ConnectionError, and a malformed line raised
JSON::ParserError.
Kosa8.resolve_endpoint
Kosa8.resolve_endpoint(host: nil, socket_path: nil, token: nil, insecure: false, env: ENV) # => Kosa8::Endpoint
Where to connect, by the kosa8 CLI's rules:
- The address is
hostif it is non-empty, elseenv["KOSA8_HOST"], stripped of spaces and trailing slashes. Empty: the unix socket atsocket_pathorKosa8.default_socket_path. - The scheme is lower-cased.
unix:///pathis a socket (and wins oversocket_path).tcp://is read ashttp://. No scheme (host:port) is read ashttp://. Any other scheme raisesKosa8::Error(KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix://). An address with no host name raisesKosa8::Error(KOSA8_HOST "…" names no host). - Plain
httpto a host other thanlocalhost,::1or127.*raisesKosa8::Error(refusing to talk to 10.0.0.5:7440 over plain http: …) unlessinsecureorKOSA8_INSECURE=1. - The token is
tokenif non-empty; otherwiseenv["KOSA8_TOKEN"], but only when the address came fromKOSA8_HOST, never with an explicithost:. A path in the URL is kept as a prefix for every request.httpsuses Net::HTTP's default certificate verification.
Kosa8::Endpoint is a Struct (keyword_init) with
kind (:unix or :tcp), socket_path,
scheme, host, port, base_path,
token, and target: "unix:///path" or
"scheme://host:port" (the port always shown, as in
https://w.example.com:443).
Other module functions and classes
| Name | Meaning |
|---|---|
Kosa8.default_socket_path | File.join(Dir.home, ".kosa8", "run", "kosa8.sock") |
Kosa8.loopback?(host) | True for localhost, ::1 (with or without brackets) and 127.*. |
Kosa8.enc(s) | Percent-encodes one path segment; a space becomes %20 and / becomes %2F. |
Kosa8.decode_frame(hash) | Replaces a non-empty String "data" with its base64 decoding, in place, and returns the hash. |
Kosa8.validate_sandbox_name(name) | Returns name, or raises CheckpointError (invalid sandbox name "../etc"). |
Kosa8.parse_time(s) | Time.iso8601(s), or Time.at(0) if it cannot be parsed. Used to order checkpoints. |
Kosa8::UnixHTTP | A Net::HTTP subclass that connects to socket_path instead of a TCP port. Requests go to host kosa8. |
Kosa8::ChunkReader | An IO-like over an Enumerable of string chunks (read(len = nil, outbuf = nil)), used for chunked write_stdin bodies. |
Checkpoint stores
A store holds { "sandbox-name" => [checkpoint record, …] }. It needs two methods:
load (returns the data) and update { |data| … } (runs the block on the
data, saves what it leaves, and returns the block's value).
| Class | Behaviour |
|---|---|
Kosa8::FileCheckpointStore.new(path = nil, lock_timeout: 10) | path and lock_timeout (readers). path defaults to ~/.kosa8/checkpoints.json, the CLI's own file. load returns {} when the file is missing or not valid JSON (other errors, such as Errno::EACCES, are raised as they are). update takes a Mutex in the process, creates the directory (mode 0700) if needed, takes an exclusive flock on checkpoints.json.lock (the CLI's lock, so the two never interleave), reloads, runs the block, writes pretty JSON to checkpoints.json.tmp (mode 0600) and renames it over the file. The lock is tried without blocking every 50 ms for up to lock_timeout seconds (since 0.1.1; 0.1.0 waited for ever), then CheckpointError is raised: timed out after 10s waiting for …/checkpoints.json.lock; another kosa8 process is holding it. |
Kosa8::MemoryCheckpointStore.new | Keeps stacks in memory, under a Mutex, deep-copied with Marshal on every load and update. Nothing is shared with the CLI or other processes, and nothing survives the process. Use it in tests. |
Kosa8::CheckpointBook.new(store) | The stack logic, as client.checkpoints. See below. |
CheckpointBook method | Returns |
|---|---|
next_id(sandbox) | [id, evict]: the next "<sandbox>-ckpt-<n>", with n one past the larger of the stack's length and the highest number used, and the oldest ids to evict to stay at 10. Validates the name. |
record(id, sandbox, label, evicted = []) | The new record {"id", "sandbox", "label", "created"}, appended after removing evicted. created is Time.now.utc.iso8601(9). No auto key is written. Validates the name. |
list(sandbox) | Records sorted by created, oldest first (ties keep file order). |
latest(sandbox) | The newest record, or raises CheckpointError. |
resolve(sandbox, ref) | The newest record whose id equals ref or whose label equals it ignoring case, or raises CheckpointError. |
pop(sandbox, id) | Keeps the stack up to and including id; returns the ids removed after it. |
forget(sandbox) | Removes the sandbox's stack; returns its ids. |
CheckpointBook.seq(id, sandbox) | The number in "<sandbox>-ckpt-<n>", or 0. |
Errors
StandardError
└─ Kosa8::Error
├─ Kosa8::ConnectionError
├─ Kosa8::TimeoutError
├─ Kosa8::StreamError #stdout, #stderr
├─ Kosa8::CheckpointError
├─ Kosa8::ProtocolError a frame that is not NDJSON (since 0.1.1)
└─ Kosa8::ApiError any HTTP error status without a closer class (500, 503, …)
├─ Kosa8::BadRequestError 400
├─ Kosa8::AuthError 401
├─ Kosa8::PolicyError 403
│ └─ Kosa8::ApprovalRequiredError 403 with an approval refusal; #approval
├─ Kosa8::NotFoundError 404
├─ Kosa8::ConflictError 409
└─ Kosa8::UnsupportedError 501
| Reader | On | Meaning |
|---|---|---|
message | all | For a daemon error: "<error> (<remediation>)", as the CLI prints it. |
status | ApiError and below | The HTTP status (Integer); nil elsewhere. |
daemon_message | daemon errors, stream error frames | The daemon's own text. |
remediation | daemon errors | The daemon's advice, when it gave one. |
http_method, path | daemon, connection, timeout and protocol errors | The request, such as "GET" and "/kosa8/v1/health". Named http_method since 0.1.1; in 0.1.0 it was method, which hid Ruby's Object#method. The constructor keyword is still method:. |
body | daemon errors | The parsed response body. |
stdout, stderr | StreamError | Output produced before the failure (UTF-8 Strings). |
approval | ApprovalRequiredError | The daemon's refusal hash: why and its other fields. |
Kosa8::Error.new(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil)
Kosa8::ApprovalRequiredError.new(message = nil, approval: {}, **kw)
Kosa8::StreamError.new(message = nil, stdout: "", stderr: "", **kw)
Kosa8.error_from_response(status, body, method:, path:) # => the typed error (not raised)
error_from_response builds the error the client raises for an HTTP status of 400
or more. daemon_message is the body's error if it is a non-empty String,
else its message; for a text body, the stripped text. remediation is the
body's non-empty remediation. The message is "<daemon_message>
(<remediation>)", the daemon message alone, or "request failed with HTTP
<status>". A 403 whose body has an approval hash with a String
why becomes ApprovalRequiredError; other statuses use
Kosa8::BY_STATUS, falling back to ApiError.
Kosa8::TimeoutError is the gem's own class, not Ruby's Timeout::Error,
and does not inherit from it. Real messages and how to handle each class:
Ruby errors and troubleshooting.