kosa8 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '059d3ca9753fb52d517a80038164038e51621d2ae4a2393fcb8f79481e9c8c95'
4
+ data.tar.gz: 10c6b5dd4914af9371fe21b17230df7ac38c85275fbb16c1fa60e1f10487bbc3
5
+ SHA512:
6
+ metadata.gz: 8774ce6bba2044d40ec5f051d84be6b057944469bda072eb52bacb8104d025f8a6cda96f5da6ca244c0eeee1207730b42f096118a93a96ad3d9454a8a740a2b0
7
+ data.tar.gz: 2207cecb3af2ca7dadfe4aa1252f29a0886b5e583534da78fe04682e5882b04235a9ff6a52fa16b546a9a1f3ad300c006a2b5413b92fe4350ca324448ebe7fed
data/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kosa8
4
+
5
+ This license covers the source code in this repository (the kosa8
6
+ TypeScript SDK packages, @kosa8/sdk and @kosa8/ai-sdk-sandbox) only. It does
7
+ not cover kosa8 itself (the kosa8 CLI, the kosa8d daemon or any other kosa8
8
+ software), which is licensed separately.
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # kosa8 (Ruby)
2
+
3
+ The Ruby SDK for [kosa8](https://kosa8.com): microVM sandboxes for AI agents. No dependencies; Ruby 2.6+.
4
+ Method for method it matches [`@kosa8/sdk`](https://github.com/kosa8ai/kosa8-js), the Python `kosa8` package and `kosa8-go`.
5
+
6
+ ```ruby
7
+ require "kosa8"
8
+
9
+ k = Kosa8::Client.new # local daemon at ~/.kosa8/run/kosa8.sock
10
+ k.create_sandbox(name: "demo", egress_allow: ["pypi.org"])
11
+ sb = k.sandbox("demo")
12
+ sb.pull("python:3.12-alpine")
13
+ puts sb.run("python:3.12-alpine", ["python", "-c", "print(6*7)"]).stdout # 42
14
+
15
+ sb.checkpoint(label: "clean") # restore point, shared with `kosa8 rewind`
16
+ sb.rewind(to: "clean") # back to it, processes resumed
17
+ best = sb.explore(["make test", "make test-fast"]) # best-of-N on forks
18
+ sb.delete
19
+ ```
20
+
21
+ ## Connecting
22
+
23
+ | Option | Default |
24
+ |---|---|
25
+ | `socket_path:` | `~/.kosa8/run/kosa8.sock` |
26
+ | `host:` / `KOSA8_HOST` | a remote worker: `https://`, `http://` (loopback only unless `insecure: true` / `KOSA8_INSECURE=1`), `tcp://`, `unix://` |
27
+ | `token:` / `KOSA8_TOKEN` | sent as `Authorization: Bearer` to a remote worker |
28
+ | `actor:` | `"tool"` (`human`, `agent`, `tool`), sent as `X-Kosa8-Actor` |
29
+ | `agent_token:` / `KOSA8_AGENT_TOKEN` | sent as `X-Kosa8-Agent-Token` |
30
+
31
+ ## Errors
32
+
33
+ All errors inherit `Kosa8::Error` and carry the daemon's message and remediation:
34
+ `ConnectionError`, `BadRequestError`, `AuthError`, `PolicyError` › `ApprovalRequiredError` (`#approval`),
35
+ `NotFoundError`, `ConflictError`, `UnsupportedError`, `StreamError` (`#stdout`/`#stderr` so far),
36
+ `CheckpointError`, `TimeoutError`. Methods that a policy may gate take `approval:`.
37
+
38
+ ## API
39
+
40
+ Daemon: `health`, `version`, `pull`.
41
+ Sandboxes: `list_sandboxes`, `get_sandbox`, `create_sandbox`, `start_sandbox`, `stop_sandbox`, `delete_sandbox`,
42
+ `pull_into_sandbox`, `sandbox_images`, `sandbox_containers`, `sandbox_egress`.
43
+ Processes: `run`, `start_container`, `exec`, `exec_frames`, `start_interactive`, `write_stdin`, `attach`.
44
+ Snapshots: `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot`, `fork`, `explore`.
45
+ Checkpoints: `checkpoint`, `list_checkpoints`, `rewind` (store: `~/.kosa8/checkpoints.json`, flock-shared with the CLI).
46
+ `client.sandbox(id)` binds them to one sandbox.
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ ruby -w test/test_client.rb # mock daemon on a unix socket
52
+ ruby examples/smoke.rb # live, against a running `kosa8 daemon`
53
+ ```
54
+
55
+ MIT licensed.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Kosa8
8
+ # pkg/checkpoint MaxPerSandbox.
9
+ MAX_CHECKPOINTS_PER_SANDBOX = 10
10
+
11
+ def self.validate_sandbox_name(name)
12
+ if !name.is_a?(String) || name.empty? || name.start_with?(".") || name.include?("/") || name.include?("\\")
13
+ raise CheckpointError, "invalid sandbox name #{name.inspect}"
14
+ end
15
+
16
+ name
17
+ end
18
+
19
+ # Keeps stacks in memory; nothing is shared with the CLI.
20
+ class MemoryCheckpointStore
21
+ def initialize
22
+ @data = {}
23
+ @mu = Mutex.new
24
+ end
25
+
26
+ def load
27
+ @mu.synchronize { Marshal.load(Marshal.dump(@data)) }
28
+ end
29
+
30
+ # Runs the block on the stacks and saves what it leaves.
31
+ def update
32
+ @mu.synchronize do
33
+ d = Marshal.load(Marshal.dump(@data))
34
+ r = yield d
35
+ @data = d
36
+ r
37
+ end
38
+ end
39
+ end
40
+
41
+ # The store the kosa8 CLI uses: ~/.kosa8/checkpoints.json (0600, rewritten
42
+ # atomically), locked with the same flock on checkpoints.json.lock, so
43
+ # `kosa8 rewind` sees checkpoints taken here and the other way round.
44
+ class FileCheckpointStore
45
+ attr_reader :path
46
+
47
+ def initialize(path = nil)
48
+ @path = path || File.join(Dir.home, ".kosa8", "checkpoints.json")
49
+ @mu = Mutex.new
50
+ end
51
+
52
+ def load
53
+ d = JSON.parse(File.read(@path))
54
+ d.is_a?(Hash) ? d : {}
55
+ rescue Errno::ENOENT, JSON::ParserError
56
+ {} # a corrupt store must not make the sandbox unusable
57
+ end
58
+
59
+ def update
60
+ @mu.synchronize do
61
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
62
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
63
+ lock.flock(File::LOCK_EX)
64
+ d = load
65
+ r = yield d
66
+ tmp = "#{@path}.tmp"
67
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(JSON.pretty_generate(d)) }
68
+ File.chmod(0o600, tmp)
69
+ File.rename(tmp, @path)
70
+ r
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # The stack logic of pkg/checkpoint over any store. Records are hashes with
77
+ # the CLI's JSON keys: id, sandbox, label, auto (omitted when false), created.
78
+ class CheckpointBook
79
+ def initialize(store)
80
+ @store = store
81
+ end
82
+
83
+ def self.seq(id, sandbox)
84
+ pre = "#{sandbox}-ckpt-"
85
+ return 0 unless id.start_with?(pre)
86
+
87
+ id[pre.length..-1][/\A\d+/].to_i
88
+ end
89
+
90
+ # The next id and the ids to evict to stay at the cap.
91
+ def next_id(sandbox)
92
+ Kosa8.validate_sandbox_name(sandbox)
93
+ list = @store.load[sandbox] || []
94
+ n = list.length + 1
95
+ list.each { |c| v = self.class.seq(c["id"].to_s, sandbox); n = v + 1 if v >= n }
96
+ over = list.length + 1 - MAX_CHECKPOINTS_PER_SANDBOX
97
+ evict = over.positive? ? list.first(over).map { |c| c["id"] } : []
98
+ ["#{sandbox}-ckpt-#{n}", evict]
99
+ end
100
+
101
+ def record(id, sandbox, label, evicted = [])
102
+ Kosa8.validate_sandbox_name(sandbox)
103
+ rec = { "id" => id, "sandbox" => sandbox, "label" => label.to_s,
104
+ "created" => Time.now.utc.iso8601(9) }
105
+ @store.update do |d|
106
+ d[sandbox] = (d[sandbox] || []).reject { |c| evicted.include?(c["id"]) } + [rec]
107
+ end
108
+ rec
109
+ end
110
+
111
+ # Oldest first.
112
+ def list(sandbox)
113
+ (@store.load[sandbox] || []).each_with_index.sort_by { |c, i| [Kosa8.parse_time(c["created"]), i] }.map(&:first)
114
+ end
115
+
116
+ def latest(sandbox)
117
+ l = list(sandbox)
118
+ raise CheckpointError, "no checkpoints for #{sandbox.inspect} — take one with checkpoint or `kosa8 checkpoint #{sandbox}`" if l.empty?
119
+
120
+ l.last
121
+ end
122
+
123
+ # By id or (case-insensitive) label, newest first.
124
+ def resolve(sandbox, ref)
125
+ list(sandbox).reverse.find { |c| c["id"] == ref || c["label"].to_s.casecmp(ref.to_s).zero? } ||
126
+ raise(CheckpointError, "no checkpoint #{ref.inspect} for sandbox #{sandbox.inspect}")
127
+ end
128
+
129
+ # Drops everything after id; returns the dropped ids.
130
+ def pop(sandbox, id)
131
+ @store.update do |d|
132
+ list = d[sandbox] || []
133
+ i = list.index { |c| c["id"] == id }
134
+ raise CheckpointError, "no checkpoint #{id.inspect} for sandbox #{sandbox.inspect}" if i.nil?
135
+
136
+ d[sandbox] = list.first(i + 1)
137
+ list[(i + 1)..-1].map { |c| c["id"] }
138
+ end
139
+ end
140
+
141
+ # Forgets a sandbox's stack; returns its ids.
142
+ def forget(sandbox)
143
+ @store.update { |d| (d.delete(sandbox) || []).map { |c| c["id"] } }
144
+ end
145
+ end
146
+
147
+ def self.parse_time(s)
148
+ Time.iso8601(s.to_s) # handles the Go CLI's nanosecond timestamps
149
+ rescue ArgumentError
150
+ Time.at(0)
151
+ end
152
+ end
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kosa8
4
+ # The outcome of a run, exec or attach.
5
+ ExecResult = Struct.new(:exit_code, :stdout, :stderr, keyword_init: true) do
6
+ def ok?
7
+ exit_code.zero?
8
+ end
9
+ end
10
+
11
+ ExploreBranch = Struct.new(:name, :variant, :exit, :output, :error, :duration_ms, keyword_init: true)
12
+ ExploreResult = Struct.new(:parent, :branches, :winner, :removed, keyword_init: true)
13
+
14
+ TRANSIENT = /connection refused|EOF|connection reset|broken pipe|no such file or directory|stream ended|ECONNREFUSED|ECONNRESET|unreachable/i.freeze
15
+
16
+ # Typed client for the kosa8 native API (/kosa8/v1) over the daemon's unix
17
+ # socket, or a remote worker via KOSA8_HOST / KOSA8_TOKEN.
18
+ #
19
+ # k = Kosa8::Client.new
20
+ # k.create_sandbox(name: "demo", egress: "deny")
21
+ # k.pull_into_sandbox("demo", "alpine:latest")
22
+ # puts k.run("demo", "alpine:latest", ["echo", "hi"]).stdout
23
+ class Client
24
+ attr_reader :transport, :checkpoints
25
+
26
+ def initialize(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil,
27
+ headers: {}, timeout: nil, checkpoint_store: nil, env: ENV)
28
+ @transport = Transport.new(socket_path: socket_path, host: host, token: token, insecure: insecure, actor: actor,
29
+ agent_token: agent_token, headers: headers, timeout: timeout, env: env)
30
+ @checkpoints = CheckpointBook.new(checkpoint_store || FileCheckpointStore.new)
31
+ end
32
+
33
+ # --- daemon ---------------------------------------------------------------
34
+
35
+ # GET /kosa8/v1/health: daemon status and version.
36
+ def health
37
+ @transport.json("GET", "/kosa8/v1/health")
38
+ end
39
+
40
+ def version
41
+ health["version"]
42
+ end
43
+
44
+ # POST /kosa8/v1/pull: pull an image into the host engine.
45
+ def pull(ref, approval: nil)
46
+ @transport.json("POST", "/kosa8/v1/pull", body: { ref: ref }, approval: approval)
47
+ end
48
+
49
+ # --- sandboxes ------------------------------------------------------------
50
+
51
+ def list_sandboxes
52
+ @transport.json("GET", "/kosa8/v1/sandboxes") || []
53
+ end
54
+
55
+ # By id, then by name; nil when there is none.
56
+ def get_sandbox(id_or_name)
57
+ l = list_sandboxes
58
+ l.find { |s| s["id"] == id_or_name } || l.find { |s| s["name"] == id_or_name }
59
+ end
60
+
61
+ # POST /kosa8/v1/sandboxes: boot a microVM sandbox.
62
+ # egress: "deny" | "any" | "fakenet" | "allow:host,host" | { allow: [...] }
63
+ def create_sandbox(name: nil, cpus: nil, mem_mb: nil, disk_gb: nil, mounts: nil, egress: nil, egress_allow: nil,
64
+ capture: nil, labels: nil, broker: nil, approval: nil)
65
+ body = {}
66
+ body[:name] = name if name
67
+ body[:cpus] = cpus if cpus
68
+ body[:mem_mb] = mem_mb if mem_mb
69
+ body[:disk_gb] = disk_gb if disk_gb
70
+ body[:mounts] = mounts if mounts
71
+ e = Client.egress_string(egress, egress_allow)
72
+ body[:egress] = e if e
73
+ body[:capture] = capture unless capture.nil?
74
+ body[:labels] = labels if labels
75
+ body[:broker] = broker if broker
76
+ @transport.json("POST", "/kosa8/v1/sandboxes", body: body, approval: approval)
77
+ end
78
+
79
+ # POST …/up: boot a stopped sandbox from its own disk.
80
+ def start_sandbox(sandbox, approval: nil)
81
+ @transport.json("POST", sb(sandbox, "up"), approval: approval)
82
+ end
83
+
84
+ # POST …/down: stop without deleting (the disk is kept).
85
+ def stop_sandbox(sandbox, approval: nil)
86
+ @transport.json("POST", sb(sandbox, "down"), approval: approval)
87
+ end
88
+
89
+ # DELETE /kosa8/v1/sandboxes/{id}; its checkpoints go too unless keep_checkpoints.
90
+ def delete_sandbox(sandbox, wipe: false, keep_checkpoints: false, approval: nil)
91
+ out = @transport.json("DELETE", sb(sandbox), query: { wipe: wipe }, approval: approval) || {}
92
+ dropped = 0
93
+ unless keep_checkpoints
94
+ begin
95
+ ids = @checkpoints.forget(sandbox)
96
+ ids.each do |sid|
97
+ delete_snapshot(sid, wipe: wipe, approval: approval)
98
+ rescue Error
99
+ nil
100
+ end
101
+ dropped = ids.length
102
+ rescue Error
103
+ nil # bookkeeping debris, not a failed delete
104
+ end
105
+ end
106
+ out.merge("checkpoints_dropped" => dropped)
107
+ end
108
+
109
+ # POST …/pull: pull an image into the sandbox's private engine.
110
+ def pull_into_sandbox(sandbox, ref, approval: nil)
111
+ @transport.json("POST", sb(sandbox, "pull"), body: { ref: ref }, approval: approval)
112
+ end
113
+
114
+ def sandbox_images(sandbox)
115
+ @transport.json("GET", sb(sandbox, "images")) || []
116
+ end
117
+
118
+ def sandbox_containers(sandbox)
119
+ @transport.json("GET", sb(sandbox, "containers")) || []
120
+ end
121
+
122
+ # GET …/egress: the policy the host enforces and what it decided.
123
+ def sandbox_egress(sandbox)
124
+ @transport.json("GET", sb(sandbox, "egress"))
125
+ end
126
+
127
+ # --- processes ------------------------------------------------------------
128
+
129
+ # POST …/run: run a container to completion. The image must already be in
130
+ # the sandbox (pull_into_sandbox). Callbacks see output as it arrives.
131
+ def run(sandbox, image, args = [], env: nil, on_stdout: nil, on_stderr: nil, on_status: nil, timeout: nil, approval: nil)
132
+ body = { image: image, args: args.to_a }
133
+ e = Client.env_list(env)
134
+ body[:env] = e if e
135
+ collect(on_stdout, on_stderr, on_status) do |h|
136
+ @transport.frames("POST", sb(sandbox, "run"), body: body, approval: approval, timeout: timeout, &h)
137
+ end
138
+ end
139
+
140
+ # POST …/start: start a detached container; returns its id.
141
+ def start_container(sandbox, image, args = [], approval: nil)
142
+ @transport.json("POST", sb(sandbox, "start"), body: { image: image, args: args.to_a }, approval: approval)["id"]
143
+ end
144
+
145
+ # POST …/exec: run a command in a running container of the sandbox.
146
+ def exec(sandbox, container, args, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil)
147
+ collect(on_stdout, on_stderr, nil) do |h|
148
+ exec_frames(sandbox, container, args, timeout: timeout, approval: approval, &h)
149
+ end
150
+ end
151
+
152
+ # The raw frame stream of an exec; yields each frame.
153
+ def exec_frames(sandbox, container, args, timeout: nil, approval: nil, &blk)
154
+ @transport.frames("POST", sb(sandbox, "exec"), body: { container: container, args: args.to_a },
155
+ approval: approval, timeout: timeout, &blk)
156
+ end
157
+
158
+ # POST …/start-interactive: start a container with stdin held open.
159
+ def start_interactive(sandbox, image, args = [], env: nil, tty: false, approval: nil)
160
+ @transport.json("POST", sb(sandbox, "start-interactive"),
161
+ body: { image: image, args: args.to_a, env: Client.env_list(env), tty: tty },
162
+ approval: approval)["id"]
163
+ end
164
+
165
+ # POST …/containers/{cid}/stdin: a String, an IO, or an Enumerable of chunks (sent chunked).
166
+ def write_stdin(sandbox, container, data, approval: nil)
167
+ @transport.json("POST", sb(sandbox, "containers", Kosa8.enc(container), "stdin"), raw_body: data, approval: approval)
168
+ end
169
+
170
+ # POST …/containers/{cid}/attach: follow a container's output until it exits.
171
+ def attach(sandbox, container, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil)
172
+ collect(on_stdout, on_stderr, nil) do |h|
173
+ @transport.frames("POST", sb(sandbox, "containers", Kosa8.enc(container), "attach"),
174
+ approval: approval, timeout: timeout, &h)
175
+ end
176
+ end
177
+
178
+ # --- snapshots and forks --------------------------------------------------
179
+
180
+ # POST …/snapshot: capture memory, processes and disks.
181
+ def snapshot(sandbox, id: nil, encrypt: false, approval: nil)
182
+ body = {}
183
+ body[:id] = id if id
184
+ body[:encrypt] = true if encrypt
185
+ @transport.json("POST", sb(sandbox, "snapshot"), body: body, approval: approval)
186
+ end
187
+
188
+ def list_snapshots
189
+ @transport.json("GET", "/kosa8/v1/snapshots") || []
190
+ end
191
+
192
+ # POST /kosa8/v1/snapshots/{id}/restore: boot a sandbox from a snapshot, processes resumed.
193
+ def restore_snapshot(snapshot_id, name: nil, label: nil, allow_tampered: false, approval: nil)
194
+ body = {}
195
+ body[:name] = name if name
196
+ body[:label] = label if label
197
+ body[:allow_tampered] = true if allow_tampered
198
+ @transport.json("POST", "/kosa8/v1/snapshots/#{Kosa8.enc(snapshot_id)}/restore", body: body, approval: approval)
199
+ end
200
+
201
+ def delete_snapshot(snapshot_id, wipe: false, force: false, approval: nil)
202
+ @transport.json("DELETE", "/kosa8/v1/snapshots/#{Kosa8.enc(snapshot_id)}",
203
+ query: { wipe: wipe, force: force }, approval: approval)
204
+ end
205
+
206
+ # POST …/fork: n live copies of a running sandbox.
207
+ # Returns { "forks" => [...], "snapshot" => ..., "fork_ms" => ..., "count" => n }.
208
+ def fork(sandbox, n = 1, prefix: nil, label: nil, approval: nil)
209
+ body = { n: n }
210
+ body[:prefix] = prefix if prefix
211
+ body[:label] = label if label
212
+ @transport.json("POST", sb(sandbox, "fork"), body: body, approval: approval)
213
+ end
214
+
215
+ # Best-of-N, as `kosa8 explore`: forks the sandbox once per variant, runs
216
+ # each (sh -c VARIANT in image) concurrently in its own branch, keeps the
217
+ # fastest successful branch and deletes the rest.
218
+ def explore(sandbox, variants, image: "alpine:latest", keep_all: false, keep: nil, timeout: 120, prefix: nil,
219
+ pull: true, approval: nil)
220
+ variants = variants.to_a
221
+ raise Error, "give at least two variants; with one there is nothing to compare" if variants.length < 2
222
+
223
+ pull_into_sandbox(sandbox, image, approval: approval) if pull
224
+ prefix ||= "#{sandbox}-explore-#{Time.now.to_i % 100_000}"
225
+ forked = fork(sandbox, variants.length, prefix: prefix, approval: approval)
226
+ names = (forked["forks"] || []).map { |f| f["id"] }.select { |x| x.is_a?(String) }
227
+ if names.length < variants.length
228
+ raise Error, "forked #{names.length} branches for #{variants.length} variants; see list_sandboxes for what was created"
229
+ end
230
+
231
+ branches = names.zip(variants).map do |name, v|
232
+ Thread.new { run_branch(name, image, v, timeout, approval) }
233
+ end.map(&:value)
234
+ winner = ""
235
+ best = nil
236
+ branches.each do |b|
237
+ if b.error.nil? && b.exit.zero? && (best.nil? || b.duration_ms < best)
238
+ winner = b.name
239
+ best = b.duration_ms
240
+ end
241
+ end
242
+ if keep
243
+ raise Error, "keep #{keep} is not one of the branches (#{names.join(', ')}); all of them were kept" unless names.include?(keep)
244
+
245
+ winner = keep
246
+ end
247
+ removed = 0
248
+ unless keep_all
249
+ branches.each do |b|
250
+ next if b.name == winner
251
+
252
+ begin
253
+ @transport.json("DELETE", sb(b.name), approval: approval)
254
+ removed += 1
255
+ rescue Error
256
+ nil # reported by the count
257
+ end
258
+ end
259
+ end
260
+ ExploreResult.new(parent: sandbox, branches: branches, winner: winner, removed: removed)
261
+ end
262
+
263
+ # --- checkpoints (client-side over snapshots, as pkg/checkpoint) ------------
264
+
265
+ # Save a restore point: a snapshot <sandbox>-ckpt-<n> on the sandbox's stack,
266
+ # the same store `kosa8 checkpoint` uses; the oldest is evicted past 10.
267
+ def checkpoint(sandbox, label: "", approval: nil)
268
+ cid, evict = @checkpoints.next_id(sandbox)
269
+ snap = snapshot(sandbox, id: cid, approval: approval)
270
+ evict.each do |old| # evict only once the replacement exists
271
+ delete_snapshot(old, approval: approval)
272
+ rescue Error
273
+ nil
274
+ end
275
+ rec = @checkpoints.record(cid, sandbox, label, evict)
276
+ { "checkpoint" => rec, "evicted" => evict, "snapshot" => snap }
277
+ end
278
+
279
+ # A sandbox's restore points, oldest first.
280
+ def list_checkpoints(sandbox)
281
+ @checkpoints.list(sandbox)
282
+ end
283
+
284
+ # Undo: put the sandbox back to a checkpoint (id or label; default the
285
+ # latest) under the same name, processes resumed. Checkpoints taken after
286
+ # the target are discarded. The current state is lost.
287
+ def rewind(sandbox, to: nil, approval: nil)
288
+ target = to ? @checkpoints.resolve(sandbox, to) : @checkpoints.latest(sandbox)
289
+ # The sandbox must go first: the restored VM takes its name. A refusal
290
+ # here leaves everything as it was, and is raised as is.
291
+ @transport.json("DELETE", sb(sandbox), approval: approval)
292
+ begin
293
+ restored = restore_snapshot(target["id"], name: sandbox, approval: approval)
294
+ rescue Error => e
295
+ raise CheckpointError, "restore failed and #{sandbox} is now stopped: #{e.message} — recover with " \
296
+ "restore_snapshot(#{target['id'].inspect}, name: #{sandbox.inspect})"
297
+ end
298
+ dropped = []
299
+ begin
300
+ dropped = @checkpoints.pop(sandbox, target["id"])
301
+ dropped.each do |sid|
302
+ delete_snapshot(sid, approval: approval)
303
+ rescue Error
304
+ nil
305
+ end
306
+ rescue Error
307
+ nil # bookkeeping
308
+ end
309
+ { "sandbox" => restored["sandbox"], "checkpoint" => target, "dropped" => dropped,
310
+ "restore_ms" => restored["restore_ms"] }
311
+ end
312
+
313
+ # A handle bound to one sandbox.
314
+ def sandbox(sandbox_id)
315
+ Sandbox.new(self, sandbox_id)
316
+ end
317
+
318
+ def self.egress_string(egress, allow)
319
+ if allow && !allow.empty?
320
+ raise Error, "egress_allow already limits the network to the hosts it lists; do not also set egress" if egress && egress != "deny"
321
+
322
+ return "allow:" + allow.map { |h| h.to_s.strip }.join(",")
323
+ end
324
+ return nil if egress.nil?
325
+ return egress if egress.is_a?(String)
326
+
327
+ hosts = (egress[:allow] || egress["allow"] || []).to_a
328
+ raise Error, 'an egress allow-list needs at least one host; use egress: "deny" for no network' if hosts.empty?
329
+
330
+ "allow:" + hosts.map { |h| h.to_s.strip }.join(",")
331
+ end
332
+
333
+ def self.env_list(env)
334
+ return nil if env.nil?
335
+ return env.map { |k, v| "#{k}=#{v}" } if env.is_a?(Hash)
336
+
337
+ env.to_a
338
+ end
339
+
340
+ private
341
+
342
+ def sb(id, *rest)
343
+ (["/kosa8/v1/sandboxes", Kosa8.enc(id)] + rest).join("/")
344
+ end
345
+
346
+ def collect(on_stdout, on_stderr, on_status)
347
+ out = +"".b
348
+ err = +"".b
349
+ result = nil
350
+ handler = lambda do |f|
351
+ next if result
352
+
353
+ if f["done"]
354
+ if f["error"]
355
+ raise StreamError.new(f["error"], stdout: Client.utf8(out), stderr: Client.utf8(err), daemon_message: f["error"])
356
+ end
357
+
358
+ result = ExecResult.new(exit_code: f["exit"].is_a?(Integer) ? f["exit"] : 0, stdout: Client.utf8(out),
359
+ stderr: Client.utf8(err))
360
+ next
361
+ end
362
+ data = f["data"]
363
+ next if data.nil? || data.empty?
364
+
365
+ case f["stream"]
366
+ when "stderr"
367
+ err << data
368
+ on_stderr&.call(data)
369
+ when "status"
370
+ on_status&.call(data)
371
+ else
372
+ out << data
373
+ on_stdout&.call(data)
374
+ end
375
+ end
376
+ yield handler
377
+ result || raise(StreamError.new("stream ended unexpectedly (no final frame)", stdout: Client.utf8(out),
378
+ stderr: Client.utf8(err)))
379
+ end
380
+
381
+ def self.utf8(b)
382
+ b.dup.force_encoding(Encoding::UTF_8).scrub
383
+ end
384
+
385
+ def run_branch(name, image, variant, timeout, approval)
386
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
387
+ b = ExploreBranch.new(name: name, variant: variant, exit: -1, output: "", error: nil)
388
+ # A freshly restored branch can refuse the first connection while its
389
+ # engine settles; retry connection-level failures briefly, as the CLI does.
390
+ 4.times do |attempt|
391
+ begin
392
+ res = run(name, image, ["sh", "-c", variant], timeout: timeout, approval: approval)
393
+ b.exit = res.exit_code
394
+ b.output = (res.stdout + res.stderr).chomp
395
+ b.error = nil
396
+ break
397
+ rescue Error => e
398
+ b.error = e.message
399
+ b.output = (e.stdout + e.stderr).chomp if e.is_a?(StreamError)
400
+ break if e.is_a?(TimeoutError) || b.error !~ TRANSIENT
401
+
402
+ sleep((attempt + 1) * 0.5)
403
+ end
404
+ end
405
+ b.duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).to_i
406
+ b
407
+ end
408
+ end
409
+
410
+ # The client's sandbox methods bound to one sandbox id.
411
+ class Sandbox
412
+ attr_reader :id, :client
413
+
414
+ def initialize(client, id)
415
+ @client = client
416
+ @id = id
417
+ end
418
+
419
+ def inspect
420
+ "#<Kosa8::Sandbox #{@id}>"
421
+ end
422
+
423
+ def info
424
+ @client.get_sandbox(@id)
425
+ end
426
+
427
+ def checkpoints
428
+ @client.list_checkpoints(@id)
429
+ end
430
+
431
+ def images
432
+ @client.sandbox_images(@id)
433
+ end
434
+
435
+ def containers
436
+ @client.sandbox_containers(@id)
437
+ end
438
+
439
+ def egress
440
+ @client.sandbox_egress(@id)
441
+ end
442
+
443
+ def run(image, args = [], **kw)
444
+ @client.run(@id, image, args, **kw)
445
+ end
446
+
447
+ def start_container(image, args = [], **kw)
448
+ @client.start_container(@id, image, args, **kw)
449
+ end
450
+
451
+ def exec(container, args, **kw)
452
+ @client.exec(@id, container, args, **kw)
453
+ end
454
+
455
+ def pull(ref, **kw)
456
+ @client.pull_into_sandbox(@id, ref, **kw)
457
+ end
458
+
459
+ def fork(n = 1, **kw)
460
+ @client.fork(@id, n, **kw)
461
+ end
462
+
463
+ def explore(variants, **kw)
464
+ @client.explore(@id, variants, **kw)
465
+ end
466
+
467
+ { start: :start_sandbox, stop: :stop_sandbox, delete: :delete_sandbox, snapshot: :snapshot,
468
+ checkpoint: :checkpoint, rewind: :rewind }.each do |name, target|
469
+ define_method(name) { |**kw| @client.public_send(target, @id, **kw) }
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kosa8
4
+ # Every error this gem raises. The daemon's own words are kept in
5
+ # +daemon_message+ and +remediation+, as @kosa8/sdk does.
6
+ class Error < StandardError
7
+ attr_reader :status, :daemon_message, :remediation, :method, :path, :body
8
+
9
+ def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil)
10
+ super(message)
11
+ @status = status
12
+ @daemon_message = daemon_message
13
+ @remediation = remediation
14
+ @method = method
15
+ @path = path
16
+ @body = body
17
+ end
18
+ end
19
+
20
+ class ConnectionError < Error; end # daemon unreachable
21
+ class ApiError < Error; end # any HTTP error answer
22
+ class BadRequestError < ApiError; end # 400
23
+ class AuthError < ApiError; end # 401
24
+ class PolicyError < ApiError; end # 403
25
+ class NotFoundError < ApiError; end # 404
26
+ class ConflictError < ApiError; end # 409
27
+ class UnsupportedError < ApiError; end # 501
28
+
29
+ # 403 that a signed approval would lift; +approval+ is the daemon's refusal.
30
+ class ApprovalRequiredError < PolicyError
31
+ attr_reader :approval
32
+
33
+ def initialize(message = nil, approval: {}, **kw)
34
+ super(message, **kw)
35
+ @approval = approval
36
+ end
37
+ end
38
+
39
+ # A stream ended with an error frame (or none); output so far is kept.
40
+ class StreamError < Error
41
+ attr_reader :stdout, :stderr
42
+
43
+ def initialize(message = nil, stdout: "", stderr: "", **kw)
44
+ super(message, **kw)
45
+ @stdout = stdout
46
+ @stderr = stderr
47
+ end
48
+ end
49
+
50
+ class CheckpointError < Error; end
51
+ class TimeoutError < Error; end
52
+
53
+ BY_STATUS = {
54
+ 400 => BadRequestError, 401 => AuthError, 403 => PolicyError,
55
+ 404 => NotFoundError, 409 => ConflictError, 501 => UnsupportedError
56
+ }.freeze
57
+
58
+ # The typed error for an HTTP error answer: the daemon's message with its
59
+ # remediation in parentheses, as the CLI and MCP server print it.
60
+ def self.error_from_response(status, body, method:, path:)
61
+ msg = nil
62
+ rem = nil
63
+ approval = nil
64
+ if body.is_a?(Hash)
65
+ msg = body["error"].is_a?(String) && !body["error"].empty? ? body["error"] : body["message"]
66
+ rem = body["remediation"] if body["remediation"].is_a?(String) && !body["remediation"].empty?
67
+ approval = body["approval"] if status == 403 && body["approval"].is_a?(Hash) && body["approval"]["why"].is_a?(String)
68
+ elsif body.is_a?(String) && !body.strip.empty?
69
+ msg = body.strip
70
+ end
71
+ base = msg.is_a?(String) && !msg.empty? ? msg : "request failed with HTTP #{status}"
72
+ text = rem ? "#{base} (#{rem})" : base
73
+ kw = { status: status, daemon_message: msg, remediation: rem, method: method, path: path, body: body }
74
+ return ApprovalRequiredError.new(text, approval: approval, **kw) if approval
75
+
76
+ (BY_STATUS[status] || ApiError).new(text, **kw)
77
+ end
78
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "socket"
8
+ require "uri"
9
+
10
+ module Kosa8
11
+ APPROVAL_HEADER = "X-Kosa8-Approval"
12
+ ACTOR_HEADER = "X-Kosa8-Actor"
13
+ AGENT_TOKEN_HEADER = "X-Kosa8-Agent-Token"
14
+
15
+ # $HOME/.kosa8/run/kosa8.sock (pkg/paths).
16
+ def self.default_socket_path
17
+ File.join(Dir.home, ".kosa8", "run", "kosa8.sock")
18
+ end
19
+
20
+ # Where requests go: a unix socket or a remote worker.
21
+ Endpoint = Struct.new(:kind, :socket_path, :scheme, :host, :port, :base_path, :token, keyword_init: true) do
22
+ def target
23
+ kind == :unix ? "unix://#{socket_path}" : "#{scheme}://#{host}:#{port}"
24
+ end
25
+ end
26
+
27
+ def self.loopback?(host)
28
+ h = host.to_s.delete("[]")
29
+ h == "localhost" || h == "::1" || h.start_with?("127.")
30
+ end
31
+
32
+ # The CLI's KOSA8_HOST rules: no host means the local socket; https://,
33
+ # http://, tcp://, unix:// or a bare host:port select a worker; plain http to a
34
+ # non-loopback host is refused unless insecure (or KOSA8_INSECURE=1).
35
+ # KOSA8_TOKEN is used only for a host that came from KOSA8_HOST.
36
+ def self.resolve_endpoint(host: nil, socket_path: nil, token: nil, insecure: false, env: ENV)
37
+ explicit = !host.nil? && !host.empty?
38
+ raw = (explicit ? host : env["KOSA8_HOST"].to_s).strip.sub(%r{/+\z}, "")
39
+ return Endpoint.new(kind: :unix, socket_path: socket_path || default_socket_path) if raw.empty?
40
+
41
+ h = raw.sub(%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}) { "#{Regexp.last_match(1).downcase}://" }
42
+ if h.start_with?("unix://")
43
+ return Endpoint.new(kind: :unix, socket_path: h.sub("unix://", ""))
44
+ elsif h.start_with?("tcp://")
45
+ h = "http://" + h.sub("tcp://", "")
46
+ elsif h =~ %r{\A([a-z][a-z0-9+.-]*)://} && !%w[http https].include?(Regexp.last_match(1))
47
+ raise Error, "KOSA8_HOST scheme \"#{Regexp.last_match(1)}\" is not supported; use http://, https://, tcp:// or unix://"
48
+ elsif h !~ %r{\Ahttps?://}
49
+ h = "http://" + h
50
+ end
51
+ u = begin
52
+ URI.parse(h)
53
+ rescue URI::InvalidURIError
54
+ nil
55
+ end
56
+ raise Error, "KOSA8_HOST #{raw.inspect} names no host" if u.nil? || u.host.nil? || u.host.empty?
57
+
58
+ if u.scheme == "http" && !insecure && env["KOSA8_INSECURE"] != "1" && !loopback?(u.host)
59
+ raise Error, "refusing to talk to #{u.host}:#{u.port} over plain http: the token and every request would cross " \
60
+ "the network unencrypted. Use https://#{u.host}:#{u.port} (a worker behind a TLS-terminating tunnel " \
61
+ "or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted"
62
+ end
63
+ token = env["KOSA8_TOKEN"] if (token.nil? || token.empty?) && !explicit
64
+ Endpoint.new(kind: :tcp, scheme: u.scheme, host: u.host, port: u.port, base_path: u.path.to_s.sub(%r{/+\z}, ""),
65
+ token: token && !token.empty? ? token : nil)
66
+ end
67
+
68
+ def self.enc(s)
69
+ URI.encode_www_form_component(s.to_s).gsub("+", "%20")
70
+ end
71
+
72
+ # Net::HTTP over a unix socket.
73
+ class UnixHTTP < Net::HTTP
74
+ attr_accessor :socket_path
75
+
76
+ def connect
77
+ io = UNIXSocket.new(@socket_path)
78
+ @socket = Net::BufferedIO.new(io, read_timeout: @read_timeout, continue_timeout: @continue_timeout,
79
+ debug_output: @debug_output)
80
+ on_connect
81
+ end
82
+ end
83
+
84
+ # An IO-like over an Enumerable of string chunks, for a chunked request body.
85
+ class ChunkReader
86
+ def initialize(chunks)
87
+ @enum = chunks.each
88
+ @buf = +""
89
+ @eof = false
90
+ end
91
+
92
+ def read(len = nil, outbuf = nil)
93
+ until @eof || (len && @buf.bytesize >= len)
94
+ begin
95
+ @buf << @enum.next.to_s.b
96
+ rescue StopIteration
97
+ @eof = true
98
+ end
99
+ end
100
+ return nil if @buf.empty? && len
101
+
102
+ out = len ? @buf.byteslice(0, len) : @buf
103
+ @buf = len ? (@buf.byteslice(len..-1) || +"") : +""
104
+ outbuf ? outbuf.replace(out) : out
105
+ end
106
+ end
107
+
108
+ # Sends requests to the daemon; the client builds on it.
109
+ class Transport
110
+ attr_reader :endpoint
111
+
112
+ def initialize(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil,
113
+ headers: {}, timeout: nil, env: ENV)
114
+ @endpoint = Kosa8.resolve_endpoint(host: host, socket_path: socket_path, token: token, insecure: insecure, env: env)
115
+ raise Error, "actor must be human, agent or tool, not #{actor.inspect}" unless %w[human agent tool].include?(actor)
116
+
117
+ @actor = actor
118
+ @agent_token = agent_token || env["KOSA8_AGENT_TOKEN"]
119
+ @headers = headers || {}
120
+ @timeout = timeout
121
+ end
122
+
123
+ # One request; yields the Net::HTTPResponse (body unread) and returns the block's value.
124
+ def raw(method, path, query: nil, body: nil, raw_body: nil, approval: nil, timeout: nil)
125
+ full = (@endpoint.kind == :tcp ? @endpoint.base_path.to_s : "") + path
126
+ q = (query || {}).each_with_object([]) do |(k, v), a|
127
+ next if v.nil? || v == false
128
+
129
+ a << [k.to_s, v == true ? "1" : v.to_s]
130
+ end
131
+ full += "?" + URI.encode_www_form(q) unless q.empty?
132
+ req = Net::HTTPGenericRequest.new(method, !raw_body.nil? || !body.nil?, true, full)
133
+ req["Accept"] = "application/json"
134
+ req[ACTOR_HEADER] = @actor
135
+ @headers.each { |k, v| req[k] = v }
136
+ if !raw_body.nil?
137
+ req["Content-Type"] = "application/octet-stream"
138
+ if raw_body.is_a?(String)
139
+ req.body = raw_body
140
+ else
141
+ req["Transfer-Encoding"] = "chunked"
142
+ req.body_stream = raw_body.respond_to?(:read) ? raw_body : ChunkReader.new(raw_body)
143
+ end
144
+ elsif !body.nil?
145
+ req["Content-Type"] = "application/json"
146
+ req.body = JSON.generate(body)
147
+ end
148
+ req[APPROVAL_HEADER] = approval.strip if approval && !approval.strip.empty?
149
+ req[AGENT_TOKEN_HEADER] = @agent_token if @agent_token && !@agent_token.empty?
150
+ req["Authorization"] = "Bearer #{@endpoint.token}" if @endpoint.kind == :tcp && @endpoint.token
151
+ http = connection
152
+ t = timeout || @timeout
153
+ if t
154
+ http.open_timeout = [t, 30].min
155
+ http.read_timeout = t
156
+ end
157
+ begin
158
+ http.start { |h| h.request(req) { |resp| return yield(resp) } }
159
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
160
+ raise TimeoutError.new("#{method} #{path} timed out talking to #{@endpoint.target}: #{e.message}",
161
+ method: method, path: path)
162
+ rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
163
+ hint = e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED) ? " (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`)" : ""
164
+ raise ConnectionError.new("kosa8 daemon unreachable at #{@endpoint.target}: #{e.message}#{hint}",
165
+ method: method, path: path)
166
+ end
167
+ end
168
+
169
+ # A request whose answer is JSON.
170
+ def json(method, path, **kw)
171
+ raw(method, path, **kw) do |resp|
172
+ text = resp.read_body.to_s
173
+ parsed = decode(text)
174
+ raise Kosa8.error_from_response(resp.code.to_i, parsed, method: method, path: path) if resp.code.to_i >= 400
175
+
176
+ parsed
177
+ end
178
+ end
179
+
180
+ # A request whose answer is NDJSON frames; yields each, data decoded from base64.
181
+ def frames(method, path, **kw)
182
+ raw(method, path, **kw) do |resp|
183
+ if resp.code.to_i >= 400
184
+ raise Kosa8.error_from_response(resp.code.to_i, decode(resp.read_body.to_s), method: method, path: path)
185
+ end
186
+
187
+ buf = +""
188
+ resp.read_body do |chunk|
189
+ buf << chunk
190
+ while (i = buf.index("\n"))
191
+ line = buf.slice!(0..i).strip
192
+ yield Kosa8.decode_frame(JSON.parse(line)) unless line.empty?
193
+ end
194
+ end
195
+ line = buf.strip
196
+ yield Kosa8.decode_frame(JSON.parse(line)) unless line.empty?
197
+ nil
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ def connection
204
+ if @endpoint.kind == :unix
205
+ h = UnixHTTP.new("kosa8", 80)
206
+ h.socket_path = @endpoint.socket_path
207
+ h
208
+ else
209
+ h = Net::HTTP.new(@endpoint.host, @endpoint.port)
210
+ h.use_ssl = @endpoint.scheme == "https"
211
+ h
212
+ end
213
+ end
214
+
215
+ def decode(text)
216
+ return nil if text.strip.empty?
217
+
218
+ JSON.parse(text)
219
+ rescue JSON::ParserError
220
+ text
221
+ end
222
+ end
223
+
224
+ def self.decode_frame(f)
225
+ f["data"] = Base64.strict_decode64(f["data"]) if f["data"].is_a?(String) && !f["data"].empty?
226
+ f
227
+ end
228
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kosa8
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kosa8.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Ruby SDK for kosa8: microVM sandboxes for AI agents.
4
+ # Method for method it matches @kosa8/sdk (TypeScript), kosa8 (Python) and kosa8-go.
5
+ require_relative "kosa8/version"
6
+ require_relative "kosa8/errors"
7
+ require_relative "kosa8/transport"
8
+ require_relative "kosa8/checkpoints"
9
+ require_relative "kosa8/client"
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kosa8
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - kosa8
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Typed client for the kosa8 daemon: sandboxes, run/exec streams, snapshots,
14
+ fork, best-of-N explore and checkpoints shared with the kosa8 CLI. No dependencies.'
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/kosa8.rb
23
+ - lib/kosa8/checkpoints.rb
24
+ - lib/kosa8/client.rb
25
+ - lib/kosa8/errors.rb
26
+ - lib/kosa8/transport.rb
27
+ - lib/kosa8/version.rb
28
+ homepage: https://kosa8.com
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ source_code_uri: https://github.com/kosa8ai/kosa8-rb
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '2.6'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.0.3.1
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: 'Ruby SDK for kosa8: microVM sandboxes for AI agents'
52
+ test_files: []