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 +7 -0
- data/LICENSE +26 -0
- data/README.md +55 -0
- data/lib/kosa8/checkpoints.rb +152 -0
- data/lib/kosa8/client.rb +472 -0
- data/lib/kosa8/errors.rb +78 -0
- data/lib/kosa8/transport.rb +228 -0
- data/lib/kosa8/version.rb +5 -0
- data/lib/kosa8.rb +9 -0
- metadata +52 -0
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
|
data/lib/kosa8/client.rb
ADDED
|
@@ -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
|
data/lib/kosa8/errors.rb
ADDED
|
@@ -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
|
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: []
|