xshellz 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fae387e6b5028c2064007a18564008e3e9ee33ecf740f38d038d6e9b37c4b707
4
- data.tar.gz: f26c57baadbe0d628c8e9cd24442b3b846303e9747e2f079995f43f7048eaada
3
+ metadata.gz: 23f86718f159f861cc34d791397cbfdb0aea3c106e24073d976caf20ac5ba014
4
+ data.tar.gz: c3048208aac6d5622c482cf33b812e9c8108e48bfdd29a43bf715530b88c75d1
5
5
  SHA512:
6
- metadata.gz: 1356a6f340d78bc8267dea29fc6712680da93660bc5dd9b42f9119593fcb1d39e9b0b48aaa27130cba6a909e96d9dfbc7fe51867c6f00ff8bce84316a398fb93
7
- data.tar.gz: 6fd6696f8eb7d23fe0035ae7f0381b0702b6d0dd4d0ce1ce6a31c29871f174450e229ce7b1c37582fdf46f9ce9b2431680cf1782660c15364ce05291194e8f25
6
+ metadata.gz: 2a9b1d1545c1c6df00e08134e432bc9fa3a9a07d5abbca41e4b2cc552c1e13a318911bad5d9cd46c3b510d404ce72e3bed56a5ecfc9370bd2b3541d0a78a88cf
7
+ data.tar.gz: 8e83a370828f5cd052b195045bae0c127262dc811c919ebce0f9aa111fd7d8b29fc8d1c2afe31124d6e941abff051bec4d228e50814796cb8c6485cbc752fd97
data/README.md CHANGED
@@ -1,138 +1,196 @@
1
1
  # xshellz
2
2
 
3
- Official Ruby SDK for [xShellz](https://xshellz.com) sandboxes: throwaway,
4
- gVisor-isolated Linux boxes you can spawn and run commands in from your own
5
- program - in three lines.
3
+ [![CI](https://github.com/xshellz/xshellz-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/xshellz/xshellz-ruby/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/xshellz)](https://rubygems.org/gems/xshellz)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- ```bash
8
- gem install xshellz
9
- ```
7
+ The official Ruby SDK for [xShellz](https://xshellz.com) sandboxes: spawn a
8
+ real Linux box from your program, run anything in it, throw it away.
10
9
 
11
- ```ruby
12
- require "xshellz"
10
+ **What is a sandbox?** A sandbox is a small, disposable Linux computer that
11
+ runs in the cloud, isolated from everything else (including your machine), so
12
+ whatever runs inside it cannot touch your files or your network. That makes it
13
+ the safe place to run untrusted things: AI-generated code, build scripts,
14
+ scrapers, other people's projects.
13
15
 
14
- Xshellz::Sandbox.create do |sbx|
15
- result = sbx.run("ruby -e 'puts 6*7'")
16
- puts result.stdout # 42
17
- end
18
- # the box is destroyed when the block exits
19
- ```
16
+ Each xShellz sandbox is a full Linux environment - root shell, package
17
+ manager, network access - running under
18
+ [gVisor](https://gvisor.dev) kernel isolation. Spawning is synchronous:
19
+ `Sandbox.create` returns once the box is reachable, typically in a few
20
+ seconds.
20
21
 
21
- Each sandbox is a real Linux box (root shell, package manager, network) running
22
- under [gVisor](https://gvisor.dev) kernel isolation. Spawning is synchronous -
23
- `Sandbox.create` returns once the box is running, typically in a few seconds.
22
+ ## Quickstart
24
23
 
25
- ## Authentication
24
+ 1. **Install the gem:**
26
25
 
27
- The SDK authenticates with an xShellz personal access token (PAT) carrying the
28
- `read` and `write` scopes:
26
+ ```bash
27
+ gem install xshellz
28
+ ```
29
29
 
30
- 1. Create a token from your [xShellz dashboard](https://app.xshellz.com)
31
- (Settings -> API tokens), or via the API: `POST /v1/auth/tokens`.
32
- 2. Export it:
30
+ 2. **Get an API key.** Sign up at [app.xshellz.com](https://app.xshellz.com),
31
+ then create a personal access token with `read` and `write` scopes under
32
+ Settings -> API tokens (or via the API: `POST /v1/auth/tokens`). Export it:
33
33
 
34
- ```bash
35
- export XSHELLZ_API_KEY="your-token"
36
- ```
34
+ ```bash
35
+ export XSHELLZ_API_KEY="your-token"
36
+ ```
37
37
 
38
- or pass it explicitly: `Xshellz::Sandbox.create(api_key: "your-token")`.
38
+ 3. **Hello world:**
39
39
 
40
- Config precedence: explicit argument > `XSHELLZ_API_KEY` / `XSHELLZ_API_URL`
41
- environment variables > default (`https://api.xshellz.com/v1`).
40
+ ```ruby
41
+ require "xshellz"
42
42
 
43
- To target a staging or self-hosted control plane:
43
+ Xshellz::Sandbox.create do |sbx|
44
+ puts sbx.run("echo hello from $(hostname)").stdout
45
+ end
46
+ # the box is destroyed when the block exits
47
+ ```
44
48
 
45
- ```bash
46
- export XSHELLZ_API_URL="https://api.staging.example.com/v1"
47
- ```
49
+ ## Recipes
48
50
 
49
- ## Usage
50
-
51
- ### Run commands
51
+ ### Run a command
52
52
 
53
53
  ```ruby
54
- Xshellz::Sandbox.create(name: "build-box") do |sbx|
54
+ Xshellz::Sandbox.create do |sbx|
55
55
  r = sbx.run("apt-get update && apt-get install -y jq", timeout: 300)
56
56
  puts r.exit_code, r.stdout, r.stderr
57
57
 
58
- # A non-zero exit code does NOT raise - it's data:
59
- r = sbx.run("false")
60
- r.exit_code # => 1
61
- r.ok? # => false
58
+ # A non-zero exit code does NOT raise - it's data, like a local subprocess:
59
+ sbx.run("false").ok? # => false
62
60
 
63
- # cwd and env:
64
- sbx.run("make test", cwd: "/srv/app", env: { "CI" => "1" })
65
-
66
- # Stream long-running output as it arrives:
67
- sbx.run("npm run build") do |stream, chunk|
68
- warn(chunk) if stream == :stderr
61
+ # cwd, env, and live output streaming:
62
+ sbx.run("make test", cwd: "/srv/app", env: { "CI" => "1" }) do |stream, chunk|
69
63
  print(chunk) if stream == :stdout
70
64
  end
71
65
  end
72
66
  ```
73
67
 
74
- ### Files (SFTP)
68
+ ### A permanent named box that survives restarts
69
+
70
+ `get_or_create` finds your box by name, or creates it, and remembers the SSH
71
+ key for you in `~/.xshellz/keys/` - so the same line works on every run, from
72
+ any process, forever:
73
+
74
+ ```ruby
75
+ sbx = Xshellz::Sandbox.get_or_create("my-dev-box")
76
+ sbx.run("echo persists across runs > ~/note.txt")
77
+ # ... later, in a different script or on a different day:
78
+ sbx = Xshellz::Sandbox.get_or_create("my-dev-box") # same box, same files
79
+ ```
80
+
81
+ A stopped (idled) box is started automatically. Keys are plaintext files with
82
+ `0600` permissions - delete the key file and `kill` the box to revoke, or pass
83
+ `keystore: false` to keep keys purely in memory.
84
+
85
+ ### Run a background job
86
+
87
+ `spawn` starts a process that keeps running after your script disconnects:
88
+
89
+ ```ruby
90
+ job = sbx.spawn("python3 train.py", name: "train")
91
+ job.pid # => 4242
92
+ job.running? # => true
93
+ job.logs(tail_lines: 20) # last 20 lines of combined stdout+stderr
94
+ job.stop # SIGTERM, then SIGKILL after a grace period
95
+
96
+ sbx.jobs.each { |j| puts "#{j.id} running=#{j.running?}" }
97
+ ```
98
+
99
+ ### Run AI-generated code safely
100
+
101
+ `run_code` writes a snippet to a temp file in the sandbox, executes the right
102
+ interpreter, and always cleans the file up. If the code is malicious, it can
103
+ only harm a disposable box:
104
+
105
+ ```ruby
106
+ result = sbx.run_code("python", generated_code, timeout: 60)
107
+ puts result.stdout
108
+ ```
109
+
110
+ Supported languages: `python` (python3), `node`, `bash`, `ruby`, `php`.
111
+
112
+ ### Files: up and down
75
113
 
76
114
  ```ruby
77
115
  sbx.write_file("/tmp/config.json", '{"debug": true}')
78
- data = sbx.read_file("/tmp/config.json") # => String
116
+ sbx.read_file("/tmp/config.json") # => String
79
117
 
80
118
  sbx.upload("local.txt", "/tmp/remote.txt")
81
119
  sbx.download("/tmp/remote.txt", "out.txt")
82
120
  ```
83
121
 
84
- ### Lifecycle
122
+ ### Check resource usage
85
123
 
86
124
  ```ruby
87
- sbx.uuid # sandbox id
88
- sbx.ssh_host # e.g. "shellus1.xshellz.com"
89
- sbx.ssh_port # e.g. 42001
90
- sbx.ssh_command # ready-to-copy "ssh -p 42001 root@..."
91
- sbx.status # "running", "stopped", ...
92
-
93
- sbx.detach # keep the box alive after the create block
94
- sbx.kill # destroy the box explicitly
95
- sbx.start # resume an idle-stopped box
96
-
97
- # Non-block form: you own the lifecycle.
98
- sbx = Xshellz::Sandbox.create
99
- sbx.run("uname -a")
100
- sbx.kill
101
-
102
- # Re-attach later (persist sbx.private_key_openssh + sbx.uuid for this):
103
- sbx = Xshellz::Sandbox.connect(uuid, private_key: saved_private_key)
104
-
105
- # Enumerate your sandboxes:
106
- Xshellz::Sandbox.list.each do |info|
107
- puts "#{info.uuid} #{info.status}"
108
- end
125
+ stats = sbx.stats
126
+ puts "mem #{stats.mem_used_mb}/#{stats.mem_allowed_mb} MB, cpu #{stats.cpu_percent}%"
127
+
128
+ top = sbx.procs
129
+ top.procs.each { |p| puts "#{p.pid} #{p.comm} #{p.cpu}%" }
109
130
  ```
110
131
 
111
- ### Typed errors
132
+ ### Open a web terminal
133
+
134
+ Get a browser-ready root shell for the box - no SSH client needed. The signed
135
+ URL expires after about an hour; mint a fresh one each time:
112
136
 
113
137
  ```ruby
114
- begin
115
- sbx = Xshellz::Sandbox.create
116
- rescue Xshellz::QuotaError
117
- # plan limit reached - attach to the existing box instead
118
- existing = Xshellz::Sandbox.list.first
119
- sbx = Xshellz::Sandbox.connect(existing.uuid, private_key: saved_key)
120
- rescue Xshellz::AuthError => e
121
- puts e.message # missing/invalid token, scope, or account verification
122
- end
138
+ puts sbx.terminal_url
123
139
  ```
124
140
 
125
- - `Xshellz::Error` - base class for everything the SDK raises
126
- - `Xshellz::AuthError` - 401/403: bad or missing token, scopes, account gates
127
- - `Xshellz::QuotaError` - plan sandbox limit reached / plan has no sandbox entitlement
128
- - `Xshellz::SandboxNotRunningError` - operation needs a `running` box
129
- - `Xshellz::CommandTimeoutError` - `run(timeout: ...)` exceeded
130
- - `Xshellz::ApiError` - any other 4xx/5xx (carries `.status` and `.body`)
141
+ ### Provision every new box the same way (boxfile)
142
+
143
+ The account-level boxfile is a package manifest applied whenever a NEW box is
144
+ created - preinstall your dependencies once:
145
+
146
+ ```ruby
147
+ Xshellz::Sandbox.set_boxfile(<<~BOX)
148
+ apt jq ripgrep
149
+ pip requests
150
+ BOX
151
+ Xshellz::Sandbox.get_boxfile # => the saved manifest
152
+ ```
153
+
154
+ ## API reference
155
+
156
+ Every public method, its parameters, return shapes, and errors are documented
157
+ in **[docs/API.md](docs/API.md)**.
158
+
159
+ ## Configuration
160
+
161
+ | Environment variable | Meaning | Default |
162
+ |---|---|---|
163
+ | `XSHELLZ_API_KEY` | Personal access token (`read` + `write` scopes) | - (required) |
164
+ | `XSHELLZ_API_URL` | Control-plane base URL | `https://api.xshellz.com/v1` |
165
+
166
+ Explicit arguments (`api_key:`, `api_url:`) always win over the environment.
167
+
168
+ ## Error types
169
+
170
+ | Error | Meaning |
171
+ |---|---|
172
+ | `Xshellz::Error` | Base class for everything the SDK raises |
173
+ | `Xshellz::AuthError` | 401/403: bad or missing token, scopes, account gates |
174
+ | `Xshellz::QuotaError` | Plan sandbox limit reached / plan has no sandbox entitlement |
175
+ | `Xshellz::MissingKeyError` | `get_or_create` found the box but no private key for it |
176
+ | `Xshellz::SandboxNotRunningError` | Operation needs a `running` box |
177
+ | `Xshellz::CommandTimeoutError` | `run(timeout: ...)` exceeded |
178
+ | `Xshellz::UnsupportedLanguageError` | `run_code` got a language it doesn't know |
179
+ | `Xshellz::ApiError` | Any other 4xx/5xx (carries `.status` and `.body`) |
180
+
181
+ ## v0 limits
182
+
183
+ - **Free tier: 1 concurrent sandbox.** A second `Sandbox.create` raises
184
+ `Xshellz::QuotaError` while one exists - attach to the existing box instead
185
+ (`get_or_create` / `Sandbox.connect`), or `kill` it first.
186
+ - **Free boxes idle-stop after ~30 minutes.** The box (its `/home` and your
187
+ key) is preserved; `sbx.start` - or `get_or_create` - resumes it.
188
+ - Sandbox creation is throttled to 10 requests/minute per account.
131
189
 
132
190
  ## How it works
133
191
 
134
192
  - **Control plane**: HTTPS to `api.xshellz.com/v1` (create / list / start /
135
- destroy), authenticated by your PAT.
193
+ destroy / stats), authenticated by your token.
136
194
  - **Data plane**: SSH directly to the box as `root` (net-ssh), files over
137
195
  SFTP (net-sftp). `Sandbox.create` generates an in-memory ed25519 keypair
138
196
  per sandbox; the private key never leaves your process and the server never
@@ -142,20 +200,11 @@ end
142
200
  pin. If your threat model requires host-key verification, connect manually
143
201
  with your own SSH tooling using `sbx.ssh_command`.
144
202
 
145
- ## v0 limits
146
-
147
- - **Free tier: 1 concurrent sandbox.** A second `Sandbox.create` raises
148
- `Xshellz::QuotaError` while one exists - use `Sandbox.list` +
149
- `Sandbox.connect` to attach to the existing box, or `kill` it first. Paid
150
- plans raise the limit.
151
- - **Free boxes idle-stop after ~30 minutes.** The box (its `/home` and your
152
- key) is preserved; call `sbx.start` to resume it.
153
- - Sandbox creation is throttled to 10 requests/minute per account.
154
-
155
203
  ## Local development (Docker)
156
204
 
157
- No local Ruby toolchain is required - the whole test suite runs inside a
158
- `ruby:3.3` container (gems cached in a named volume):
205
+ No local Ruby toolchain is required - the whole test suite (with the >= 80%
206
+ coverage gate) runs inside a `ruby:3.3` container, gems cached in a named
207
+ volume:
159
208
 
160
209
  ```bash
161
210
  docker compose run --rm test
@@ -86,6 +86,10 @@ module Xshellz
86
86
  request("POST", path, body)
87
87
  end
88
88
 
89
+ def put(path, body = nil)
90
+ request("PUT", path, body)
91
+ end
92
+
89
93
  def delete(path)
90
94
  request("DELETE", path)
91
95
  end
@@ -22,6 +22,15 @@ module Xshellz
22
22
  # The sandbox is not in the +running+ state (or no longer exists).
23
23
  class SandboxNotRunningError < Error; end
24
24
 
25
+ # +Sandbox.get_or_create+ found an existing sandbox by name but no private
26
+ # key for it: none was passed explicitly and the keystore has no key file
27
+ # (or the keystore is disabled). The message says where a key was expected.
28
+ class MissingKeyError < Error; end
29
+
30
+ # +Sandbox#run_code+ was called with a language it does not know how to
31
+ # execute. The message lists the supported languages.
32
+ class UnsupportedLanguageError < Error; end
33
+
25
34
  # A command executed with +run(timeout: ...)+ exceeded its deadline.
26
35
  class CommandTimeoutError < Error; end
27
36
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xshellz
4
+ # A background process started with +Sandbox#spawn+ (or listed by
5
+ # +Sandbox#jobs+).
6
+ #
7
+ # The process runs detached inside the sandbox (+nohup+, disowned) and its
8
+ # combined stdout+stderr goes to +log_path+ (+~/.xshellz/jobs/<id>.log+ in
9
+ # the box). The handle survives SDK restarts: +Sandbox#jobs+ rebuilds
10
+ # handles from the pid files the spawn command leaves behind.
11
+ class JobHandle
12
+ STOP_POLL_INTERVAL = 0.2
13
+
14
+ # Short job id (name-prefixed when +spawn+ was given a name).
15
+ attr_reader :id
16
+
17
+ # Pid of the detached process inside the sandbox.
18
+ attr_reader :pid
19
+
20
+ # Path of the job's log file inside the sandbox.
21
+ attr_reader :log_path
22
+
23
+ def initialize(sandbox:, id:, pid:)
24
+ @sandbox = sandbox
25
+ @id = id
26
+ @pid = Integer(pid)
27
+ @log_path = "~/.xshellz/jobs/#{id}.log"
28
+ end
29
+
30
+ # Live probe: is the process still running? (+kill -0+ inside the box.)
31
+ def running?
32
+ @sandbox.run("kill -0 #{pid} 2>/dev/null").ok?
33
+ end
34
+
35
+ # The last +tail_lines+ lines of the job's log (stdout+stderr combined).
36
+ def logs(tail_lines: 100)
37
+ @sandbox.run("tail -n #{Integer(tail_lines)} #{log_path} 2>/dev/null").stdout
38
+ end
39
+
40
+ # Stop the job: SIGTERM, then SIGKILL if it is still alive after +grace+
41
+ # seconds. Returns true when the process exited from the SIGTERM alone.
42
+ def stop(grace: 5)
43
+ @sandbox.run("kill -TERM #{pid} 2>/dev/null")
44
+ deadline = monotonic + grace
45
+ loop do
46
+ return true unless running?
47
+
48
+ if monotonic >= deadline
49
+ @sandbox.run("kill -KILL #{pid} 2>/dev/null")
50
+ return false
51
+ end
52
+ sleep(STOP_POLL_INTERVAL)
53
+ end
54
+ end
55
+
56
+ def inspect
57
+ "#<Xshellz::JobHandle id=#{id.inspect} pid=#{pid} log=#{log_path.inspect}>"
58
+ end
59
+ alias to_s inspect
60
+
61
+ private
62
+
63
+ def monotonic
64
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Xshellz
6
+ # Local on-disk private-key store backing +Sandbox.get_or_create+.
7
+ #
8
+ # One file per sandbox name (sanitized), OpenSSH private key inside,
9
+ # +0600+ permissions, in +~/.xshellz/keys/+ by default.
10
+ #
11
+ # Security note: keys are stored in PLAINTEXT on disk, protected only by
12
+ # file permissions (0600, directory 0700). Anyone who can read the file can
13
+ # SSH into the sandbox. Delete the file (+delete+) and kill the box to
14
+ # revoke; pass +keystore: false+ to +get_or_create+ to keep keys purely
15
+ # in-memory instead.
16
+ class Keystore
17
+ # Characters allowed to survive sanitization into a key file name.
18
+ SAFE_NAME = /[^A-Za-z0-9._-]+/
19
+
20
+ attr_reader :dir
21
+
22
+ def self.default_dir
23
+ File.join(Dir.home, ".xshellz", "keys")
24
+ end
25
+
26
+ # Turn a sandbox name into a safe file basename (no separators, no
27
+ # leading dots - so no traversal and no hidden files).
28
+ def self.sanitize(name)
29
+ cleaned = name.to_s.gsub(SAFE_NAME, "_").sub(/\A\.+/, "_")
30
+ raise ArgumentError, "Cannot derive a key file name from #{name.inspect}" if cleaned.empty?
31
+
32
+ cleaned
33
+ end
34
+
35
+ def initialize(dir: self.class.default_dir)
36
+ @dir = File.expand_path(dir)
37
+ end
38
+
39
+ # The absolute path where +name+'s key lives (whether or not it exists).
40
+ def path_for(name)
41
+ File.join(dir, "#{self.class.sanitize(name)}.key")
42
+ end
43
+
44
+ # Persist an OpenSSH private key for +name+ (0600). Returns the path.
45
+ def save(name, private_key_openssh)
46
+ FileUtils.mkdir_p(dir, mode: 0o700)
47
+ path = path_for(name)
48
+ File.write(path, private_key_openssh, perm: 0o600)
49
+ File.chmod(0o600, path)
50
+ path
51
+ end
52
+
53
+ # The stored key for +name+, or nil when none exists.
54
+ def load(name)
55
+ path = path_for(name)
56
+ File.file?(path) ? File.read(path) : nil
57
+ end
58
+
59
+ # Remove the stored key for +name+. Returns true when a file was deleted.
60
+ def delete(name)
61
+ path = path_for(name)
62
+ return false unless File.file?(path)
63
+
64
+ File.delete(path)
65
+ true
66
+ end
67
+ end
68
+ end
@@ -53,4 +53,88 @@ module Xshellz
53
53
  )
54
54
  end
55
55
  end
56
+
57
+ # Live resource usage for a running sandbox (+Sandbox#stats+), mirroring
58
+ # the control plane's wire fields. +*_allowed_*+ fields are the plan
59
+ # ceilings so used/allowed gauges need no second call.
60
+ SandboxStats = Struct.new(
61
+ :mem_used_mb,
62
+ :mem_limit_mb,
63
+ :mem_allowed_mb,
64
+ :cpu_percent,
65
+ :cpu_allowed_vcpus,
66
+ :cpu_throttled_periods,
67
+ :pids_current,
68
+ :pids_allowed,
69
+ :disk_used_mb,
70
+ :disk_allowed_mb,
71
+ :net_rx_mb,
72
+ :net_tx_mb,
73
+ :blk_read_mb,
74
+ :blk_write_mb,
75
+ keyword_init: true
76
+ ) do
77
+ # Build a +SandboxStats+ from the API's JSON payload (tolerant of
78
+ # missing keys).
79
+ def self.from_api(payload)
80
+ payload ||= {}
81
+ new(
82
+ mem_used_mb: (payload["mem_used_mb"] || 0).to_i,
83
+ mem_limit_mb: (payload["mem_limit_mb"] || 0).to_i,
84
+ mem_allowed_mb: (payload["mem_allowed_mb"] || 0).to_i,
85
+ cpu_percent: (payload["cpu_percent"] || 0.0).to_f,
86
+ cpu_allowed_vcpus: (payload["cpu_allowed_vcpus"] || 0.0).to_f,
87
+ cpu_throttled_periods: (payload["cpu_throttled_periods"] || 0).to_i,
88
+ pids_current: (payload["pids_current"] || 0).to_i,
89
+ pids_allowed: (payload["pids_allowed"] || 0).to_i,
90
+ disk_used_mb: (payload["disk_used_mb"] || 0).to_i,
91
+ disk_allowed_mb: (payload["disk_allowed_mb"] || 0).to_i,
92
+ net_rx_mb: (payload["net_rx_mb"] || 0).to_i,
93
+ net_tx_mb: (payload["net_tx_mb"] || 0).to_i,
94
+ blk_read_mb: (payload["blk_read_mb"] || 0).to_i,
95
+ blk_write_mb: (payload["blk_write_mb"] || 0).to_i
96
+ )
97
+ end
98
+ end
99
+
100
+ # One process row from +Sandbox#procs+.
101
+ ProcessInfo = Struct.new(:pid, :comm, :cpu, :mem, keyword_init: true) do
102
+ def self.from_api(payload)
103
+ payload ||= {}
104
+ new(
105
+ pid: (payload["pid"] || 0).to_i,
106
+ comm: payload["comm"].to_s,
107
+ cpu: (payload["cpu"] || 0.0).to_f,
108
+ mem: (payload["mem"] || 0.0).to_f
109
+ )
110
+ end
111
+ end
112
+
113
+ # Top processes + session info for a running sandbox (+Sandbox#procs+).
114
+ #
115
+ # +procs+ is an Array<ProcessInfo>; +sessions+ counts active SSH sessions;
116
+ # +agents+ lists detected agent processes (e.g. "claude") by name.
117
+ SandboxProcs = Struct.new(
118
+ :procs,
119
+ :sessions,
120
+ :agents,
121
+ :disk_used_mb,
122
+ :disk_allowed_mb,
123
+ keyword_init: true
124
+ ) do
125
+ # Build a +SandboxProcs+ from the API's JSON payload (tolerant of
126
+ # missing keys).
127
+ def self.from_api(payload)
128
+ payload ||= {}
129
+ rows = payload["procs"]
130
+ rows = [] unless rows.is_a?(Array)
131
+ new(
132
+ procs: rows.map { |row| ProcessInfo.from_api(row) },
133
+ sessions: (payload["sessions"] || 0).to_i,
134
+ agents: Array(payload["agents"]).map(&:to_s),
135
+ disk_used_mb: (payload["disk_used_mb"] || 0).to_i,
136
+ disk_allowed_mb: (payload["disk_allowed_mb"] || 0).to_i
137
+ )
138
+ end
139
+ end
56
140
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "shellwords"
5
+
3
6
  module Xshellz
4
7
  # A remote sandbox: control plane over HTTPS, data plane over SSH.
5
8
  #
@@ -14,6 +17,18 @@ module Xshellz
14
17
  # end
15
18
  class Sandbox
16
19
  STATUS_RUNNING = "running"
20
+ STATUS_STOPPED = "stopped"
21
+
22
+ JOBS_DIR = "~/.xshellz/jobs"
23
+
24
+ # Interpreters +run_code+ knows how to execute, keyed by language name.
25
+ RUN_CODE_LANGUAGES = {
26
+ "python" => { command: "python3", extension: ".py" },
27
+ "node" => { command: "node", extension: ".js" },
28
+ "bash" => { command: "bash", extension: ".sh" },
29
+ "ruby" => { command: "ruby", extension: ".rb" },
30
+ "php" => { command: "php", extension: ".php" }
31
+ }.freeze
17
32
 
18
33
  # The last-known control-plane state (a SandboxInfo).
19
34
  attr_reader :info
@@ -83,6 +98,56 @@ module Xshellz
83
98
  new(info: info, client: client, private_key_openssh: private_key)
84
99
  end
85
100
 
101
+ # Get-or-create a PERMANENT named sandbox: find the account's sandbox
102
+ # whose name is exactly +name+, or create one with that name.
103
+ #
104
+ # The private key is persisted in a local keystore
105
+ # (+~/.xshellz/keys/<name>.key+, 0600) so a later +get_or_create+ from
106
+ # another process can re-attach without you handling key material:
107
+ #
108
+ # - Not found -> create; persist the generated key in the keystore.
109
+ # - Found -> attach with the explicit +private_key+ if given, else the
110
+ # keystore's key; a stopped box is started first. When no key can be
111
+ # found, raises MissingKeyError telling you where one was expected.
112
+ #
113
+ # +keystore+ accepts +:default+ (=> +~/.xshellz/keys/+), a directory
114
+ # String, a +Keystore+ instance, or +false+/+nil+ to disable persistence
115
+ # entirely (then +get_or_create+ can only create-or-error, unless you
116
+ # pass +private_key+).
117
+ #
118
+ # Security note: the keystore holds plaintext private keys on disk
119
+ # (0600). Delete the key file and kill the box to revoke.
120
+ def self.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default)
121
+ name = name.to_s
122
+ raise ArgumentError, "get_or_create requires a non-empty sandbox name" if name.empty?
123
+
124
+ store = resolve_keystore(keystore)
125
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
126
+
127
+ found = client.get("/shells/agent")
128
+ .map { |item| SandboxInfo.from_api(item) }
129
+ .find { |info| info.name == name }
130
+ return create_named(client, name, store) if found.nil?
131
+
132
+ key = private_key || store&.load(name)
133
+ if key.nil?
134
+ hint = if store.nil?
135
+ "the keystore is disabled, so pass private_key: ..."
136
+ else
137
+ "expected a key file at #{store.path_for(name)}"
138
+ end
139
+ raise MissingKeyError,
140
+ "Sandbox #{name.inspect} already exists but no private key is " \
141
+ "available to attach to it (#{hint}). Pass private_key: ..., " \
142
+ "or kill the box and let get_or_create recreate it."
143
+ end
144
+ Ssh.load_private_key!(key)
145
+
146
+ sandbox = new(info: found, client: client, private_key_openssh: key)
147
+ sandbox.start if sandbox.status == STATUS_STOPPED
148
+ sandbox
149
+ end
150
+
86
151
  # List the account's active sandboxes (a bare array on the wire).
87
152
  #
88
153
  # @return [Array<SandboxInfo>]
@@ -91,6 +156,26 @@ module Xshellz
91
156
  client.get("/shells/agent").map { |item| SandboxInfo.from_api(item) }
92
157
  end
93
158
 
159
+ # The account's saved +xshellz.box+ provisioning manifest, or nil when
160
+ # none is saved (+GET /shells/agent/boxfile+).
161
+ #
162
+ # The boxfile is applied when a NEW box is created: it is seeded into
163
+ # +~/xshellz.box+ on every fresh box, so destroy+recreate reproduces
164
+ # your package environment (preinstall deps, etc.).
165
+ def self.get_boxfile(api_key: nil, api_url: nil, http: nil)
166
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
167
+ (client.get("/shells/agent/boxfile") || {})["manifest"]
168
+ end
169
+
170
+ # Save the account's +xshellz.box+ manifest (+PUT /shells/agent/boxfile+,
171
+ # max 16 KB). Pass nil (or a blank string) to clear it. Returns the
172
+ # stored manifest (nil when cleared). Applies to boxes created AFTER the
173
+ # save - existing boxes are not touched.
174
+ def self.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil)
175
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
176
+ (client.put("/shells/agent/boxfile", { "manifest" => manifest }) || {})["manifest"]
177
+ end
178
+
94
179
  # Resolve one sandbox via the list endpoint (there is no GET show route).
95
180
  def self.find(client, uuid)
96
181
  client.get("/shells/agent").each do |item|
@@ -101,6 +186,33 @@ module Xshellz
101
186
  "Sandbox #{uuid} was not found among this account's active sandboxes."
102
187
  end
103
188
 
189
+ class << self
190
+ private
191
+
192
+ def resolve_keystore(keystore)
193
+ case keystore
194
+ when :default then Keystore.new
195
+ when nil, false then nil
196
+ when String then Keystore.new(dir: keystore)
197
+ else keystore
198
+ end
199
+ end
200
+
201
+ def create_named(client, name, store)
202
+ keypair = Keys.generate
203
+ payload = client.post(
204
+ "/shells/agent",
205
+ { "ssh_public_key" => keypair.public_key_line, "name" => name }
206
+ )
207
+ store&.save(name, keypair.private_key_openssh)
208
+ new(
209
+ info: SandboxInfo.from_api(payload),
210
+ client: client,
211
+ private_key_openssh: keypair.private_key_openssh
212
+ )
213
+ end
214
+ end
215
+
104
216
  # ------------------------------------------------------------------ #
105
217
  # Properties
106
218
  # ------------------------------------------------------------------ #
@@ -171,6 +283,76 @@ module Xshellz
171
283
  transport.download(remote_path, local_path)
172
284
  end
173
285
 
286
+ # Start +command+ as a detached background process inside the sandbox
287
+ # and return immediately with a JobHandle.
288
+ #
289
+ # The process survives this SDK disconnecting (nohup, stdin from
290
+ # /dev/null); combined stdout+stderr goes to +~/.xshellz/jobs/<id>.log+
291
+ # in the box. +name+ (optional) prefixes the generated job id.
292
+ #
293
+ # @return [JobHandle]
294
+ def spawn(command, name: nil)
295
+ job_id = generate_job_id(name)
296
+ script = "mkdir -p #{JOBS_DIR} && " \
297
+ "nohup bash -c #{Shellwords.escape(command)} " \
298
+ "> #{JOBS_DIR}/#{job_id}.log 2>&1 < /dev/null & " \
299
+ "echo $! > #{JOBS_DIR}/#{job_id}.pid; echo $!"
300
+ result = transport.exec(script)
301
+ pid = result.stdout[/\d+/]
302
+ if !result.ok? || pid.nil?
303
+ raise Error,
304
+ "Failed to spawn background job #{job_id.inspect} " \
305
+ "(exit #{result.exit_code}): #{result.stderr.strip}"
306
+ end
307
+
308
+ JobHandle.new(sandbox: self, id: job_id, pid: pid)
309
+ end
310
+
311
+ # List the sandbox's background jobs (every job ever spawned whose
312
+ # +~/.xshellz/jobs/<id>.pid+ file still exists). Check liveness per
313
+ # handle with +running?+; read output with +logs+.
314
+ #
315
+ # @return [Array<JobHandle>]
316
+ def jobs
317
+ listing = transport.exec(
318
+ "mkdir -p #{JOBS_DIR} && for f in #{JOBS_DIR}/*.pid; do " \
319
+ '[ -e "$f" ] || continue; ' \
320
+ 'printf "%s %s\n" "$(basename "$f" .pid)" "$(cat "$f")"; done'
321
+ )
322
+ listing.stdout.each_line.filter_map do |line|
323
+ id, pid = line.split
324
+ next if id.nil? || pid.nil? || !pid.match?(/\A\d+\z/)
325
+
326
+ JobHandle.new(sandbox: self, id: id, pid: pid)
327
+ end
328
+ end
329
+
330
+ # Execute a snippet of code in the sandbox: write it to a temp file,
331
+ # run the matching interpreter, always delete the temp file.
332
+ #
333
+ # Supported languages: python (python3), node, bash, ruby, php. An
334
+ # unknown language raises UnsupportedLanguageError. Semantics otherwise
335
+ # match +run+ (same cwd/env/timeout/stream-block, non-zero exit is
336
+ # data, returns a CommandResult).
337
+ #
338
+ # @return [CommandResult]
339
+ def run_code(language, code, cwd: nil, env: nil, timeout: nil, &block)
340
+ interpreter = RUN_CODE_LANGUAGES[language.to_s.downcase]
341
+ if interpreter.nil?
342
+ raise UnsupportedLanguageError,
343
+ "Unsupported run_code language #{language.inspect}. " \
344
+ "Supported languages: #{RUN_CODE_LANGUAGES.keys.join(", ")}."
345
+ end
346
+
347
+ path = "/tmp/xshellz-run-#{SecureRandom.hex(6)}#{interpreter[:extension]}"
348
+ write_file(path, code)
349
+ begin
350
+ run("#{interpreter[:command]} #{Shellwords.escape(path)}", cwd: cwd, env: env, timeout: timeout, &block)
351
+ ensure
352
+ delete_remote_file_quietly(path)
353
+ end
354
+ end
355
+
174
356
  # ------------------------------------------------------------------ #
175
357
  # Lifecycle (control plane)
176
358
  # ------------------------------------------------------------------ #
@@ -195,6 +377,44 @@ module Xshellz
195
377
  self
196
378
  end
197
379
 
380
+ # Reboot a running box (+POST /shells/agent/{uuid}/restart+): re-runs
381
+ # the entrypoint; +/home+ is preserved. Returns self with refreshed
382
+ # info; the SSH connection is re-established on the next data-plane
383
+ # call.
384
+ def restart
385
+ payload = @client.post("/shells/agent/#{uuid}/restart")
386
+ @info = SandboxInfo.from_api(payload)
387
+ close_transport
388
+ self
389
+ end
390
+
391
+ # Live resource usage (+GET /shells/agent/{uuid}/stats+): memory, CPU,
392
+ # pids, disk, network and block IO, each alongside the plan-allowed
393
+ # ceiling.
394
+ #
395
+ # @return [SandboxStats]
396
+ def stats
397
+ SandboxStats.from_api(@client.get("/shells/agent/#{uuid}/stats"))
398
+ end
399
+
400
+ # Top processes + active SSH session count inside the box
401
+ # (+GET /shells/agent/{uuid}/procs+).
402
+ #
403
+ # @return [SandboxProcs]
404
+ def procs
405
+ SandboxProcs.from_api(@client.get("/shells/agent/#{uuid}/procs"))
406
+ end
407
+
408
+ # Mint a fresh signed web-terminal URL for this box
409
+ # (+GET /shells/agent/{uuid}/terminal+). Open it in a browser for a
410
+ # root shell - no SSH client needed. The embedded HMAC token expires
411
+ # after ~1 hour; call again for a fresh URL rather than storing it.
412
+ #
413
+ # @return [String]
414
+ def terminal_url
415
+ (@client.get("/shells/agent/#{uuid}/terminal") || {})["url"].to_s
416
+ end
417
+
198
418
  # Destroy the sandbox (+DELETE /shells/agent/{uuid}+). Idempotent: a 404
199
419
  # (already gone) is swallowed.
200
420
  def kill
@@ -266,5 +486,20 @@ module Xshellz
266
486
  @transport&.close
267
487
  @transport = nil
268
488
  end
489
+
490
+ # A short unique job id, safe to interpolate into shell paths
491
+ # unquoted; prefixed with the sanitized job name when one was given.
492
+ def generate_job_id(name)
493
+ suffix = SecureRandom.hex(4)
494
+ return suffix if name.to_s.empty?
495
+
496
+ "#{Keystore.sanitize(name)}-#{suffix}"
497
+ end
498
+
499
+ def delete_remote_file_quietly(path)
500
+ transport.exec("rm -f #{Shellwords.escape(path)}")
501
+ rescue StandardError
502
+ nil
503
+ end
269
504
  end
270
505
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Xshellz
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/xshellz.rb CHANGED
@@ -4,8 +4,10 @@ require_relative "xshellz/version"
4
4
  require_relative "xshellz/errors"
5
5
  require_relative "xshellz/models"
6
6
  require_relative "xshellz/keys"
7
+ require_relative "xshellz/keystore"
7
8
  require_relative "xshellz/client"
8
9
  require_relative "xshellz/ssh"
10
+ require_relative "xshellz/job_handle"
9
11
  require_relative "xshellz/sandbox"
10
12
 
11
13
  # xShellz Ruby SDK - throwaway, gVisor-isolated Linux sandboxes.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xshellz
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - xShellz
@@ -78,7 +78,9 @@ files:
78
78
  - lib/xshellz.rb
79
79
  - lib/xshellz/client.rb
80
80
  - lib/xshellz/errors.rb
81
+ - lib/xshellz/job_handle.rb
81
82
  - lib/xshellz/keys.rb
83
+ - lib/xshellz/keystore.rb
82
84
  - lib/xshellz/models.rb
83
85
  - lib/xshellz/sandbox.rb
84
86
  - lib/xshellz/ssh.rb