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 +4 -4
- data/README.md +150 -101
- data/lib/xshellz/client.rb +4 -0
- data/lib/xshellz/errors.rb +9 -0
- data/lib/xshellz/job_handle.rb +67 -0
- data/lib/xshellz/keystore.rb +68 -0
- data/lib/xshellz/models.rb +84 -0
- data/lib/xshellz/sandbox.rb +235 -0
- data/lib/xshellz/version.rb +1 -1
- data/lib/xshellz.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23f86718f159f861cc34d791397cbfdb0aea3c106e24073d976caf20ac5ba014
|
|
4
|
+
data.tar.gz: c3048208aac6d5622c482cf33b812e9c8108e48bfdd29a43bf715530b88c75d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a9b1d1545c1c6df00e08134e432bc9fa3a9a07d5abbca41e4b2cc552c1e13a318911bad5d9cd46c3b510d404ce72e3bed56a5ecfc9370bd2b3541d0a78a88cf
|
|
7
|
+
data.tar.gz: 8e83a370828f5cd052b195045bae0c127262dc811c919ebce0f9aa111fd7d8b29fc8d1c2afe31124d6e941abff051bec4d228e50814796cb8c6485cbc752fd97
|
data/README.md
CHANGED
|
@@ -1,138 +1,196 @@
|
|
|
1
1
|
# xshellz
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](https://github.com/xshellz/xshellz-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/xshellz)
|
|
5
|
+
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
1. **Install the gem:**
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
```bash
|
|
27
|
+
gem install xshellz
|
|
28
|
+
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
+
3. **Hello world:**
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
```ruby
|
|
41
|
+
require "xshellz"
|
|
42
42
|
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
export XSHELLZ_API_URL="https://api.staging.example.com/v1"
|
|
47
|
-
```
|
|
49
|
+
## Recipes
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
### Run commands
|
|
51
|
+
### Run a command
|
|
52
52
|
|
|
53
53
|
```ruby
|
|
54
|
-
Xshellz::Sandbox.create
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
###
|
|
122
|
+
### Check resource usage
|
|
85
123
|
|
|
86
124
|
```ruby
|
|
87
|
-
sbx.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
sbx.
|
|
91
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
158
|
-
`ruby:3.3` container
|
|
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
|
data/lib/xshellz/client.rb
CHANGED
data/lib/xshellz/errors.rb
CHANGED
|
@@ -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
|
data/lib/xshellz/models.rb
CHANGED
|
@@ -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
|
data/lib/xshellz/sandbox.rb
CHANGED
|
@@ -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
|
data/lib/xshellz/version.rb
CHANGED
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.
|
|
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
|