Class: Xshellz::Sandbox

Inherits:
Object
  • Object
show all
Defined in:
lib/xshellz/sandbox.rb

Overview

A remote sandbox: control plane over HTTPS, data plane over SSH.

Create one with Sandbox.create, attach to an existing one with Sandbox.connect, or enumerate them with Sandbox.list.

The block form destroys the sandbox when the block exits, unless detach was called:

Xshellz::Sandbox.create do |sbx|
result = sbx.run("echo hello")
end

Constant Summary collapse

STATUS_RUNNING =
"running"
STATUS_STOPPED =
"stopped"
JOBS_DIR =
"~/.xshellz/jobs"
RUN_CODE_LANGUAGES =

Interpreters run_code knows how to execute, keyed by language name.

{
  "python" => { command: "python3", extension: ".py" },
  "node" => { command: "node", extension: ".js" },
  "bash" => { command: "bash", extension: ".sh" },
  "ruby" => { command: "ruby", extension: ".rb" },
  "php" => { command: "php", extension: ".php" }
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(info:, client:, private_key_openssh: nil, transport: nil) ⇒ Sandbox

Returns a new instance of Sandbox.



40
41
42
43
44
45
46
47
# File 'lib/xshellz/sandbox.rb', line 40

def initialize(info:, client:, private_key_openssh: nil, transport: nil)
  @info = info
  @client = client
  @private_key_openssh = private_key_openssh
  @transport = transport
  @detached = false
  @killed = false
end

Instance Attribute Details

#infoObject (readonly)

The last-known control-plane state (a SandboxInfo).



34
35
36
# File 'lib/xshellz/sandbox.rb', line 34

def info
  @info
end

#private_key_opensshObject (readonly)

OpenSSH serialization of the private key authenticating this sandbox's SSH (persist it to reconnect later via Sandbox.connect).



38
39
40
# File 'lib/xshellz/sandbox.rb', line 38

def private_key_openssh
  @private_key_openssh
end

Class Method Details

.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil) ⇒ Object

Attach to an existing sandbox by UUID.

private_key is the OpenSSH-format private key whose public half the box was created with (the private_key_openssh of the original sandbox).



94
95
96
97
98
99
# File 'lib/xshellz/sandbox.rb', line 94

def self.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil)
  Ssh.load_private_key!(private_key)
  client = Client.new(api_key: api_key, api_url: api_url, http: http)
  info = find(client, uuid)
  new(info: info, client: client, private_key_openssh: private_key)
end

.create(name: nil, api_key: nil, api_url: nil, http: nil) ⇒ Object

Spawn a new sandbox and return it once it is RUNNING.

An in-memory ed25519 keypair is generated for the box; the private key never leaves this process and the server never sees it. Spawning is synchronous - the box is reachable when this returns.

With a block, the sandbox is yielded and destroyed when the block exits (unless detach was called); the block's value is returned.

Raises AuthError (missing/invalid API key, insufficient scope, or an account gate), QuotaError (the plan's concurrent sandbox limit is reached or the plan has no sandbox entitlement), or ApiError (other API failures: throttle 429, host capacity 503, ...).



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/xshellz/sandbox.rb', line 66

def self.create(name: nil, api_key: nil, api_url: nil, http: nil)
  keypair = Keys.generate
  client = Client.new(api_key: api_key, api_url: api_url, http: http)

  body = { "ssh_public_key" => keypair.public_key_line }
  body["name"] = name unless name.nil?
  payload = client.post("/shells/agent", body)

  sandbox = new(
    info: SandboxInfo.from_api(payload),
    client: client,
    private_key_openssh: keypair.private_key_openssh
  )
  return sandbox unless block_given?

  begin
    yield sandbox
  ensure
    sandbox.kill unless sandbox.detached?
    sandbox.close
  end
end

.find(client, uuid) ⇒ Object

Resolve one sandbox via the list endpoint (there is no GET show route).



180
181
182
183
184
185
186
187
# File 'lib/xshellz/sandbox.rb', line 180

def self.find(client, uuid)
  client.get("/shells/agent").each do |item|
    info = SandboxInfo.from_api(item)
    return info if info.uuid == uuid
  end
  raise SandboxNotRunningError,
        "Sandbox #{uuid} was not found among this account's active sandboxes."
end

.get_boxfile(api_key: nil, api_url: nil, http: nil) ⇒ Object

The account's saved xshellz.box provisioning manifest, or nil when none is saved (+GET /shells/agent/boxfile+).

The boxfile is applied when a NEW box is created: it is seeded into ~/xshellz.box on every fresh box, so destroy+recreate reproduces your package environment (preinstall deps, etc.).



165
166
167
168
# File 'lib/xshellz/sandbox.rb', line 165

def self.get_boxfile(api_key: nil, api_url: nil, http: nil)
  client = Client.new(api_key: api_key, api_url: api_url, http: http)
  (client.get("/shells/agent/boxfile") || {})["manifest"]
end

.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default) ⇒ Object

Get-or-create a PERMANENT named sandbox: find the account's sandbox whose name is exactly name, or create one with that name.

The private key is persisted in a local keystore (+~/.xshellz/keys/.key+, 0600) so a later get_or_create from another process can re-attach without you handling key material:

  • Not found -> create; persist the generated key in the keystore.
  • Found -> attach with the explicit private_key if given, else the keystore's key; a stopped box is started first. When no key can be found, raises MissingKeyError telling you where one was expected.

keystore accepts :default (=> ~/.xshellz/keys/), a directory String, a Keystore instance, or +false+/+nil+ to disable persistence entirely (then get_or_create can only create-or-error, unless you pass private_key).

Security note: the keystore holds plaintext private keys on disk (0600). Delete the key file and kill the box to revoke.

Raises:

  • (ArgumentError)


120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/xshellz/sandbox.rb', line 120

def self.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default)
  name = name.to_s
  raise ArgumentError, "get_or_create requires a non-empty sandbox name" if name.empty?

  store = resolve_keystore(keystore)
  client = Client.new(api_key: api_key, api_url: api_url, http: http)

  found = client.get("/shells/agent")
                .map { |item| SandboxInfo.from_api(item) }
                .find { |info| info.name == name }
  return create_named(client, name, store) if found.nil?

  key = private_key || store&.load(name)
  if key.nil?
    hint = if store.nil?
             "the keystore is disabled, so pass private_key: ..."
           else
             "expected a key file at #{store.path_for(name)}"
           end
    raise MissingKeyError,
          "Sandbox #{name.inspect} already exists but no private key is " \
          "available to attach to it (#{hint}). Pass private_key: ..., " \
          "or kill the box and let get_or_create recreate it."
  end
  Ssh.load_private_key!(key)

  sandbox = new(info: found, client: client, private_key_openssh: key)
  sandbox.start if sandbox.status == STATUS_STOPPED
  sandbox
end

.list(api_key: nil, api_url: nil, http: nil) ⇒ Array<SandboxInfo>

List the account's active sandboxes (a bare array on the wire).

Returns:



154
155
156
157
# File 'lib/xshellz/sandbox.rb', line 154

def self.list(api_key: nil, api_url: nil, http: nil)
  client = Client.new(api_key: api_key, api_url: api_url, http: http)
  client.get("/shells/agent").map { |item| SandboxInfo.from_api(item) }
end

.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil) ⇒ Object

Save the account's xshellz.box manifest (+PUT /shells/agent/boxfile+, max 16 KB). Pass nil (or a blank string) to clear it. Returns the stored manifest (nil when cleared). Applies to boxes created AFTER the save - existing boxes are not touched.



174
175
176
177
# File 'lib/xshellz/sandbox.rb', line 174

def self.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil)
  client = Client.new(api_key: api_key, api_url: api_url, http: http)
  (client.put("/shells/agent/boxfile", { "manifest" => manifest }) || {})["manifest"]
end

Instance Method Details

#closeObject

Close the SSH connection (keeps the box alive).



448
449
450
451
# File 'lib/xshellz/sandbox.rb', line 448

def close
  close_transport
  nil
end

#detachObject

Keep the sandbox alive when the create block exits.

Persist private_key_openssh and uuid to re-attach later with Sandbox.connect.



437
438
439
440
# File 'lib/xshellz/sandbox.rb', line 437

def detach
  @detached = true
  self
end

#detached?Boolean

Returns:

  • (Boolean)


244
245
246
# File 'lib/xshellz/sandbox.rb', line 244

def detached?
  @detached
end

#download(remote_path, local_path) ⇒ Object

Download a file from the sandbox to a local path (SFTP).



282
283
284
# File 'lib/xshellz/sandbox.rb', line 282

def download(remote_path, local_path)
  transport.download(remote_path, local_path)
end

#inspectObject Also known as: to_s



453
454
455
# File 'lib/xshellz/sandbox.rb', line 453

def inspect
  "#<Xshellz::Sandbox uuid=#{uuid.inspect} status=#{status.inspect} ssh=#{ssh_host}:#{ssh_port}>"
end

#jobsArray<JobHandle>

List the sandbox's background jobs (every job ever spawned whose ~/.xshellz/jobs/<id>.pid file still exists). Check liveness per handle with running?; read output with logs.

Returns:



316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/xshellz/sandbox.rb', line 316

def jobs
  listing = transport.exec(
    "mkdir -p #{JOBS_DIR} && for f in #{JOBS_DIR}/*.pid; do " \
    '[ -e "$f" ] || continue; ' \
    'printf "%s %s\n" "$(basename "$f" .pid)" "$(cat "$f")"; done'
  )
  listing.stdout.each_line.filter_map do |line|
    id, pid = line.split
    next if id.nil? || pid.nil? || !pid.match?(/\A\d+\z/)

    JobHandle.new(sandbox: self, id: id, pid: pid)
  end
end

#killObject

Destroy the sandbox (+DELETE /shells/agent/#uuid+). Idempotent: a 404 (already gone) is swallowed.



420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/xshellz/sandbox.rb', line 420

def kill
  close_transport
  return if @killed

  begin
    @client.delete("/shells/agent/#{uuid}")
  rescue ApiError => e
    raise unless e.status == 404
  end
  @killed = true
  nil
end

#nameObject



224
225
226
# File 'lib/xshellz/sandbox.rb', line 224

def name
  @info.name
end

#procsSandboxProcs

Top processes + active SSH session count inside the box (+GET /shells/agent/#uuid/procs+).

Returns:



404
405
406
# File 'lib/xshellz/sandbox.rb', line 404

def procs
  SandboxProcs.from_api(@client.get("/shells/agent/#{uuid}/procs"))
end

#read_file(path) ⇒ Object

Read and return the contents of path inside the sandbox (SFTP).



272
273
274
# File 'lib/xshellz/sandbox.rb', line 272

def read_file(path)
  transport.read_file(path)
end

#refreshObject

Re-fetch this sandbox's state from the control plane.



443
444
445
# File 'lib/xshellz/sandbox.rb', line 443

def refresh
  @info = self.class.find(@client, uuid)
end

#restartObject

Reboot a running box (+POST /shells/agent/#uuid/restart+): re-runs the entrypoint; /home is preserved. Returns self with refreshed info; the SSH connection is re-established on the next data-plane call.



384
385
386
387
388
389
# File 'lib/xshellz/sandbox.rb', line 384

def restart
  payload = @client.post("/shells/agent/#{uuid}/restart")
  @info = SandboxInfo.from_api(payload)
  close_transport
  self
end

#run(command, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult

Run a shell command in the sandbox and wait for it to finish.

A non-zero exit code does NOT raise - inspect result.exit_code. An optional block receives (:stdout | :stderr, chunk) as output arrives (the full output is still returned in the result).

Raises SandboxNotRunningError when the box is not running, and CommandTimeoutError when timeout seconds elapse before exit.

Returns:



262
263
264
# File 'lib/xshellz/sandbox.rb', line 262

def run(command, cwd: nil, env: nil, timeout: nil, &block)
  transport.exec(Ssh.build_shell_command(command, cwd: cwd, env: env), timeout: timeout, &block)
end

#run_code(language, code, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult

Execute a snippet of code in the sandbox: write it to a temp file, run the matching interpreter, always delete the temp file.

Supported languages: python (python3), node, bash, ruby, php. An unknown language raises UnsupportedLanguageError. Semantics otherwise match run (same cwd/env/timeout/stream-block, non-zero exit is data, returns a CommandResult).

Returns:



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/xshellz/sandbox.rb', line 339

def run_code(language, code, cwd: nil, env: nil, timeout: nil, &block)
  interpreter = RUN_CODE_LANGUAGES[language.to_s.downcase]
  if interpreter.nil?
    raise UnsupportedLanguageError,
          "Unsupported run_code language #{language.inspect}. " \
          "Supported languages: #{RUN_CODE_LANGUAGES.keys.join(", ")}."
  end

  path = "/tmp/xshellz-run-#{SecureRandom.hex(6)}#{interpreter[:extension]}"
  write_file(path, code)
  begin
    run("#{interpreter[:command]} #{Shellwords.escape(path)}", cwd: cwd, env: env, timeout: timeout, &block)
  ensure
    delete_remote_file_quietly(path)
  end
end

#spawn(command, name: nil) ⇒ JobHandle

Start command as a detached background process inside the sandbox and return immediately with a JobHandle.

The process survives this SDK disconnecting (nohup, stdin from /dev/null); combined stdout+stderr goes to ~/.xshellz/jobs/<id>.log in the box. name (optional) prefixes the generated job id.

Returns:



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/xshellz/sandbox.rb', line 294

def spawn(command, name: nil)
  job_id = generate_job_id(name)
  script = "mkdir -p #{JOBS_DIR} && " \
           "nohup bash -c #{Shellwords.escape(command)} " \
           "> #{JOBS_DIR}/#{job_id}.log 2>&1 < /dev/null & " \
           "echo $! > #{JOBS_DIR}/#{job_id}.pid; echo $!"
  result = transport.exec(script)
  pid = result.stdout[/\d+/]
  if !result.ok? || pid.nil?
    raise Error,
          "Failed to spawn background job #{job_id.inspect} " \
          "(exit #{result.exit_code}): #{result.stderr.strip}"
  end

  JobHandle.new(sandbox: self, id: job_id, pid: pid)
end

#ssh_commandObject



240
241
242
# File 'lib/xshellz/sandbox.rb', line 240

def ssh_command
  @info.ssh_command
end

#ssh_hostObject



232
233
234
# File 'lib/xshellz/sandbox.rb', line 232

def ssh_host
  @info.ssh_host
end

#ssh_portObject



236
237
238
# File 'lib/xshellz/sandbox.rb', line 236

def ssh_port
  @info.ssh_port
end

#startObject

Resume an idle-stopped box (+POST /shells/agent/#uuid/start+).

Free-tier boxes idle-stop after ~30 minutes; this brings the same box (same /home, same authorized key) back to running.



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/xshellz/sandbox.rb', line 364

def start
  begin
    payload = @client.post("/shells/agent/#{uuid}/start")
  rescue ApiError => e
    if e.status == 404
      raise SandboxNotRunningError,
            "Sandbox #{uuid} has no stopped box to start - it may " \
            "already be running, suspended, or deleted."
    end
    raise
  end
  @info = SandboxInfo.from_api(payload)
  close_transport
  self
end

#statsSandboxStats

Live resource usage (+GET /shells/agent/#uuid/stats+): memory, CPU, pids, disk, network and block IO, each alongside the plan-allowed ceiling.

Returns:



396
397
398
# File 'lib/xshellz/sandbox.rb', line 396

def stats
  SandboxStats.from_api(@client.get("/shells/agent/#{uuid}/stats"))
end

#statusObject



228
229
230
# File 'lib/xshellz/sandbox.rb', line 228

def status
  @info.status
end

#terminal_urlString

Mint a fresh signed web-terminal URL for this box (+GET /shells/agent/#uuid/terminal+). Open it in a browser for a root shell - no SSH client needed. The embedded HMAC token expires after ~1 hour; call again for a fresh URL rather than storing it.

Returns:

  • (String)


414
415
416
# File 'lib/xshellz/sandbox.rb', line 414

def terminal_url
  (@client.get("/shells/agent/#{uuid}/terminal") || {})["url"].to_s
end

#upload(local_path, remote_path) ⇒ Object

Upload a local file into the sandbox (SFTP).



277
278
279
# File 'lib/xshellz/sandbox.rb', line 277

def upload(local_path, remote_path)
  transport.upload(local_path, remote_path)
end

#uuidObject

------------------------------------------------------------------ # Properties ------------------------------------------------------------------ #



220
221
222
# File 'lib/xshellz/sandbox.rb', line 220

def uuid
  @info.uuid
end

#write_file(path, data) ⇒ Object

Write data to path inside the sandbox (SFTP).



267
268
269
# File 'lib/xshellz/sandbox.rb', line 267

def write_file(path, data)
  transport.write_file(path, data)
end