kosa8 0.1.2 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbd21499fe850f6b1c9055f9451c06500e9b779e60d880426afa850fc8a69ce5
4
- data.tar.gz: 786b60910b4a591d9fec38b5b0b5d2623fd805934ff8508337ceec1f7e3159b4
3
+ metadata.gz: '08b5f504643a5faa4c88bbccaea1a60ca60d91a5c2e1a846e014d6a2a91019ea'
4
+ data.tar.gz: 2ad6b1df0b4223436d67994ddb21f6323479a5923573c0dafab8bb03f9a2551a
5
5
  SHA512:
6
- metadata.gz: f5e5ea9ecbde4e0fdd2085b89d00ac7145485a4f6f2866bbde4986672a9e24215e3af27b9fa89aed98adc010698359d84ad435343d737f120c7faa37c7657af8
7
- data.tar.gz: 946d830b7d1ecfba2962857cded5f61c8bcb9d621fc8d0af7f3985f755272811934175bdff469aabe1a442c740a457a8ea6cecc32eba2b5c0d993344a818c799
6
+ metadata.gz: 9ed7181a597817332ac84fd9d6fc53afce2d8cd2c093dab59d1bd8b1550d21f918161cdf019f31d699592e696f67a50ab9268d41c921425642927a85030c02c4
7
+ data.tar.gz: 7d317bbd4ac4f54f287640c95c212bd88f7d416a090a7dde47dad22dd48554dd228193703033706d12a9752c5586ceb18ee5330f066d294fc53801961f611a0c
data/README.md CHANGED
@@ -27,12 +27,26 @@ sb.delete
27
27
  | `token:` / `KOSA8_TOKEN` | sent as `Authorization: Bearer` to a remote worker |
28
28
  | `actor:` | `"tool"` (`human`, `agent`, `tool`), sent as `X-Kosa8-Actor` |
29
29
  | `agent_token:` / `KOSA8_AGENT_TOKEN` | sent as `X-Kosa8-Agent-Token` |
30
+ | `worker_file:` | `~/.kosa8/worker.json`; `false` or `""` reads none |
31
+
32
+ Where requests go, the same as the CLI:
33
+
34
+ 1. `host:`, or a non-blank `KOSA8_HOST`. The token is `token:`, else `KOSA8_TOKEN` (only for a
35
+ host from `KOSA8_HOST`). The saved file is not read.
36
+ 2. Else, unless `socket_path:` is given, the connection the CLI saved in `~/.kosa8/worker.json`
37
+ (`kosa8 wsl setup` on Windows, `kosa8 worker join` anywhere): its `host`, its `token` unless
38
+ `token:` is given, and plain http to a LAN address only with `"insecure": true`, `insecure: true`
39
+ or `KOSA8_INSECURE=1`. A missing or malformed file, or a blank `host`, is ignored.
40
+ 3. Else the unix socket.
41
+
42
+ On Windows the engine runs in WSL2: run `kosa8 wsl setup` once and `Kosa8::Client.new` finds it.
43
+ `client.transport.endpoint.target` shows where requests go.
30
44
 
31
45
  ## Errors
32
46
 
33
47
  All errors inherit `Kosa8::Error` and carry the daemon's message and remediation:
34
48
  `ConnectionError`, `BadRequestError`, `AuthError`, `PolicyError` › `ApprovalRequiredError` (`#approval`),
35
- `NotFoundError`, `ConflictError`, `UnsupportedError`, `StreamError` (`#stdout`/`#stderr` so far),
49
+ `NotFoundError`, `ConflictError`, `UnsupportedError`, `PaymentRequiredError` (402), `RateLimitError` (429), `StreamError` (`#stdout`/`#stderr` so far),
36
50
  `CheckpointError`, `TimeoutError`. Methods that a policy may gate take `approval:`.
37
51
 
38
52
  ## API
@@ -47,10 +61,81 @@ Snapshots: `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot`,
47
61
  Checkpoints: `checkpoint`, `list_checkpoints`, `rewind` (store: `~/.kosa8/checkpoints.json`, flock-shared with the CLI).
48
62
  `client.sandbox(id)` binds them to one sandbox.
49
63
 
64
+ ## Toolbox
65
+
66
+ Run code, files, git, shell sessions, terminals, previews, lifecycle and templates, each one daemon
67
+ route (see the [native API](https://docs.kosa8.com/guides/native-api/)). All of them except the
68
+ template calls are also on `client.sandbox(id)`.
69
+
70
+ ```ruby
71
+ k.create_sandbox(name: "work", egress_allow: ["github.com"], auto_stop_min: 30, ttl_min: 120)
72
+ sb = k.sandbox("work")
73
+
74
+ r = sb.run_code("print(6*7)") # Kosa8::CodeResult; also language: "javascript" / "typescript"
75
+ r.stdout # "42\n"; r.ok?, r.exit_code, r.timed_out
76
+ r.artifacts.each { |a| File.binwrite(a.name, a.data) } # matplotlib charts as PNG bytes
77
+
78
+ sb.write_file("/workspace/app.py", "print(1)") # make_dir, move_file, delete_file(recursive: true)
79
+ sb.read_text("/workspace/app.py") # read_file gives binary bytes; list_files, stat_file
80
+
81
+ sb.git_clone("https://github.com/octocat/Hello-World.git", "hw", depth: 1)
82
+ sb.git_commit("hw", "change", author_name: "me", author_email: "me@example.com") # stages everything first
83
+ sb.git("log", path: "hw", n: 5).log # any op: status, add, push, pull, checkout, branches, diff
84
+
85
+ sb.shell("cd /tmp && export X=1") # session "main", made on first use; cd/export persist
86
+ sb.shell("echo $X; pwd").cwd # "/tmp"; create_session / session_exec / delete_session
87
+
88
+ cid = sb.start_pty(cols: 80, rows: 24) # then WebSocket …/containers/{cid}/tty; resize_pty
89
+ sb.create_preview(8000, session: "main", expires_in_s: 600) # signed link; k.revoke_previews ends them all
90
+ sb.set_lifecycle(auto_stop_min: 0) # nil keeps a setting, 0 turns it off
91
+
92
+ k.build_template("py-data", "./ctx", build_args: { "V" => "1" }) { |out| print out } # tars ./ctx, honours .dockerignore
93
+ k.create_sandbox(name: "fast", template: "py-data")
94
+ k.list_templates; k.delete_template("py-data")
95
+ ```
96
+
97
+ A non-zero exit or a failed git op is a result (`ok?` false), not an exception; a failed template build
98
+ raises `Kosa8::StreamError`.
99
+
100
+ ## kosa8 Cloud
101
+
102
+ The same sandboxes on kosa8 Cloud instead of the local daemon. Make an API
103
+ key in the cloud dashboard and set `KOSA8_API_KEY`.
104
+
105
+ ```ruby
106
+ require "kosa8"
107
+
108
+ cloud = Kosa8::Cloud.new # or Kosa8::Cloud.new(api_key: "k8c_live_...")
109
+
110
+ box = cloud.sandboxes.create(template: "python", sleep_after_s: 600)
111
+ r = box.exec(["python3", "-c", "print(6 * 7)"])
112
+ puts r.exit_code, r.stdout # 0, 42
113
+ box.sleep # costs only snapshot storage while asleep
114
+ box.exec("ls /workspace") # wakes it, files and all
115
+ box.delete
116
+
117
+ cloud.functions.deploy("hello", {
118
+ "runtime" => "python3.12",
119
+ "code" => "def handler(event):\n return {\"hello\": event.get(\"name\", \"world\")}\n"
120
+ })
121
+ out = cloud.functions.invoke("hello", { "name" => "kosa8" })
122
+ p out["ok"], out["result"] # true, {"hello"=>"kosa8"}
123
+ ```
124
+
125
+ A new organization runs nothing until a payment method is on file; until
126
+ then creating a sandbox raises `Kosa8::PaymentRequiredError` (`code`
127
+ `payment_required`), whose message says what to do. Every error from the
128
+ cloud carries its stable `code`. The address is `KOSA8_CLOUD_URL` (default
129
+ `https://api.kosa8.com`, https only except localhost); `cloud.me` and
130
+ `cloud.usage` show the org and this month's bill estimate.
131
+
50
132
  ## Development
51
133
 
52
134
  ```sh
53
135
  ruby -w test/test_client.rb # mock daemon on a unix socket
136
+ ruby -w test/test_toolbox.rb
137
+ ruby -w test/test_cloud.rb # stub transport and a local socket
138
+ ruby -w -Ilib -Itest -e 'Dir["test/test_*.rb"].sort.each { |f| require File.expand_path(f) }' # all, as CI does
54
139
  ruby examples/smoke.rb # live, against a running `kosa8 daemon`
55
140
  ```
56
141
 
data/lib/kosa8/client.rb CHANGED
@@ -14,19 +14,23 @@ module Kosa8
14
14
  TRANSIENT = /connection refused|EOF|connection reset|broken pipe|no such file or directory|stream ended|ECONNREFUSED|ECONNRESET|unreachable/i.freeze
15
15
 
16
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.
17
+ # socket, or a remote worker via KOSA8_HOST / KOSA8_TOKEN, or the connection
18
+ # the CLI saved in ~/.kosa8/worker.json (`kosa8 wsl setup`, `kosa8 worker join`).
18
19
  #
19
20
  # k = Kosa8::Client.new
20
21
  # k.create_sandbox(name: "demo", egress: "deny")
21
22
  # k.pull_into_sandbox("demo", "alpine:latest")
22
23
  # puts k.run("demo", "alpine:latest", ["echo", "hi"]).stdout
23
24
  class Client
25
+ include Toolbox
26
+
24
27
  attr_reader :transport, :checkpoints
25
28
 
26
29
  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)
30
+ headers: {}, timeout: nil, checkpoint_store: nil, env: ENV, worker_file: nil)
28
31
  @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)
32
+ agent_token: agent_token, headers: headers, timeout: timeout, env: env,
33
+ worker_file: worker_file)
30
34
  @checkpoints = CheckpointBook.new(checkpoint_store || FileCheckpointStore.new)
31
35
  @stdin_sent = {}
32
36
  @stdin_mu = Mutex.new
@@ -62,8 +66,14 @@ module Kosa8
62
66
 
63
67
  # POST /kosa8/v1/sandboxes: boot a microVM sandbox.
64
68
  # egress: "deny" | "any" | "fakenet" | "allow:host,host" | { allow: [...] }
69
+ #
70
+ # auto_stop_min stops it after that many idle minutes, ttl_min that long
71
+ # after creation, auto_delete_min deletes it once stopped that long, and
72
+ # ephemeral deletes it whenever it stops. template starts it with a
73
+ # template's image (see build_template).
65
74
  def create_sandbox(name: nil, cpus: nil, mem_mb: nil, disk_gb: nil, mounts: nil, egress: nil, egress_allow: nil,
66
- capture: nil, labels: nil, broker: nil, approval: nil)
75
+ capture: nil, labels: nil, broker: nil, auto_stop_min: nil, auto_delete_min: nil, ttl_min: nil,
76
+ ephemeral: false, template: nil, approval: nil)
67
77
  body = {}
68
78
  body[:name] = name if name
69
79
  body[:cpus] = cpus if cpus
@@ -75,6 +85,11 @@ module Kosa8
75
85
  body[:capture] = capture unless capture.nil?
76
86
  body[:labels] = labels if labels
77
87
  body[:broker] = broker if broker
88
+ { auto_stop_min: auto_stop_min, auto_delete_min: auto_delete_min, ttl_min: ttl_min }.each do |k, v|
89
+ body[k] = v if v && v.positive?
90
+ end
91
+ body[:ephemeral] = true if ephemeral
92
+ body[:template] = template if template
78
93
  @transport.json("POST", "/kosa8/v1/sandboxes", body: body, approval: approval)
79
94
  end
80
95
 
@@ -512,6 +527,18 @@ module Kosa8
512
527
  @client.attach(@id, container, **kw)
513
528
  end
514
529
 
530
+ # The toolbox, bound to this sandbox: sandbox.run_code("print(1)"),
531
+ # sandbox.write_file("a.txt", "hi"), sandbox.shell("ls"), …
532
+ %i[run_code list_files stat_file read_file read_text write_file delete_file make_dir move_file git git_clone
533
+ git_status git_commit create_session list_sessions session_exec shell delete_session start_pty resize_pty
534
+ create_preview].each do |name|
535
+ define_method(name) { |*args, **kw, &blk| @client.public_send(name, @id, *args, **kw, &blk) }
536
+ end
537
+
538
+ def set_lifecycle(**kw)
539
+ @client.set_lifecycle(@id, **kw)
540
+ end
541
+
515
542
  { start: :start_sandbox, stop: :stop_sandbox, delete: :delete_sandbox, snapshot: :snapshot,
516
543
  checkpoint: :checkpoint, rewind: :rewind }.each do |name, target|
517
544
  define_method(name) { |**kw| @client.public_send(target, @id, **kw) }
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "socket"
7
+ require "uri"
8
+
9
+ module Kosa8
10
+ # The outcome of a cloud exec. +woke_ms+ is set when the sandbox was asleep
11
+ # and woke for this command.
12
+ CloudExecResult = Struct.new(:exit_code, :stdout, :stderr, :woke_ms, keyword_init: true) do
13
+ def ok?
14
+ exit_code == 0 # exit_code is nil when the command never ran
15
+ end
16
+ end
17
+
18
+ # kosa8 Cloud: the same sandboxes, run on kosa8's hosts (or your own, with
19
+ # bring-your-own-cloud) instead of the local daemon.
20
+ #
21
+ # cloud = Kosa8::Cloud.new # KOSA8_API_KEY from the environment
22
+ # box = cloud.sandboxes.create(template: "python")
23
+ # box.exec(["python3", "-c", "print(6 * 7)"]).stdout # => "42\n"
24
+ # box.delete
25
+ #
26
+ # Authentication is an API key (k8c_live_...) made in the cloud dashboard.
27
+ # Errors are the SDK's usual typed errors; each carries the cloud's stable
28
+ # +code+ (for example payment_required, quota_exceeded). Standard library only.
29
+ class Cloud
30
+ DEFAULT_URL = "https://api.kosa8.com"
31
+ KEY_PREFIX = "k8c_live_"
32
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
33
+
34
+ attr_reader :base_url, :timeout, :sandboxes, :functions
35
+
36
+ # transport: an optional callable (method, url, headers, body_or_nil, timeout) -> [status, body_string],
37
+ # used instead of Net::HTTP (tests, proxies).
38
+ def initialize(api_key: nil, base_url: nil, env: ENV, timeout: 120, transport: nil)
39
+ key = (api_key.nil? ? env["KOSA8_API_KEY"].to_s : api_key.to_s).strip
40
+ raise Error, "no kosa8 Cloud API key: pass api_key: or set KOSA8_API_KEY" if key.empty?
41
+ raise Error, "a kosa8 Cloud API key starts with #{KEY_PREFIX}" unless key.start_with?(KEY_PREFIX)
42
+
43
+ @key = key
44
+ base = [base_url, env["KOSA8_CLOUD_URL"]].find { |v| v && !v.to_s.empty? } || DEFAULT_URL
45
+ @base_url = Cloud.check_base(base.to_s)
46
+ @timeout = timeout
47
+ @transport = transport || method(:net_http)
48
+ @sandboxes = CloudSandboxes.new(self)
49
+ @functions = CloudFunctions.new(self)
50
+ end
51
+
52
+ # Never the key.
53
+ def inspect
54
+ "#<Kosa8::Cloud base_url=#{@base_url.inspect}>"
55
+ end
56
+ alias to_s inspect
57
+
58
+ # The org, the key in use and, for a member's key, the member.
59
+ def me
60
+ request("GET", "/v1/me")
61
+ end
62
+
63
+ # This month's usage, bill estimate and whether work is paused for payment.
64
+ def usage
65
+ request("GET", "/v1/usage")
66
+ end
67
+
68
+ # One JSON request; raises the typed error for an error answer.
69
+ def request(method, path, body = nil)
70
+ headers = { "Authorization" => "Bearer #{@key}", "Accept" => "application/json" }
71
+ data = nil
72
+ unless body.nil?
73
+ headers["Content-Type"] = "application/json"
74
+ data = JSON.generate(body)
75
+ end
76
+ begin
77
+ status, raw = @transport.call(method, @base_url + path, headers, data, @timeout)
78
+ rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
79
+ raise TimeoutError.new("kosa8 Cloud did not answer within #{@timeout} s (#{e.message})",
80
+ method: method, path: path)
81
+ rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
82
+ raise ConnectionError.new("could not reach kosa8 Cloud at #{@base_url}: #{e.message}",
83
+ method: method, path: path)
84
+ end
85
+ text = raw.to_s.dup.force_encoding(Encoding::UTF_8)
86
+ text = text.scrub unless text.valid_encoding?
87
+ parsed = begin
88
+ text.strip.empty? ? nil : JSON.parse(text)
89
+ rescue JSON::ParserError
90
+ text
91
+ end
92
+ raise Cloud.error(status.to_i, parsed, method, path) if status.to_i >= 400
93
+
94
+ parsed
95
+ end
96
+
97
+ # The cloud answers {"error": code, "message": sentence}: the sentence is
98
+ # the message, and the code is kept.
99
+ def self.error(status, body, method, path)
100
+ code = nil
101
+ msg_body = body
102
+ if body.is_a?(Hash)
103
+ code = body["error"] if body["error"].is_a?(String)
104
+ m = body["message"]
105
+ msg_body = { "error" => m.is_a?(String) && !m.empty? ? m : body["error"] }
106
+ end
107
+ e = Kosa8.error_from_response(status, msg_body, method: method, path: path, code: code)
108
+ e.instance_variable_set(:@body, body)
109
+ e
110
+ end
111
+
112
+ def self.check_base(url)
113
+ u = begin
114
+ URI.parse(url)
115
+ rescue URI::InvalidURIError
116
+ nil
117
+ end
118
+ raise Error, "kosa8 Cloud address is not a URL: #{url}" if u.nil? || u.scheme.nil? || u.host.nil? || u.host.empty?
119
+
120
+ local = LOCAL_HOSTS.include?(u.hostname)
121
+ # The API key travels in every request.
122
+ unless u.scheme == "https" || (u.scheme == "http" && local)
123
+ raise Error, "kosa8 Cloud address must use https: #{url}"
124
+ end
125
+
126
+ url.sub(%r{/+\z}, "")
127
+ end
128
+
129
+ private
130
+
131
+ def net_http(method, url, headers, body, timeout)
132
+ u = URI.parse(url)
133
+ http = Net::HTTP.new(u.hostname, u.port)
134
+ http.use_ssl = u.scheme == "https"
135
+ if timeout
136
+ http.open_timeout = [timeout, 30].min
137
+ http.read_timeout = timeout
138
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
139
+ end
140
+ req = Net::HTTPGenericRequest.new(method, !body.nil?, true, u.request_uri)
141
+ headers.each { |k, v| req[k] = v }
142
+ req.body = body unless body.nil?
143
+ http.start do |h|
144
+ resp = h.request(req)
145
+ [resp.code.to_i, resp.body.to_s]
146
+ end
147
+ end
148
+ end
149
+
150
+ # cloud.sandboxes
151
+ class CloudSandboxes
152
+ def initialize(cloud)
153
+ @cloud = cloud
154
+ end
155
+
156
+ def inspect
157
+ "#<Kosa8::CloudSandboxes>"
158
+ end
159
+
160
+ # Start a sandbox; returns once it is running. Unset options take the org's settings.
161
+ def create(template: nil, region: nil, sleep_after_s: nil)
162
+ body = {}
163
+ body["template"] = template unless template.nil?
164
+ body["region"] = region unless region.nil?
165
+ body["sleep_after_s"] = sleep_after_s unless sleep_after_s.nil?
166
+ CloudSandbox.new(@cloud, @cloud.request("POST", "/v1/sandboxes", body)["sandbox"])
167
+ end
168
+
169
+ # The org's sandboxes; with all: true, deleted ones too.
170
+ def list(all: false)
171
+ r = @cloud.request("GET", "/v1/sandboxes" + (all ? "?all=1" : ""))
172
+ r["sandboxes"].map { |s| CloudSandbox.new(@cloud, s) }
173
+ end
174
+
175
+ def get(id)
176
+ CloudSandbox.new(@cloud, @cloud.request("GET", "/v1/sandboxes/#{Kosa8.enc(id)}")["sandbox"])
177
+ end
178
+ end
179
+
180
+ # One cloud sandbox. +info+ is its state as of the last call.
181
+ class CloudSandbox
182
+ attr_reader :info
183
+
184
+ def initialize(cloud, info)
185
+ @cloud = cloud
186
+ @info = info
187
+ end
188
+
189
+ def id
190
+ @info["id"]
191
+ end
192
+
193
+ def state
194
+ @info["state"].to_s
195
+ end
196
+
197
+ def inspect
198
+ "#<Kosa8::CloudSandbox id=#{id.inspect} state=#{state.inspect}>"
199
+ end
200
+
201
+ # Run a command and wait for it. A String runs through sh -c, an Array is
202
+ # the argv. A sleeping sandbox wakes for it.
203
+ def exec(cmd, image: nil, env: nil)
204
+ argv = cmd.is_a?(String) ? ["sh", "-c", cmd] : cmd.to_a
205
+ body = { "cmd" => argv }
206
+ body["image"] = image unless image.nil?
207
+ body["env"] = env.map { |k, v| "#{k}=#{v}" } if env && !env.empty?
208
+ r = @cloud.request("POST", path("/exec"), body)
209
+ CloudExecResult.new(exit_code: r["exit"], stdout: r["stdout"] || "", stderr: r["stderr"] || "",
210
+ woke_ms: r["woke_ms"])
211
+ end
212
+
213
+ # Snapshot it and free its VM; it then costs only snapshot storage.
214
+ def sleep
215
+ @info = @cloud.request("POST", path("/sleep"))["sandbox"]
216
+ self
217
+ end
218
+
219
+ # Restore a sleeping sandbox, files and all.
220
+ def wake
221
+ @info = @cloud.request("POST", path("/wake"))["sandbox"]
222
+ self
223
+ end
224
+
225
+ def refresh
226
+ @info = @cloud.request("GET", path)["sandbox"]
227
+ self
228
+ end
229
+
230
+ def delete
231
+ @cloud.request("DELETE", path)
232
+ nil
233
+ end
234
+
235
+ private
236
+
237
+ def path(suffix = "")
238
+ "/v1/sandboxes/#{Kosa8.enc(id)}#{suffix}"
239
+ end
240
+ end
241
+
242
+ # cloud.functions
243
+ class CloudFunctions
244
+ def initialize(cloud)
245
+ @cloud = cloud
246
+ end
247
+
248
+ def inspect
249
+ "#<Kosa8::CloudFunctions>"
250
+ end
251
+
252
+ # Deploy (create or replace) a function; every deploy bumps its version.
253
+ def deploy(name, spec)
254
+ @cloud.request("PUT", path(name), spec.to_h)
255
+ end
256
+
257
+ def list
258
+ @cloud.request("GET", "/v1/functions")["functions"]
259
+ end
260
+
261
+ def get(name)
262
+ @cloud.request("GET", path(name))
263
+ end
264
+
265
+ def delete(name)
266
+ @cloud.request("DELETE", path(name))
267
+ nil
268
+ end
269
+
270
+ # Call it once. A handler that raises answers "ok" => false, not an exception.
271
+ def invoke(name, event = nil)
272
+ @cloud.request("POST", path(name, "/invoke"), { "event" => event })
273
+ end
274
+
275
+ # One call per event, results in input order.
276
+ def map(name, events, concurrency: nil)
277
+ body = { "events" => events.to_a }
278
+ body["concurrency"] = concurrency unless concurrency.nil?
279
+ @cloud.request("POST", path(name, "/map"), body)
280
+ end
281
+
282
+ private
283
+
284
+ def path(name, suffix = "")
285
+ "/v1/functions/#{Kosa8.enc(name)}#{suffix}"
286
+ end
287
+ end
288
+ end
data/lib/kosa8/errors.rb CHANGED
@@ -4,9 +4,11 @@ module Kosa8
4
4
  # Every error this gem raises. The daemon's own words are kept in
5
5
  # +daemon_message+ and +remediation+, as @kosa8/sdk does.
6
6
  class Error < StandardError
7
- attr_reader :status, :daemon_message, :remediation, :http_method, :path, :body
7
+ # +code+ is kosa8 Cloud's stable error code (payment_required, quota_exceeded, ...).
8
+ attr_reader :status, :daemon_message, :remediation, :http_method, :path, :body, :code
8
9
 
9
- def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil)
10
+ def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil,
11
+ code: nil)
10
12
  super(message)
11
13
  @status = status
12
14
  @daemon_message = daemon_message
@@ -14,6 +16,7 @@ module Kosa8
14
16
  @http_method = method
15
17
  @path = path
16
18
  @body = body
19
+ @code = code
17
20
  end
18
21
  end
19
22
 
@@ -25,6 +28,8 @@ module Kosa8
25
28
  class NotFoundError < ApiError; end # 404
26
29
  class ConflictError < ApiError; end # 409
27
30
  class UnsupportedError < ApiError; end # 501
31
+ class PaymentRequiredError < ApiError; end # 402: kosa8 Cloud wants a payment method first
32
+ class RateLimitError < ApiError; end # 429: too many requests, or a quota reached
28
33
 
29
34
  # 403 that a signed approval would lift; +approval+ is the daemon's refusal.
30
35
  class ApprovalRequiredError < PolicyError
@@ -54,13 +59,13 @@ module Kosa8
54
59
  class ProtocolError < Error; end
55
60
 
56
61
  BY_STATUS = {
57
- 400 => BadRequestError, 401 => AuthError, 403 => PolicyError,
58
- 404 => NotFoundError, 409 => ConflictError, 501 => UnsupportedError
62
+ 400 => BadRequestError, 401 => AuthError, 402 => PaymentRequiredError, 403 => PolicyError,
63
+ 404 => NotFoundError, 409 => ConflictError, 429 => RateLimitError, 501 => UnsupportedError
59
64
  }.freeze
60
65
 
61
66
  # The typed error for an HTTP error answer: the daemon's message with its
62
67
  # remediation in parentheses, as the CLI and MCP server print it.
63
- def self.error_from_response(status, body, method:, path:)
68
+ def self.error_from_response(status, body, method:, path:, code: nil)
64
69
  msg = nil
65
70
  rem = nil
66
71
  approval = nil
@@ -73,7 +78,8 @@ module Kosa8
73
78
  end
74
79
  base = msg.is_a?(String) && !msg.empty? ? msg : "request failed with HTTP #{status}"
75
80
  text = rem ? "#{base} (#{rem})" : base
76
- kw = { status: status, daemon_message: msg, remediation: rem, method: method, path: path, body: body }
81
+ kw = { status: status, daemon_message: msg, remediation: rem, method: method, path: path, body: body,
82
+ code: code }
77
83
  return ApprovalRequiredError.new(text, approval: approval, **kw) if approval
78
84
 
79
85
  (BY_STATUS[status] || ApiError).new(text, **kw)
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "pathname"
5
+ require "rubygems/package"
6
+ require "stringio"
7
+
8
+ module Kosa8
9
+ # A file a code run produced: today, a chart as a PNG. +data+ is a binary String.
10
+ Artifact = Struct.new(:type, :name, :data, keyword_init: true)
11
+
12
+ # The outcome of run_code. A non-zero exit_code is not an error.
13
+ CodeResult = Struct.new(:exit_code, :stdout, :stderr, :language, :image, :artifacts, :duration_ms, :timed_out,
14
+ :truncated, keyword_init: true) do
15
+ def ok?
16
+ exit_code.zero? && !timed_out
17
+ end
18
+ end
19
+
20
+ # The outcome of one command in a session. +cwd+ is where the shell is now.
21
+ ShellResult = Struct.new(:exit_code, :stdout, :stderr, :cwd, :duration_ms, :timed_out, :truncated,
22
+ keyword_init: true) do
23
+ def ok?
24
+ exit_code.zero? && !timed_out
25
+ end
26
+ end
27
+
28
+ # The outcome of a git operation; +status+, +log+ or +branches+ are filled for those ops.
29
+ # A git failure is ok == false, not an exception.
30
+ GitResult = Struct.new(:op, :ok, :exit_code, :stdout, :stderr, :path, :status, :log, :branches,
31
+ keyword_init: true) do
32
+ def ok?
33
+ ok
34
+ end
35
+ end
36
+
37
+ # How long the daemon lets a snippet or session command run by default
38
+ # (codeTimeoutDefault), and a git operation (gitTimeout).
39
+ CODE_TIMEOUT_DEFAULT_S = 300
40
+ GIT_TIMEOUT_S = 600
41
+
42
+ # The sandbox toolbox: run code, files and git, shell sessions, terminals,
43
+ # previews, lifecycle and templates. Included in Kosa8::Client; every method
44
+ # maps to one daemon route (see https://docs.kosa8.com/guides/native-api/).
45
+ module Toolbox
46
+ # --- code -----------------------------------------------------------------
47
+
48
+ # POST …/code: run a Python, JavaScript or TypeScript snippet. It starts in
49
+ # /workspace. Charts a Python snippet draws with matplotlib come back in
50
+ # +artifacts+ as PNG bytes.
51
+ def run_code(sandbox, code, language: "python", image: nil, env: nil, timeout_s: nil, approval: nil)
52
+ body = { language: language, code: code }
53
+ body[:image] = image if image
54
+ body[:env] = env.to_h if env && !env.empty?
55
+ body[:timeout_s] = timeout_s if timeout_s && timeout_s.positive?
56
+ d = @transport.json("POST", sb(sandbox, "code"), body: body, approval: approval,
57
+ timeout: (timeout_s || CODE_TIMEOUT_DEFAULT_S) + 120) || {}
58
+ CodeResult.new(
59
+ exit_code: d["exit"].to_i, stdout: d["stdout"].to_s, stderr: d["stderr"].to_s,
60
+ language: d["language"].to_s, image: d["image"].to_s,
61
+ artifacts: (d["artifacts"] || []).map do |a|
62
+ Artifact.new(type: a["type"].to_s, name: a["name"].to_s, data: Base64.decode64(a["data"].to_s).b)
63
+ end,
64
+ duration_ms: d["duration_ms"].to_i, timed_out: d["timed_out"] == true, truncated: d["truncated"] == true
65
+ )
66
+ end
67
+
68
+ # --- files ----------------------------------------------------------------
69
+
70
+ # GET …/files: a folder in the workspace (or one entry).
71
+ def list_files(sandbox, path = "/workspace")
72
+ d = @transport.json("GET", sb(sandbox, "files"), query: { path: path })
73
+ (d.is_a?(Hash) ? d["files"] : nil) || []
74
+ end
75
+
76
+ # GET …/files/stat: one entry, not following a symlink.
77
+ def stat_file(sandbox, path)
78
+ @transport.json("GET", sb(sandbox, "files", "stat"), query: { path: path })
79
+ end
80
+
81
+ # GET …/files/content: a file's bytes, as a binary String.
82
+ def read_file(sandbox, path)
83
+ route = sb(sandbox, "files", "content")
84
+ @transport.raw("GET", route, query: { path: path }) do |resp|
85
+ data = resp.read_body.to_s.b
86
+ code = resp.code.to_i
87
+ if code >= 400
88
+ raise Kosa8.error_from_response(code, decode_body(data), method: "GET", path: route)
89
+ end
90
+
91
+ want = resp["Content-Length"]
92
+ if want && want.to_i != data.bytesize
93
+ raise StreamError.new("#{path}: got #{data.bytesize} of #{want} bytes", method: "GET", path: route)
94
+ end
95
+
96
+ data
97
+ end
98
+ end
99
+
100
+ def read_text(sandbox, path, encoding: Encoding::UTF_8)
101
+ read_file(sandbox, path).force_encoding(encoding)
102
+ end
103
+
104
+ # PUT …/files/content: replace a file (a String, binary or text), making its folders.
105
+ def write_file(sandbox, path, data, mode: nil, approval: nil)
106
+ q = { path: path }
107
+ q[:mode] = mode if mode
108
+ @transport.json("PUT", sb(sandbox, "files", "content"), query: q, raw_body: data.to_s.b, approval: approval)
109
+ end
110
+
111
+ # DELETE …/files: a file, or with recursive: a folder.
112
+ def delete_file(sandbox, path, recursive: false, approval: nil)
113
+ @transport.json("DELETE", sb(sandbox, "files"), query: { path: path, recursive: recursive }, approval: approval)
114
+ end
115
+
116
+ def make_dir(sandbox, path, approval: nil)
117
+ @transport.json("POST", sb(sandbox, "files", "mkdir"), body: { path: path }, approval: approval)
118
+ end
119
+
120
+ def move_file(sandbox, from, to, approval: nil)
121
+ @transport.json("POST", sb(sandbox, "files", "move"), body: { from: from, to: to }, approval: approval)
122
+ end
123
+
124
+ # --- git ------------------------------------------------------------------
125
+
126
+ # POST …/git: +op+ is clone, status, add, commit, push, pull, checkout,
127
+ # branches, log or diff; +fields+ are that op's (url:, path:, branch:,
128
+ # message:, token:…). A git failure returns ok == false.
129
+ def git(sandbox, op, approval: nil, **fields)
130
+ body = { op: op }
131
+ fields.each { |k, v| body[k] = v unless v.nil? }
132
+ d = @transport.json("POST", sb(sandbox, "git"), body: body, approval: approval, timeout: GIT_TIMEOUT_S + 120) || {}
133
+ GitResult.new(op: d["op"] || op.to_s, ok: d["ok"] == true, exit_code: d["exit"].to_i, stdout: d["stdout"].to_s,
134
+ stderr: d["stderr"].to_s, path: d["path"].to_s, status: d["status"], log: d["log"],
135
+ branches: d["branches"])
136
+ end
137
+
138
+ def git_clone(sandbox, url, path = nil, **kw)
139
+ git(sandbox, "clone", url: url, path: path, **kw)
140
+ end
141
+
142
+ def git_status(sandbox, path, **kw)
143
+ git(sandbox, "status", path: path, **kw)
144
+ end
145
+
146
+ # Stage everything (unless add_all: false) and commit.
147
+ def git_commit(sandbox, path, message, add_all: true, **kw)
148
+ if add_all
149
+ r = git(sandbox, "add", path: path, approval: kw[:approval])
150
+ return r unless r.ok
151
+ end
152
+ git(sandbox, "commit", path: path, message: message, **kw)
153
+ end
154
+
155
+ # --- sessions -------------------------------------------------------------
156
+
157
+ # POST …/sessions: a shell that keeps cd and export between commands.
158
+ def create_session(sandbox, name = nil, image: nil, env: nil, approval: nil)
159
+ body = {}
160
+ body[:name] = name if name
161
+ body[:image] = image if image
162
+ body[:env] = env.to_h if env && !env.empty?
163
+ @transport.json("POST", sb(sandbox, "sessions"), body: body, approval: approval)
164
+ end
165
+
166
+ def list_sessions(sandbox)
167
+ @transport.json("GET", sb(sandbox, "sessions")) || []
168
+ end
169
+
170
+ # POST …/sessions/{name}/exec: run one command line in the session.
171
+ def session_exec(sandbox, session, command, timeout_s: nil, approval: nil)
172
+ body = { command: command }
173
+ body[:timeout_s] = timeout_s if timeout_s && timeout_s.positive?
174
+ d = @transport.json("POST", sb(sandbox, "sessions", Kosa8.enc(session), "exec"), body: body, approval: approval,
175
+ timeout: (timeout_s || CODE_TIMEOUT_DEFAULT_S) + 120) || {}
176
+ ShellResult.new(exit_code: d["exit"].to_i, stdout: d["stdout"].to_s, stderr: d["stderr"].to_s, cwd: d["cwd"].to_s,
177
+ duration_ms: d["duration_ms"].to_i, timed_out: d["timed_out"] == true,
178
+ truncated: d["truncated"] == true)
179
+ end
180
+
181
+ # Run a command in a session, making the session on first use.
182
+ def shell(sandbox, command, session: "main", **kw)
183
+ begin
184
+ return session_exec(sandbox, session, command, **kw)
185
+ rescue NotFoundError => e
186
+ raise unless "#{e.daemon_message} #{e.message}".include?("no such session")
187
+ end
188
+ create_session(sandbox, session, approval: kw[:approval])
189
+ session_exec(sandbox, session, command, **kw)
190
+ end
191
+
192
+ def delete_session(sandbox, session, approval: nil)
193
+ @transport.json("DELETE", sb(sandbox, "sessions", Kosa8.enc(session)), approval: approval)
194
+ end
195
+
196
+ # --- terminals ------------------------------------------------------------
197
+
198
+ # POST …/pty: a container with a terminal; returns its id. Connect to
199
+ # /kosa8/v1/sandboxes/{id}/containers/{cid}/tty with any WebSocket client:
200
+ # binary messages are the terminal, text messages JSON control.
201
+ def start_pty(sandbox, image: nil, command: nil, cols: 0, rows: 0, env: nil, approval: nil)
202
+ body = {}
203
+ body[:image] = image if image
204
+ body[:command] = command.to_a if command && !command.empty?
205
+ if cols.to_i.positive? && rows.to_i.positive?
206
+ body[:cols] = cols
207
+ body[:rows] = rows
208
+ end
209
+ body[:env] = env.to_h if env && !env.empty?
210
+ @transport.json("POST", sb(sandbox, "pty"), body: body, approval: approval)["id"]
211
+ end
212
+
213
+ def resize_pty(sandbox, container, cols, rows)
214
+ @transport.json("POST", sb(sandbox, "containers", Kosa8.enc(container), "resize"), body: { cols: cols, rows: rows })
215
+ end
216
+
217
+ # --- previews -------------------------------------------------------------
218
+
219
+ # POST …/previews: a signed link to a web server in the sandbox that expires.
220
+ def create_preview(sandbox, port, session: nil, container: nil, expires_in_s: nil, approval: nil)
221
+ body = { port: port }
222
+ body[:session] = session if session
223
+ body[:container] = container if container
224
+ body[:expires_in_s] = expires_in_s if expires_in_s && expires_in_s.positive?
225
+ @transport.json("POST", sb(sandbox, "previews"), body: body, approval: approval)
226
+ end
227
+
228
+ # POST /kosa8/v1/previews/rotate: end every preview link at once.
229
+ def revoke_previews(approval: nil)
230
+ @transport.json("POST", "/kosa8/v1/previews/rotate", approval: approval)
231
+ end
232
+
233
+ # --- lifecycle ------------------------------------------------------------
234
+
235
+ # POST …/lifecycle: change auto-stop, auto-delete, TTL or ephemeral.
236
+ # Arguments left nil keep their value; 0 turns one off.
237
+ def set_lifecycle(sandbox, auto_stop_min: nil, auto_delete_min: nil, ttl_min: nil, ephemeral: nil, approval: nil)
238
+ body = { auto_stop_min: auto_stop_min, auto_delete_min: auto_delete_min, ttl_min: ttl_min, ephemeral: ephemeral }
239
+ .reject { |_, v| v.nil? }
240
+ @transport.json("POST", sb(sandbox, "lifecycle"), body: body, approval: approval)
241
+ end
242
+
243
+ # --- templates ------------------------------------------------------------
244
+
245
+ def list_templates
246
+ @transport.json("GET", "/kosa8/v1/templates") || []
247
+ end
248
+
249
+ def delete_template(name, approval: nil)
250
+ @transport.json("DELETE", "/kosa8/v1/templates/#{Kosa8.enc(name)}", approval: approval)
251
+ end
252
+
253
+ # POST /kosa8/v1/templates: build context_dir's Dockerfile in a throwaway
254
+ # sandbox and keep the image as a template. Build output goes to on_output
255
+ # (or the block) as it arrives; returns the final frame. A failed build
256
+ # raises Kosa8::StreamError.
257
+ def build_template(name, context_dir = ".", dockerfile: "Dockerfile", build_args: nil, egress: nil, replace: false,
258
+ on_output: nil, approval: nil, &blk)
259
+ on_output ||= blk
260
+ q = [["name", name], ["dockerfile", dockerfile]]
261
+ (build_args || {}).each { |k, v| q << ["buildarg", "#{k}=#{v}"] }
262
+ q << ["egress", egress] if egress
263
+ q << %w[replace 1] if replace
264
+ ctx = Toolbox.tar_dir(context_dir, dockerfile)
265
+ route = "/kosa8/v1/templates?" + URI.encode_www_form(q)
266
+ last = nil
267
+ @transport.frames("POST", route, raw_body: ctx, approval: approval) do |f|
268
+ next if last
269
+
270
+ if f["done"]
271
+ if f["error"].is_a?(String) && !f["error"].empty?
272
+ raise StreamError.new(f["error"], daemon_message: f["error"], method: "POST", path: "/kosa8/v1/templates")
273
+ end
274
+
275
+ last = f
276
+ next
277
+ end
278
+ on_output&.call(f["data"]) if f["data"].is_a?(String) && !f["data"].empty?
279
+ end
280
+ last || raise(StreamError.new("template build stream ended unexpectedly (no final frame)",
281
+ method: "POST", path: "/kosa8/v1/templates"))
282
+ end
283
+
284
+ # Pack a build context the way `kosa8 build` does, honouring .dockerignore
285
+ # prefixes. Returns the tar as a binary String.
286
+ def self.tar_dir(path, dockerfile = "Dockerfile")
287
+ raise Error, "no #{dockerfile} in #{path}" unless File.exist?(File.join(path, dockerfile))
288
+
289
+ ignore = []
290
+ di = File.join(path, ".dockerignore")
291
+ if File.exist?(di)
292
+ ignore = File.readlines(di).reject { |l| l.strip.empty? || l.start_with?("#") }
293
+ .map { |l| Pathname.new(l.strip).cleanpath.to_s }
294
+ end
295
+ skip = ->(rel) { ignore.any? { |p| rel == p || rel.start_with?(p + "/") } }
296
+ io = StringIO.new(+"".b)
297
+ Gem::Package::TarWriter.new(io) do |tar|
298
+ walk = lambda do |dir, rel_dir|
299
+ Dir.children(dir).sort.each do |name|
300
+ full = File.join(dir, name)
301
+ rel = rel_dir.empty? ? name : "#{rel_dir}/#{name}"
302
+ next if skip.call(rel)
303
+
304
+ st = File.lstat(full)
305
+ mode = st.mode & 0o7777
306
+ if st.symlink?
307
+ tar.add_symlink(rel, File.readlink(full), mode)
308
+ elsif st.directory?
309
+ tar.mkdir(rel, mode)
310
+ walk.call(full, rel)
311
+ elsif st.file?
312
+ tar.add_file_simple(rel, mode, st.size) { |w| File.open(full, "rb") { |f| IO.copy_stream(f, w) } }
313
+ end
314
+ end
315
+ end
316
+ walk.call(path, "")
317
+ end
318
+ io.string
319
+ end
320
+
321
+ private
322
+
323
+ def decode_body(data)
324
+ text = data.dup.force_encoding(Encoding::UTF_8).scrub
325
+ return nil if text.strip.empty?
326
+
327
+ JSON.parse(text)
328
+ rescue JSON::ParserError
329
+ text
330
+ end
331
+ end
332
+ end
@@ -4,6 +4,7 @@ require "base64"
4
4
  require "json"
5
5
  require "net/http"
6
6
  require "openssl"
7
+ require "rbconfig"
7
8
  require "socket"
8
9
  require "uri"
9
10
 
@@ -17,8 +18,16 @@ module Kosa8
17
18
  File.join(Dir.home, ".kosa8", "run", "kosa8.sock")
18
19
  end
19
20
 
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
21
+ # $HOME/.kosa8/worker.json: the connection `kosa8 wsl setup` (Windows) and
22
+ # `kosa8 worker join` save, which the CLI uses whenever KOSA8_HOST is unset.
23
+ def self.default_worker_file
24
+ File.join(Dir.home, ".kosa8", "worker.json")
25
+ end
26
+
27
+ # Where requests go: a unix socket or a remote worker. default_socket is true
28
+ # when nothing chose the socket: no host, no saved worker, no socket_path.
29
+ Endpoint = Struct.new(:kind, :socket_path, :scheme, :host, :port, :base_path, :token, :default_socket,
30
+ keyword_init: true) do
22
31
  def target
23
32
  kind == :unix ? "unix://#{socket_path}" : "#{scheme}://#{host}:#{port}"
24
33
  end
@@ -29,22 +38,54 @@ module Kosa8
29
38
  h == "localhost" || h == "::1" || h.start_with?("127.")
30
39
  end
31
40
 
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)
41
+ # The saved worker connection as {"host", "token", "insecure"}, or nil when
42
+ # the file is missing, unreadable, not a JSON object or names no host.
43
+ def self.load_saved_worker(path)
44
+ return nil if path.nil? || path == false || path.to_s.empty?
45
+
46
+ w = JSON.parse(File.read(path.to_s))
47
+ return nil unless w.is_a?(Hash) && w["host"].is_a?(String) && !w["host"].strip.empty?
48
+
49
+ { "host" => w["host"], "token" => w["token"].is_a?(String) ? w["token"] : nil, "insecure" => w["insecure"] == true }
50
+ rescue SystemCallError, IOError, JSON::ParserError, EncodingError
51
+ nil
52
+ end
53
+
54
+ # The CLI's rules (internal/cli/worker.go targetHost/targetToken/targetInsecure):
55
+ # 1. an explicit host:, or a non-blank KOSA8_HOST: https://, http://, tcp://,
56
+ # unix:// or a bare host:port; KOSA8_TOKEN goes only to a KOSA8_HOST host;
57
+ # 2. else, unless socket_path: was given, the saved worker in worker_file
58
+ # (nil = ~/.kosa8/worker.json, false or "" = none), with its token unless
59
+ # token: is given, and its "insecure": true;
60
+ # 3. else the unix socket.
61
+ # Plain http to a non-loopback host is refused unless insecure (or KOSA8_INSECURE=1).
62
+ def self.resolve_endpoint(host: nil, socket_path: nil, token: nil, insecure: false, env: ENV, worker_file: nil)
37
63
  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?
64
+ raw = (explicit ? host : env["KOSA8_HOST"].to_s).strip
65
+ if raw.empty?
66
+ saved = socket_path.nil? ? load_saved_worker(worker_file.nil? ? default_worker_file : worker_file) : nil
67
+ if saved.nil?
68
+ return Endpoint.new(kind: :unix, socket_path: socket_path || default_socket_path,
69
+ default_socket: socket_path.nil?)
70
+ end
71
+
72
+ tok = token.nil? || token.empty? ? saved["token"] : token
73
+ return parse_host(saved["host"].strip, token: tok, insecure: insecure || saved["insecure"], env: env,
74
+ source: "the saved worker host (#{worker_file || default_worker_file})")
75
+ end
76
+ token = env["KOSA8_TOKEN"] if (token.nil? || token.empty?) && !explicit
77
+ parse_host(raw, token: token, insecure: insecure, env: env, source: explicit ? "host" : "KOSA8_HOST")
78
+ end
40
79
 
80
+ def self.parse_host(raw, token:, insecure:, env:, source:)
81
+ raw = raw.sub(%r{/+\z}, "")
41
82
  h = raw.sub(%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}) { "#{Regexp.last_match(1).downcase}://" }
42
83
  if h.start_with?("unix://")
43
- return Endpoint.new(kind: :unix, socket_path: h.sub("unix://", ""))
84
+ return Endpoint.new(kind: :unix, socket_path: h.sub("unix://", ""), default_socket: false)
44
85
  elsif h.start_with?("tcp://")
45
86
  h = "http://" + h.sub("tcp://", "")
46
87
  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://"
88
+ raise Error, "#{source} scheme \"#{Regexp.last_match(1)}\" is not supported; use http://, https://, tcp:// or unix://"
48
89
  elsif h !~ %r{\Ahttps?://}
49
90
  h = "http://" + h
50
91
  end
@@ -53,16 +94,20 @@ module Kosa8
53
94
  rescue URI::InvalidURIError
54
95
  nil
55
96
  end
56
- raise Error, "KOSA8_HOST #{raw.inspect} names no host" if u.nil? || u.host.nil? || u.host.empty?
97
+ raise Error, "#{source} #{raw.inspect} names no host" if u.nil? || u.host.nil? || u.host.empty?
57
98
 
58
99
  if u.scheme == "http" && !insecure && env["KOSA8_INSECURE"] != "1" && !loopback?(u.host)
59
100
  raise Error, "refusing to talk to #{u.host}:#{u.port} over plain http: the token and every request would cross " \
60
101
  "the network unencrypted. Use https://#{u.host}:#{u.port} (a worker behind a TLS-terminating tunnel " \
61
102
  "or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted"
62
103
  end
63
- token = env["KOSA8_TOKEN"] if (token.nil? || token.empty?) && !explicit
64
104
  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)
105
+ token: token && !token.empty? ? token : nil, default_socket: false)
106
+ end
107
+ private_class_method :parse_host
108
+
109
+ def self.windows?
110
+ RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/ ? true : false
66
111
  end
67
112
 
68
113
  def self.enc(s)
@@ -74,6 +119,8 @@ module Kosa8
74
119
  attr_accessor :socket_path
75
120
 
76
121
  def connect
122
+ raise Errno::EAFNOSUPPORT, "unix sockets are not available in this Ruby" unless defined?(UNIXSocket)
123
+
77
124
  io = UNIXSocket.new(@socket_path)
78
125
  @socket = Net::BufferedIO.new(io, read_timeout: @read_timeout, continue_timeout: @continue_timeout,
79
126
  debug_output: @debug_output)
@@ -121,8 +168,9 @@ module Kosa8
121
168
  attr_reader :endpoint
122
169
 
123
170
  def initialize(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil,
124
- headers: {}, timeout: nil, env: ENV)
125
- @endpoint = Kosa8.resolve_endpoint(host: host, socket_path: socket_path, token: token, insecure: insecure, env: env)
171
+ headers: {}, timeout: nil, env: ENV, worker_file: nil)
172
+ @endpoint = Kosa8.resolve_endpoint(host: host, socket_path: socket_path, token: token, insecure: insecure, env: env,
173
+ worker_file: worker_file)
126
174
  raise Error, "actor must be human, agent or tool, not #{actor.inspect}" unless %w[human agent tool].include?(actor)
127
175
 
128
176
  @actor = actor
@@ -177,7 +225,13 @@ module Kosa8
177
225
  raise TimeoutError.new("#{method} #{path} timed out talking to #{@endpoint.target}: #{e.message}",
178
226
  method: method, path: path)
179
227
  rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
180
- hint = e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED) ? " (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`)" : ""
228
+ # Windows reports a missing unix socket path as ENETDOWN.
229
+ absent = e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED) ||
230
+ (Kosa8.windows? && @endpoint.kind == :unix && e.is_a?(Errno::ENETDOWN))
231
+ hint = absent ? " (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`)" : ""
232
+ if @endpoint.kind == :unix && @endpoint.default_socket && Kosa8.windows?
233
+ hint += "; on Windows the engine runs in WSL2: run `kosa8 wsl setup`, or set KOSA8_HOST"
234
+ end
181
235
  raise ConnectionError.new("kosa8 daemon unreachable at #{@endpoint.target}: #{e.message}#{hint}",
182
236
  method: method, path: path)
183
237
  end
data/lib/kosa8/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kosa8
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/kosa8.rb CHANGED
@@ -6,4 +6,6 @@ require_relative "kosa8/version"
6
6
  require_relative "kosa8/errors"
7
7
  require_relative "kosa8/transport"
8
8
  require_relative "kosa8/checkpoints"
9
+ require_relative "kosa8/toolbox"
9
10
  require_relative "kosa8/client"
11
+ require_relative "kosa8/cloud"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kosa8
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kosa8
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-03 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Typed client for the kosa8 daemon: sandboxes, run/exec streams, snapshots,
14
14
  fork, best-of-N explore and checkpoints shared with the kosa8 CLI. No dependencies.'
@@ -22,7 +22,9 @@ files:
22
22
  - lib/kosa8.rb
23
23
  - lib/kosa8/checkpoints.rb
24
24
  - lib/kosa8/client.rb
25
+ - lib/kosa8/cloud.rb
25
26
  - lib/kosa8/errors.rb
27
+ - lib/kosa8/toolbox.rb
26
28
  - lib/kosa8/transport.rb
27
29
  - lib/kosa8/version.rb
28
30
  homepage: https://kosa8.com