Guides / Java / Developer guide
Java developer guide
Each section starts with what the feature is for, in plain words, then goes into exactly what the SDK does: which HTTP call it makes, what comes back, what can go wrong. The quickstart is the five-minute version; the API reference lists every signature.
This page describes SDK 0.1.2 (com.kosa8:kosa8:0.1.2 on Maven Central; see the
quickstart). The capture and mount examples, the only code
0.1.2 changed, were run on 0.1.2 built from the v0.1.2 tag (commit 14e5fc6) with mvn install, because Maven Central was still answering 404 for 0.1.2 while it was being published. Every block of output on this page was printed by
a real program run against kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1) on OpenJDK
21.0.12.1. Everything that changed in 0.1.1 (mounts, capture, the stdin and exec-env checks, the
Sandbox handle's new methods, concurrent checkpoints, the lock, the redacted
Endpoint) was run on 0.1.1, built from its release tag just before it was published.
The quickstart was re-run on the 0.1.1 jar from Maven Central. Output from code paths that 0.1.1 did not
change was first recorded on 0.1.0 from Maven Central. The agent loop, the shared-checkpoint
example and the run/exec/stdin/frames examples were then re-run on 0.1.1, and they behaved the
same apart from timings and container ids. Long JSON answers are shortened where marked with
…; nothing else was edited. Timings come from a machine that was busy with other
sandboxes. Where a statement comes from reading the SDK's source rather than from a run (signed
approvals, encrypted snapshots, checkpoint eviction at 10, remote workers over https), the text
says so.
What this is, in plain words
kosa8 gives a program a sandbox: a small virtual computer, with its own operating system and its own disk, that runs on your machine. You decide whether it may use the network, and which websites. Code inside it cannot reach your files or your passwords. You can save it at any moment and go back to that moment later, or make copies of it and try different things in each.
The Java SDK is a library that lets a Java program drive all of that. It is a thin layer over the daemon's HTTP API: each method sends one request (a few send several) and gives back the answer. It has no dependencies, so adding it to a project adds one jar and nothing else.
The pieces
| Class | In plain words | In detail |
|---|---|---|
Kosa8Client | Your connection to kosa8. | Built with Kosa8Client.builder()…build(). Every operation is a method on it, taking the sandbox name or id as the first argument. Final class, immutable after build(). |
Sandbox | A shortcut for one sandbox. | client.sandbox("name"). The same methods without the first argument. It holds only the client and the name; creating one sends nothing and does not check that the sandbox exists. |
Options.* | The settings for one call. | Small builders: new Options.Create().name("x").egress("deny"). Each setter returns the same object. Fields you do not set are not sent, so the daemon's defaults apply. null wherever an options argument is accepted means "no options". |
Results.* | What came back. | Java records: Exec, Fork, Explore, ExploreBranch, CheckpointSaved, Rewind. Calls with no record of their own return the daemon's JSON as Map<String, Object>. |
Checkpoint | One saved restore point. | Record (id, sandbox, label, auto, created). |
Kosa8Exception | Something went wrong. | Unchecked (extends RuntimeException), with nested subclasses per cause. See Errors and troubleshooting. |
JSON in Java. The SDK carries its own small JSON codec (com.kosa8.Json, public).
Objects decode to Map<String, Object> (a LinkedHashMap, so key order is
the daemon's), arrays to List<Object>, whole numbers to Long, other
numbers to Double, and true/false to Boolean. So
created.get("boot_ms") is a Long, and nested objects need a cast:
@SuppressWarnings("unchecked")
Map<String, Object> info = (Map<String, Object>) created.get("sandbox");
// or, without the warning:
if (created.get("sandbox") instanceof Map<?, ?> m) System.out.println(m.get("state"));
Connecting
In plain words. Most of the time kosa8 runs on the same machine as your program, and
Kosa8Client.builder().build() is all you need. If kosa8 runs on another machine (a
"worker"), you give the client its address and a password-like token.
In detail. Java's built-in java.net.http.HttpClient cannot talk over a unix
socket, so the SDK writes HTTP/1.1 itself: over a UnixDomainSocketAddress for the local
daemon, or a plain or TLS Socket for a worker. Each request opens a new connection and
sends Connection: close; there is no pool and nothing to shut down.
build() resolves where to connect and checks the settings; it sends nothing.
Where requests go
| You set | The client talks to |
|---|---|
| nothing | The unix socket ~/.kosa8/run/kosa8.sock (Endpoint.defaultSocketPath(), from the user.home system property). |
socketPath("/path/kosa8.sock") | That socket. Ignored if a host is set here or in KOSA8_HOST. |
host(…) or KOSA8_HOST | A worker. host(…) wins over the variable. Forms: https://h[:port][/base], http://h[:port], tcp://h:port (same as http://), unix:///path (a socket), or a bare h:port (taken as http://). Trailing slashes are dropped; a path after the host becomes a prefix on every request. |
System.out.println(Kosa8Client.builder().host("https://worker.example.com/k8/").token("t").build().endpoint());
System.out.println(Kosa8Client.builder().host("tcp://127.0.0.1:7070").build().endpoint().target());
Endpoint[https://worker.example.com:443/k8, token=***] http://127.0.0.1:7070
Endpoint.toString() never prints the token, only token=*** when one is
set (0.1.0 printed it). The token itself is still available as endpoint().token().
Settings and environment variables
| Builder method | Environment variable | Default | What it does |
|---|---|---|---|
socketPath(String) | none | ~/.kosa8/run/kosa8.sock | The local daemon's socket. |
host(String) | KOSA8_HOST | none (local socket) | A remote worker, forms above. |
token(String) | KOSA8_TOKEN | none | Sent as Authorization: Bearer …, only to a network endpoint, never over a unix socket. The variable is read only when the host also came from KOSA8_HOST: if you call host(…), call token(…) too. |
insecure(boolean) | KOSA8_INSECURE=1 | false | Allows plain http:// to a host that is not loopback (localhost, ::1, 127.*). Without it, build() refuses, because the token would cross the network unencrypted. |
actor(String) | none | "tool" | human, agent or tool, sent as X-Kosa8-Actor and recorded in the audit log. Anything else makes build() throw. |
agentToken(String) | KOSA8_AGENT_TOKEN | none | Sent as X-Kosa8-Agent-Token, for agent identity. |
headers(Map) | none | none | Extra header lines on every request, written after X-Kosa8-Actor and before the approval, agent-token and authorization headers. They are added, not substituted: repeating a header the SDK sets sends it twice. |
timeout(Duration) | none | none | A time limit for every request; see Timeouts. |
checkpoints(CheckpointStore) | none | the CLI's ~/.kosa8/checkpoints.json | Where checkpoint stacks are kept; see Checkpoints. |
getenv(Function) | none | System::getenv | Where the variables above are read from. For tests. |
// The variables can come from anywhere; here, a map.
Map<String, String> env = Map.of("KOSA8_HOST", "worker.internal:9000", "KOSA8_INSECURE", "1", "KOSA8_TOKEN", "sekret");
System.out.println(Kosa8Client.builder().getenv(env::get).build().endpoint());
Endpoint[http://worker.internal:9000, token=***]
A network connection is given 30 seconds to connect (fixed in the SDK); https uses the JVM's
default SSLSocketFactory, so the JVM's trust store decides which certificates are
accepted. Remote workers were not exercised for this page; the forms above were checked with
endpoint() only.
Is the daemon there?
System.out.println("health " + k.health());
health {commit=f6124c0, go=go1.26.9, phase=0-foundations, status=ok, uptime_seconds=4328, version=0.9.36}
version() is health().get("version") as a string.
Sandboxes
In plain words. Creating a sandbox boots a small virtual machine. You choose how many processor cores and how much memory it gets, and what network it may use. It keeps running until you stop or delete it. Stopping keeps its disk; deleting removes everything.
Map<String, Object> c = k.createSandbox(new Options.Create().name("doc-java-guide").cpus(2).memMb(1024)
.egressAllow(List.of("example.com")).labels(Map.of("team", "docs")));
System.out.println("create " + c);
Sandbox sb = k.sandbox("doc-java-guide");
System.out.println("info " + sb.info());
System.out.println("egress " + sb.egress());
create {boot_ms=10666, sandbox={id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}}}
info {id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}}
egress {policy=allow:example.com, stats={passed=3, denied=0, dropped=2, dns_answered=2, dns_refused=0}}
createSandbox blocks until the machine has booted. If you leave out the name, read
the one the daemon chose from c.get("sandbox"). labels are your own free-form
tags; they are not the classification label (CUI and so on) that kosa8 sandbox
create --label sets, which Options.Create has no setter for.
Options.Create
| Setter | Sent as | Meaning |
|---|---|---|
name(String) | name | The sandbox's name and id. A name already in use is a ConflictException (409). |
cpus(int) | cpus | Virtual CPUs (the run above shows the daemon's default of 2). |
memMb(int) | mem_mb | Memory in MiB. |
diskGb(int) | disk_gb | Disk size in GB (default seen: 32). |
mounts(List<String>) | mounts, as {host_path, guest_path, read_only} objects | Host folders shared in, each host:guest, host:guest:ro or host:guest:rw. Both paths must be absolute: the host path as Path.isAbsolute() sees it, the guest path starting with /. Any other shape, or a relative path, throws Kosa8Exception before sending (the absolute-path check is new in 0.1.2). Mounted paths are visible inside containers too (example). Broken in 0.1.0, which sent the strings as they were and got 500 mount has no guest path. |
egress(String) | egress | deny, any, fakenet or allow:host,host. See Network rules. |
egressAllow(List<String>) | egress as allow:… | Only these hosts. Each is trimmed and joined with commas. Combined with any egress other than deny, the SDK throws a plain Kosa8Exception before sending anything. |
capture(String hostFile) | capture as {"path": …} | Record every frame the sandbox sends or receives, on the host, as one pcapng file at that path (opens in Wireshark); the folder must exist (example). An empty string throws capture needs a host file path to write the capture to. The file holds everything sent in the clear; treat it as secret. |
capture(boolean) | nothing | Deprecated. capture(true) throws Kosa8Exception before sending, because the daemon needs a path (in 0.1.0 it reached the daemon and failed with a 500). The message is quoted on the errors page. |
labels(Map<String,String>) | labels | Your own tags; they come back in listSandboxes(), which makes clean-up easy (recipe). |
broker(Object) | broker | The credential-broker configuration, passed through as JSON. See Keep API keys out. |
approval(String) | X-Kosa8-Approval header | A signed approval, when a policy gates creation. See Approvals. |
The network rule, seen from inside
Results.Exec net = sb.run("alpine:latest", List.of("sh", "-c",
"wget -q -T 5 -O- http://example.com | head -c 60; echo; wget -q -T 5 -O- http://pypi.org >/dev/null && echo pypi ok || echo pypi blocked"));
System.out.println("net: " + net.stdout());
net: <!doctype html><html lang=en><head><meta charset=utf-8><link pypi blocked
The allowed host answered; the other did not. The rule is enforced by the host, outside the virtual machine, so nothing inside can change it.
Sharing a folder, and recording traffic
Map<String, Object> c = k.createSandbox(new Options.Create().name("doc-java-012").egress("deny")
.mounts(List.of(dir + "/mnt:/work:ro")).capture(dir + "/traffic.pcapng"));
System.out.println("create " + c);
sb.pull("alpine:latest");
System.out.println("mount from a container: " + sb.run("alpine:latest",
List.of("sh", "-c", "cat /work/hello.txt 2>&1; ls / | tr '\n' ' '")).stdout());
create {boot_ms=13255, sandbox={id=doc-java-012, …, mounts=[{host_path=/private/tmp/…/p12/mnt, guest_path=/work, read_only=true}], egress=deny, capture=/private/tmp/…/p12/traffic.pcapng, ip=192.168.127.2}}
mount from a container: hello from the host
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var work
$ file p12/traffic.pcapng
p12/traffic.pcapng: pcapng capture file - version 1.0
The host folder mnt (holding hello.txt) appeared read-only at
/work in a fresh container. The capture path became a 2.7 kB pcapng file, owner-only
(mode 0600), once the sandbox was gone. Mount strings are checked before sending:
mount "/tmp": want host:guest or host:guest:ro mount "mnt:/work": both paths must be absolute (use Path.toAbsolutePath() for the host side) mount "…/p12/mnt:work": both paths must be absolute (use Path.toAbsolutePath() for the host side)
To use a folder relative to your program, build the host side with
Path.of("mnt").toAbsolutePath().
Lifecycle
System.out.println("stop: " + sb.stop());
System.out.println("state: " + sb.info().get("state"));
Map<String, Object> up = sb.start();
System.out.println("start: state=" + ((Map<?, ?>) up.get("sandbox")).get("state") + " boot_ms=" + up.get("boot_ms"));
stop: {ok=true, sandbox={id=doc-java-sweep, name=doc-java-sweep, created=2026-11-03T01:51:31.468078-05:00, state=stopped, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, labels={owner=doc-java}}, warning=}
state: stopped
start: state=running boot_ms=743
| Call | HTTP | Notes |
|---|---|---|
listSandboxes() | GET /kosa8/v1/sandboxes | Every sandbox, as maps. |
getSandbox(idOrName) / sb.info() | the same list | Filtered in the client: first by id, then by name. null when there is none; it does not throw. |
stopSandbox / sb.stop() | POST …/down | Shuts the machine down; the disk is kept. Running processes are lost. |
startSandbox / sb.start() | POST …/up | Boots a stopped sandbox from its disk. On a sandbox that does not exist, kosa8 0.9.36 answers 500, so this is a plain ApiException, not NotFoundException. |
deleteSandbox / sb.delete() | DELETE … | Then removes the sandbox's checkpoint records and their snapshots (below). |
What delete does, step by step. It sends DELETE /kosa8/v1/sandboxes/{id}
(with ?wipe=1 if you asked for Options.Delete().wipe(true), which
overwrites the disks instead of only unlinking them). If that fails, the exception is thrown and
nothing else happens. If it succeeds, and you did not ask for keepCheckpoints(true),
the SDK removes the sandbox's stack from the checkpoint store and deletes each of those
snapshots, ignoring individual failures. The answer is the daemon's map plus
checkpoints_dropped, the number of records removed.
Running code
In plain words. Inside a sandbox, programs run in containers, the same kind Docker uses. You can run a command and wait for it, keep a container alive and run several commands in it, feed a program input, or watch output as it happens.
First, an image. A container needs an image, and the image must be inside the sandbox.
sb.pull(ref) (pullIntoSandbox) fetches it on the host and copies it in, so it
works even with the network denied. k.pull(ref) pulls into the host's shared engine
instead, not into any sandbox. sb.images() lists what a sandbox holds:
pull {image=docker.io/library/alpine:latest, ok=true}
images [docker.io/library/alpine:latest]
Running an image that was never pulled does not fail with a 404. The daemon starts the
stream and then ends it with an error, so it is a StreamException:
unpulled: com.kosa8.Kosa8Exception$StreamException status=0: image "docker.io/library/python:3.12-alpine" not found in engine: image "docker.io/library/python:3.12-alpine": not found
The four ways to run
| Method | Use it when | Returns |
|---|---|---|
run(sandbox, image, args, Options.Run) | One command in a fresh container; you want its output when it ends. | Results.Exec |
startContainer(sandbox, image, args) then exec(sandbox, container, args, Options.Run) | Several commands that share files or running processes. | a container id, then Results.Exec per command |
startInteractive, writeStdin, attach | A program that reads its input from stdin. | a container id, {ok=true}, then Results.Exec when it exits |
execFrames(sandbox, container, args, Consumer<Frame>, Options.Approval) | You want the raw stream, frame by frame, including the final one. | nothing; your consumer sees every frame |
Results.Exec is (int exitCode, byte[] stdoutBytes, byte[] stderrBytes)
with stdout() and stderr() decoding as UTF-8, and ok() meaning
exitCode == 0. A non-zero exit code is a result, not an exception. The call
throws only if the command could not run or the stream broke.
Environment variables and live output
Results.Exec r = sb.run("alpine:latest",
List.of("sh", "-c", "echo GREETING=$GREETING; echo oops >&2; for i in 1 2 3; do echo tick $i; sleep 0.3; done; exit 0"),
new Options.Run().env(Map.of("GREETING", "hello"))
.onStdout(b -> System.out.print("[out] " + new String(b, StandardCharsets.UTF_8)))
.onStderr(b -> System.out.print("[err] " + new String(b, StandardCharsets.UTF_8)))
.onStatus(b -> System.out.print("[status] " + new String(b, StandardCharsets.UTF_8))));
System.out.println("run exit=" + r.exitCode() + " ok=" + r.ok()
+ " stdout=" + r.stdout().replace("\n", "\\n") + " stderr=" + r.stderr().replace("\n", "\\n"));
[err] oops [out] GREETING=hello tick 1 [out] tick 2 [out] tick 3 run exit=0 ok=true stdout=GREETING=hello\ntick 1\ntick 2\ntick 3\n stderr=oops\n
Three things to notice, all real behaviour:
- Callbacks get chunks, not lines. The first stdout chunk held two lines. If you need lines, buffer and split yourself.
- Order across streams is not kept. The script wrote to stdout first, yet the stderr chunk arrived first; in a re-run on 0.1.1 it came second. Within one stream the order is kept.
- The full output is still collected. The callbacks are extra;
r.stdout()has everything. Callbacks run on the thread that calledrun, between reads, so a slow callback slows the stream down.
env takes a Map or a List of KEY=VALUE
strings. Only run can use it. exec with a non-empty env throws
Kosa8Exception before sending, because the daemon's exec takes no environment (0.1.0
silently dropped it):
exec env: exec does not take env: the container keeps the environment it was started with. Set it at startInteractive, or wrap the command: List.of("env", "K=V", "cmd")
attach ignores it. For exec, put variables in the command (env KEY=V cmd) or set them when
starting the container with startInteractive(…, new Options.Interactive().env(…)).
onStatus receives frames on the status stream (pull and progress lines);
this run produced none.
A container that stays up
String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity"));
Results.Exec r = sb.exec(cid, List.of("sh", "-c", "echo one > /f; cat /f"), null);
System.out.println(sb.containers());
startContainer returns as soon as the container is started, with its id (such as
k3-745c8bcf). containers() lists every container with its
id, image, status (running,
exited) and ip. An exec into a container that does not exist is a
StreamException (no such container: nope), for the same reason as an
unpulled image.
Feeding stdin
String ic = sb.startInteractive("alpine:latest", List.of("sh", "-c", "tr a-z A-Z"), null);
System.out.println("stdin " + sb.writeStdin(ic, "hello stdin\n".getBytes(StandardCharsets.UTF_8), null));
try {
sb.writeStdin(ic, "again\n".getBytes(StandardCharsets.UTF_8), null);
} catch (Kosa8Exception e) {
System.out.println("second stdin: " + e.getMessage());
}
System.out.println("attach " + sb.attach(ic, null).stdout().strip());
stdin {ok=true}
second stdin: stdin of container k3-d6cc9b51 in doc-java-011 was already sent and is closed: the daemon treats one writeStdin body as the whole of stdin. Pass an InputStream to stream input
attach HELLO STDIN
startInteractive starts a container with stdin held open (with
Options.Interactive().tty(true) it gets a terminal). writeStdin sends the
bytes; when the body ends, the program's stdin is closed, which is why tr finished.
One body is the whole of stdin, so a second writeStdin to the same container from the
same client throws, as above (0.1.0 answered ok for input the program never saw). The check is kept
per client, in memory: a second client, or a new JVM, is not stopped by it, and the daemon still
discards such input. To feed input over time, pass one InputStream that produces bytes as
you go, such as a PipedInputStream.
attach follows the container's output until it exits. writeStdin also takes
an InputStream, which it sends with chunked transfer encoding, 16 KiB at a time, until
the stream returns end-of-file (the byte-array form was the one run here). Since 0.1.1 the
Sandbox handle has startInteractive, writeStdin,
attach and execFrames as well as the client.
Raw frames
Underneath, run, exec and attach read a stream of JSON lines
(NDJSON). Each line is a Frame: (stream, data, done, ok, error, exit, id),
with data already decoded from base64. execFrames hands you each one:
String svc = sb.startContainer("alpine:latest", List.of("sleep", "3600"));
k.execFrames("doc-java-guide", svc, List.of("sh", "-c", "echo a; echo b >&2; exit 4"), f ->
System.out.println("frame stream=" + f.stream() + " data=" + new String(f.data(), StandardCharsets.UTF_8).replace("\n", "\\n")
+ " done=" + f.done() + " ok=" + f.ok() + " exit=" + f.exit() + " error=" + f.error() + " id=" + f.id()), null);
frame stream=stderr data=b\n done=false ok=false exit=null error=null id=null frame stream=stdout data=a\n done=false ok=false exit=null error=null id=null frame stream=null data= done=true ok=true exit=4 error=null id=null
The last frame has done=true. Its ok means the stream ended
cleanly, not that the command succeeded: ok=true with exit=4. A final frame
with error set is how the daemon reports a command that could not run, and the
collecting methods turn it into a StreamException. execFrames does not: it
passes the error frame to you and returns normally. It also takes no timeout.
Timeouts
In plain words. A timeout stops your program from waiting forever. It does not stop the command in the sandbox.
try {
sb.run("alpine:latest", List.of("sleep", "30"), new Options.Run().timeout(Duration.ofSeconds(2)));
} catch (Kosa8Exception.TimeoutException e) {
System.out.println("timeout: " + e.getMessage());
}
Thread.sleep(1000);
System.out.println("containers after timeout " + sb.containers());
timeout: POST /kosa8/v1/sandboxes/doc-java-guide/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock after 2012ms
containers after timeout [{id=k3-745c8bcf, image=docker.io/library/alpine:latest, status=running, ip=10.88.0.4}]
(The "after 2012ms" was added by the test program.) In detail: a timeout is a watchdog
on one shared daemon thread (kosa8-timeout). When it fires it closes the connection,
and the blocked read fails with TimeoutException. The sleep 30 container was
still running afterwards. Stop such a container yourself, or delete the sandbox. A
TimeoutException carries no output: if you need what was printed before the limit, collect
it with onStdout/onStderr.
| Where | Applies to | Default |
|---|---|---|
Options.Run.timeout(Duration) | That one run, exec or attach. | the client's |
Builder.timeout(Duration) | Every request without its own, including createSandbox, snapshot and restores. Booting took 10–16 seconds and one restore 31 seconds on the busy test machine, so set this generously or not at all. | none |
Options.Explore.timeout(Duration) | Each branch's command in explore. | 120 seconds |
A zero Duration means no limit. The limit is measured from when the connection
opens, so it covers sending the request too.
Snapshots
In plain words. A snapshot is a photograph of a whole running sandbox: memory, running programs and disk. You can start a new sandbox from it later, as many times as you like, and every copy carries on from exactly that moment.
var snap = sb.snapshot(new Options.Snapshot().id("doc-java-snap-manual"));
var restored = k.restoreSnapshot("doc-java-snap-manual", new Options.Restore().name("doc-java-snap-copy"));
System.out.println("copy /f=" + k.exec("doc-java-snap-copy", cid, List.of("cat", "/f"), null).stdout().strip());
System.out.println("delete snap " + k.deleteSnapshot("doc-java-snap-manual", null));
snapshot {snapshot={id=doc-java-snap-manual, sandbox=doc-java-snap, created=2026-11-03T01:42:22.741039-05:00, …, size_bytes=106958848, state_path=/Users/dhruvit/.kosa8/snapshots/doc-java-snap-manual/state.vzvmsave, …, egress=deny}, snapshot_ms=14794}
restored {restore_ms=31285, sandbox={id=doc-java-snap-copy, name=doc-java-snap-copy, created=2026-11-03T01:42:54.029023-05:00, state=running, parent=doc-java-snap, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, ip=192.168.127.2}}
copy /f=one
delete snap {encrypted=false, ok=true, removed=doc-java-snap-manual}
The copy has the original's container, with the same id, still running and holding the same
file. listSnapshots() returns every snapshot on the machine (checkpoints included,
since they are snapshots too).
| Options | Setter | Meaning |
|---|---|---|
Snapshot | id(String) | The snapshot's id; the daemon picks one if unset. |
encrypt(boolean) | Encrypt the snapshot at rest once it is taken, as kosa8 snapshot create --encrypt. An org policy with require_snapshot_encryption does it anyway. (Not run for this page.) | |
Restore | name(String) | The new sandbox's name. |
label(String) | Raise the restored sandbox's classification label above the snapshot's. Lower or sideways is refused. Not a free-form tag. | |
allowTampered(boolean) | Restore a sealed snapshot whose seal does not verify, to examine the tampering. Recorded in the audit log. | |
SnapshotDelete | wipe(boolean) | Overwrite the files, not just unlink them (?wipe=1). |
force(boolean) | ?force=1. |
Fork and explore
In plain words. Forking makes live copies of a running sandbox, so you can try several things side by side. Explore does that for you: one copy per idea, run them all at once, keep the copy where the idea worked fastest, throw the others away.
var f = sb.fork(2, new Options.Fork().prefix("doc-java-fork"));
System.out.println("fork " + f);
fork Fork[forks=[{id=doc-java-fork1, name=doc-java-fork1, …, state=running, parent=doc-java-snap, …}, {id=doc-java-fork2, name=doc-java-fork2, …, state=running, parent=doc-java-snap, …}], snapshot=doc-java-snap-fork-1793688195516612000, forkMs=3356, count=2]
fork(sandbox, n, Options.Fork) sends POST …/fork with n (at
least 1; a smaller value is raised to 1). Forks are named prefix plus a number, as
doc-java-fork1, doc-java-fork2 above. Options.Fork().label(…) raises
the forks' classification above the parent's (never lower); it is not a free-form tag. The forks
are ordinary sandboxes: delete them when done.
var ex = sb.explore(List.of("sleep 2; echo slow", "echo fast", "exit 1"),
new Options.Explore().pull(false).timeout(Duration.ofSeconds(60)));
System.out.println("explore parent=" + ex.parent() + " winner=" + ex.winner() + " removed=" + ex.removed());
for (var b : ex.branches()) System.out.println(" " + b);
explore parent=doc-java-snap winner=doc-java-snap-explore-881982 removed=2 ExploreBranch[name=doc-java-snap-explore-881981, variant=sleep 2; echo slow, exit=0, output=slow, error=null, durationMs=3586] ExploreBranch[name=doc-java-snap-explore-881982, variant=echo fast, exit=0, output=fast, error=null, durationMs=1603] ExploreBranch[name=doc-java-snap-explore-881983, variant=exit 1, exit=1, output=, error=null, durationMs=1603]
What explore does, step by step (it is client-side, the same algorithm as
kosa8 explore):
- Throws at once if there are fewer than two variants.
- Unless
pull(false), pullsimage(defaultalpine:latest) into the sandbox. - Forks the sandbox once per variant, with prefix
prefixor<sandbox>-explore-<5 digits of the clock>. If fewer forks come back than variants, it throws and leaves what was created for you to find withlistSandboxes(). - Runs
sh -c VARIANTin each fork at the same time, on a thread pool with one thread per variant, each with the branch timeout. A connection-level failure (refused, reset, EOF, …) is retried up to four times with a growing pause, since a just-restored branch can refuse its first connection. A timeout is not retried. - The winner is the fastest branch with exit 0 and no error.
keep(name)overrides that; a name that is not one of the branches throws, after the run, with every branch kept. - Unless
keepAll(true), deletes every branch except the winner.removedcounts the deletions that succeeded. When no branch succeeded,winneris""and every branch is deleted.
The winner is left running and is yours to delete. The parent sandbox is untouched.
Checkpoints
In plain words. A checkpoint is an "undo" point. Take one before something risky; if it
goes wrong, rewind, and the sandbox is back as it was, programs still running. The SDK and the
kosa8 command line share the same list, so a checkpoint taken in Java can be rewound
from the terminal and the other way round.
sb.checkpoint("taken from Java");
// …then, from the terminal: kosa8 checkpoint doc-java-shared --label 'taken from the CLI'
for (Checkpoint c : sb.checkpoints())
System.out.println(c.id() + " label=\"" + c.label() + "\" auto=" + c.auto() + " created=" + c.created());
System.out.println("rewind to: " + sb.rewind("taken from java").checkpoint().id());
$ kosa8 checkpoint doc-java-shared --label 'taken from the CLI'
checkpoint doc-java-shared-ckpt-2 (taken from the CLI) saved in 545ms
rewind with `kosa8 rewind doc-java-shared`
doc-java-shared-ckpt-1 label="taken from Java" auto=false created=2026-11-03T06:52:55.215776Z
doc-java-shared-ckpt-2 label="taken from the CLI" auto=false created=2026-11-03T06:52:55.837825Z
rewind to: doc-java-shared-ckpt-1
$ kosa8 checkpoint ls doc-java-shared
CHECKPOINT WHEN TAKEN WHY
*doc-java-shared-ckpt-1 just now you taken from Java
* is where `kosa8 rewind doc-java-shared` goes
deleted: {ok=true, checkpoints_dropped=1}
Java saw the CLI's checkpoint, rewound to its own by label (matched without regard to case:
"taken from java" found "taken from Java"), and the CLI then saw that the
later checkpoint had been dropped.
How checkpoints work
The daemon has snapshots; checkpoints are bookkeeping the client keeps on top of them, exactly
as the CLI's own pkg/checkpoint does.
- A stack per sandbox. Each checkpoint is a snapshot with the id
<sandbox>-ckpt-<n>, wherenis one more than the highest number on the stack (or the stack's size plus one, whichever is larger). - At most 10. Taking the eleventh evicts the oldest, after the new snapshot exists, so a
failed checkpoint never costs you the old one.
CheckpointSaved.evicted()lists what went. (From the source and the SDK's mock-daemon test; not run live.) - Rewind (
to: an id or a label;nullmeans the latest) does three things in order: deletes the sandbox, restores the checkpoint's snapshot under the same name, then drops the checkpoints taken after the target and deletes their snapshots. The current state is lost. If the delete is refused, nothing has changed and that exception is thrown as is. If the restore fails, the sandbox no longer exists, the checkpoint is intact, and you get aCheckpointExceptionwhose message says so and contains the exactrestoreSnapshot(…)call that recovers it (real message). - Two at once. Two checkpoints of one sandbox taken at the same moment (two threads, two
clients, or Java and the CLI) can pick the same next id. The daemon refuses the second snapshot with
409
already exists, and the SDK steps to the next number and tries again, up to 10 times. Any other 409 is thrown. Run with two clients in two threads:concurrent: doc-java-011-ckpt-1 (client one), doc-java-011-ckpt-2 (client two)
- Names are checked. An empty name, one starting with
., or one containing/or\is refused (invalid sandbox name "../etc") before any request is sent. automay be set on records written by kosa8's own Go tools. The SDK always records false, and leaves the field out of the file when it is false, as the CLI does.
Where the list lives, and the locking caveat
By default the client uses FileCheckpointStore, which reads and writes the CLI's own
file, ~/.kosa8/checkpoints.json. Every write goes to
checkpoints.json.tmp, is set to mode 0600, and is renamed over the file atomically, so a
reader never sees half a file. A file that cannot be parsed is treated as empty rather than
breaking your sandbox.
To keep two writers from overwriting each other's changes, each update first takes an
in-process lock shared by every FileCheckpointStore on that path in the JVM (so two
clients in one program queue up instead of failing), then an exclusive lock on
checkpoints.json.lock. It waits at most 10 seconds for both together, then throws
CheckpointException. Choose a different limit with
new FileCheckpointStore(path, Duration lockTimeout). With another process holding the
lock and a 2-second limit:
com.kosa8.Kosa8Exception$CheckpointException after 2021ms: timed out after 2s waiting for /private/tmp/…/ck/checkpoints.json.lock; another kosa8 process is holding it
Here is the caveat about the CLI, precisely:
- Java can only take
fcntllocks (that is whatFileChannel.tryLock()uses on Unix). The CLI takesflocklocks. These are two different locking systems in the operating system. - On Linux they do not see each other. A Java checkpoint, rewind or delete that updates the file at the very instant a CLI command updates it can lose one of the two records. (From the SDK's documentation and Linux's locking rules; not reproduced for this page.)
- On macOS they do exclude each other. We held a
flockon the lock file from another process for 6 seconds and ran a Java update; it waited:
java update took 8ms # no other lock held python holds flock for 6s java update took 5172ms # waited for the flock to be released
- Java processes always exclude each other, on both systems, and so do clients within one JVM.
The window is one file rewrite, a few milliseconds, so in practice it matters only on Linux, when scripts and the CLI checkpoint the same machine at the same moment. If that is your situation, or you want the SDK's checkpoints to be private, use a store of your own:
// In memory: nothing shared with the CLI, gone when the JVM exits.
Kosa8Client k = Kosa8Client.builder().checkpoints(new MemoryCheckpointStore()).build();
// A different file, same format and locking.
Kosa8Client k2 = Kosa8Client.builder()
.checkpoints(new FileCheckpointStore(Path.of("/srv/agent/checkpoints.json"))).build();
Or implement CheckpointStore yourself: load() returns a copy of all stacks
(sandbox name to list, oldest first), and update(fn) must run fn on a mutable
copy and save what it leaves, excluding other updates while it does.
Thread safety
In plain words. One client can be shared by many threads, and several clients can live in one program. Calls do not get in each other's way.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<String>> out = new ArrayList<>();
for (int i = 1; i <= 4; i++) {
int n = i;
out.add(pool.submit(() -> sb.exec(cid, List.of("sh", "-c", "echo job " + n + " squared is $((" + n + "*" + n + "))"), null).stdout().strip()));
}
for (Future<String> f : out) System.out.println(f.get());
job 1 squared is 1 job 2 squared is 4 job 3 squared is 9 job 4 squared is 16
In detail:
| Part | Safe to share? | Why |
|---|---|---|
Kosa8Client, Sandbox | Yes | All fields are final and set in build(). Each call opens its own connection, so calls do not share any stream. |
Results.*, Checkpoint, Frame, Endpoint | Yes, read-only | Records. Exec exposes its byte[] arrays directly; do not modify them if others read them. |
Options.* | Build per call | Mutable builders with no locking. |
| Checkpoints of the same sandbox from two threads or clients | Yes | Both can pick the same next id; the one the daemon refuses with 409 steps to the next (seen: ckpt-1 and ckpt-2). Each still waits its turn for the store's lock. |
| Two clients with default stores in one JVM | Yes | Each build() without checkpoints(…) makes a new FileCheckpointStore, but stores on one path share one in-process lock, so the second waits (seen: 1027 ms behind a 1-second update, no exception). In 0.1.0 it threw OverlappingFileLockException. |
The writeStdin once-only check | Per client | Kept in a concurrent set on the client. Another client does not know what this one sent. |
Streaming callbacks run on the calling thread. explore starts its own pool, sized to
the number of variants, and shuts it down before returning.
Resources and clean-up
Neither Kosa8Client nor Sandbox implements AutoCloseable, so
there is no try-with-resources form. The client holds no open connection and needs no closing. What
does need cleaning up is what you create in the daemon: sandboxes, forks, explore winners and
snapshots. They outlive your program. Use try … finally, as in the
quickstart, and sweep by label to catch anything left by a crash.
If you want try-with-resources, a three-line wrapper of your own does it:
record TempSandbox(Sandbox sb) implements AutoCloseable {
@Override public void close() { sb.delete(); }
}
// try (var t = new TempSandbox(k.sandbox("x"))) { t.sb().run(…); }
Approvals and policy
A company policy can forbid an action, or require a signed
approval for it. Every option class extends Options.Call, so any gated call takes
.approval(sig); calls whose only option is an approval take
Options.Approval.of(sig). The signature is sent as the X-Kosa8-Approval
header. A refusal that an approval would lift is an ApprovalRequiredException, whose
approval() map is the daemon's explanation (why, who may sign, the command
to request one). On the Sandbox handle, methods that take an options object carry the approval in it; start, stop, pull, startContainer, checkpoint and rewind take none, so use the client's overloads for those. (From
the source; no policy was installed for this page.)
Recipes
An agent loop with undo
Checkpoint before each step an agent proposes; if the step fails, rewind so the next step starts from a known-good machine. A failed step costs nothing.
import com.kosa8.*;
import java.util.List;
import java.util.Map;
public class AgentLoop {
public static void main(String[] args) {
Kosa8Client k = Kosa8Client.builder().actor("agent").build();
String name = "doc-java-agent";
k.createSandbox(new Options.Create().name(name).egress("deny").labels(Map.of("owner", "doc-java")));
Sandbox sb = k.sandbox(name);
try {
sb.pull("alpine:latest");
String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity"));
// The steps an agent proposed. One of them is a mistake.
List<String> steps = List.of(
"mkdir -p /work && echo 'print(1)' > /work/app.py",
"rm -rf /work && false", // destroys the work, then fails
"echo 'print(2)' >> /work/app.py");
for (String step : steps) {
sb.checkpoint("before: " + step);
Results.Exec r = sb.exec(cid, List.of("sh", "-c", step), null);
if (r.ok()) {
System.out.println("ok " + step);
} else {
Results.Rewind rw = sb.rewind(null); // undo exactly this step
System.out.println("undone " + step + " (exit " + r.exitCode() + ", back to " + rw.checkpoint().id() + ")");
}
}
System.out.print("result:\n" + sb.exec(cid, List.of("cat", "/work/app.py"), null).stdout());
System.out.println("checkpoints kept: " + sb.checkpoints().size());
} finally {
System.out.println("deleted: " + sb.delete());
}
}
}
ok mkdir -p /work && echo 'print(1)' > /work/app.py
undone rm -rf /work && false (exit 1, back to doc-java-agent-ckpt-2)
ok echo 'print(2)' >> /work/app.py
result:
print(1)
print(2)
checkpoints kept: 3
deleted: {ok=true, checkpoints_dropped=3}
The container id stayed valid across the rewind, because the rewind brings back the running
container. actor("agent") makes the audit log show these calls as an agent's. (The run
that printed this also ran the thread-pool example above before deleting; those lines are left
out.)
Sweep up leftovers by label
Label everything your program creates, and delete by label at start-up or in a cron job. This catches sandboxes left behind by a crash or a killed JVM.
/** Delete every sandbox labelled owner=OWNER, with its checkpoints. */
static List<String> sweep(Kosa8Client k, String owner) {
List<String> gone = new ArrayList<>();
for (Map<String, Object> s : k.listSandboxes()) {
if (s.get("labels") instanceof Map<?, ?> l && owner.equals(l.get("owner"))) {
String id = (String) s.get("id");
try {
k.deleteSandbox(id);
gone.add(id);
} catch (Kosa8Exception.NotFoundException e) {
// already gone
}
}
}
return gone;
}
swept: [doc-java-sweep]
Forks and explore branches carry a parent field in listSandboxes() and are
named after a prefix, so a sweep can also match on either of those. (Whether forks copy their
parent's labels was not checked for this page.)
The same thing from the command line
| Java | CLI |
|---|---|
k.createSandbox(new Options.Create().name("x").egress("deny")) | kosa8 sandbox create x --egress deny |
k.listSandboxes() | kosa8 sandbox ls |
sb.checkpoint("why") | kosa8 checkpoint x --label why |
sb.checkpoints() | kosa8 checkpoint ls x |
sb.rewind(null) | kosa8 rewind x |
sb.explore(List.of("a", "b"), null) | kosa8 explore x --variant a --variant b |
k.listSnapshots() | kosa8 snapshot ls |
Every call made from Java appears in kosa8 audit ls with the actor you set.
Errors and troubleshooting: every exception and what to do about it. API reference: every class and method. SDKs in five languages compares Java with the others.