microsandbox-rb 0.5.8 → 0.5.10

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.
data/README.md CHANGED
@@ -4,7 +4,31 @@ Lightweight microVM sandboxes for Ruby — run AI agents and untrusted code with
4
4
 
5
5
  The `microsandbox-rb` gem provides native bindings to the [microsandbox](https://github.com/superradcompany/microsandbox) runtime via a Rust extension (magnus). It spins up real microVMs (not containers) in under 100 ms, runs standard OCI (Docker) images, and gives you full control over command execution, the guest filesystem, networking, and metrics — all from an idiomatic, **synchronous** Ruby API. There is no daemon to install and no server to connect to: the runtime is embedded directly in your process.
6
6
 
7
- This is an **unofficial, community-maintained** Ruby implementation — not part of the official SDK family ([Rust](https://github.com/superradcompany/microsandbox/tree/main/sdk), TypeScript, Python, Go) — though it wraps the same core engine.
7
+ This is an **unofficial, community-maintained** Ruby implementation — not part of the official SDK family — though it wraps the same core engine.
8
+
9
+ ## Upstream & acknowledgements
10
+
11
+ `microsandbox-rb` exists only because of the excellent work by the [Super Rad
12
+ Company](https://github.com/superradcompany) team on the upstream
13
+ **microsandbox** runtime. All the hard parts — the microVM engine, the guest
14
+ `agentd`, the networking stack — are theirs; this gem is a thin Ruby skin over
15
+ them. Our deepest thanks to the maintainers and community. 🙏
16
+
17
+ - **Website & docs** — <https://microsandbox.dev> · [documentation](https://docs.microsandbox.dev)
18
+ - **Official repository** — [superradcompany/microsandbox](https://github.com/superradcompany/microsandbox)
19
+ - **Official SDKs** —
20
+ [Rust](https://github.com/superradcompany/microsandbox/tree/main/sdk) ·
21
+ [Python](https://github.com/superradcompany/microsandbox/tree/main/sdk/python) ·
22
+ [TypeScript / Node](https://github.com/superradcompany/microsandbox/tree/main/sdk/node-ts) ·
23
+ [Go](https://github.com/superradcompany/microsandbox/tree/main/sdk/go)
24
+ - **Agents** — [Agent Skills](https://github.com/superradcompany/skills) · [MCP server](https://github.com/superradcompany/microsandbox-mcp)
25
+ - **Community** — [Discord](https://discord.gg/T95Y3XnEAK)
26
+
27
+ > **Not affiliated.** This gem is an independent, community-maintained project.
28
+ > It is **not** built, endorsed, or supported by Super Rad Company or the
29
+ > microsandbox team. Please don't direct questions about this gem to the
30
+ > upstream project — open an issue here instead. **Contributions are very
31
+ > welcome — PRs welcome!** See [Contributing](#contributing).
8
32
 
9
33
  ## Features
10
34
 
@@ -14,6 +38,10 @@ This is an **unofficial, community-maintained** Ruby implementation — not part
14
38
  - **Command execution** — run commands or shell scripts and collect output
15
39
  - **Guest filesystem access** — read, write, list, copy, stat files inside a running sandbox
16
40
  - **Metrics & logs** — CPU, memory, disk and network I/O; captured stdout/stderr/system logs
41
+ - **Rootfs patches** — inject files, dirs, and symlinks into the image before boot (`Microsandbox::Patch`)
42
+ - **Fine-grained networking** — policy presets *and* custom CIDR/domain/group allow-deny rules (`Microsandbox::NetworkPolicy`)
43
+ - **SSH & SFTP** — native in-process SSH client/server and file transfer (`Sandbox#ssh`)
44
+ - **Raw agent client** — byte-level access to the guest `agentd` protocol (`Microsandbox::AgentClient`)
17
45
  - **Idiomatic Ruby** — keyword arguments, block-scoped lifecycle, a typed error hierarchy
18
46
  - **Thread-friendly** — the GVL is released during sandbox calls, so other Ruby threads keep running
19
47
 
@@ -93,17 +121,33 @@ sb = Microsandbox::Sandbox.create("box", image: "public.ecr.aws/docker/library/a
93
121
  begin
94
122
  # ...
95
123
  ensure
96
- sb.stop # graceful (sb.stop(timeout: 5) to bound the wait)
97
- # sb.kill # force (SIGKILL)
124
+ sb.stop # graceful (SIGTERM→SIGKILL escalation, 10s default)
125
+ # sb.stop_and_wait # graceful, then wait → ExitStatus(#exit_code, #success?)
126
+ # sb.kill # force (SIGKILL); sb.drain for a graceful drain
98
127
  end
99
128
 
100
- # Inspect / manage existing sandboxes
101
- Microsandbox::Sandbox.list # => [Microsandbox::SandboxInfo, ...]
102
- Microsandbox::Sandbox.get("box") # => Microsandbox::SandboxInfo
129
+ # Inspect / manage existing sandboxes. `get`/`list` return a controllable
130
+ # SandboxHandle (the live `stop`/`kill`/`drain`/`wait` live on the object from
131
+ # `create`/`start`; fine-grained control lives on the handle).
132
+ Microsandbox::Sandbox.list # => [Microsandbox::SandboxHandle, ...]
133
+ h = Microsandbox::Sandbox.get("box") # => Microsandbox::SandboxHandle
134
+ h.status # :running, :stopped, :created, ...
135
+ h.stop_with_timeout(5) # custom escalation timeout
136
+ h.request_stop # fire-and-return; pair with #wait_until_stopped
137
+ h.request_kill
138
+ h.request_drain
139
+ h.wait_until_stopped # => Microsandbox::SandboxStopResult
103
140
  Microsandbox::Sandbox.start("box") # restart a stopped sandbox
104
141
  Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
105
142
  ```
106
143
 
144
+ > **v0.5.8 lifecycle change.** Upstream split the lifecycle into the live
145
+ > `Sandbox` and a controllable `SandboxHandle`, and the gem mirrors it. The live
146
+ > `Sandbox#stop`/`#kill` no longer take a `timeout:`; `#request_stop`/
147
+ > `#request_kill`/`#request_drain`/`#wait_until_stopped` and a custom stop timeout
148
+ > now live on the `SandboxHandle` from `Sandbox.get`. `Sandbox.get`/`.list` return
149
+ > a `SandboxHandle` (was a read-only `SandboxInfo`, kept as a deprecated alias).
150
+
107
151
  ### Configuration
108
152
 
109
153
  ```ruby
@@ -193,8 +237,9 @@ Microsandbox::Sandbox.create("stream", image: "public.ecr.aws/docker/library/pyt
193
237
  print event.text if event.stdout?
194
238
  end
195
239
  # or: out = handle.collect → ExecOutput (drain to the end)
196
- # interactive stdin:
197
- # sink = handle.stdin; sink.write("data\n"); sink.close
240
+ # interactive stdin — create the stream with stdin: :pipe to get a writable sink:
241
+ # h = sb.exec_stream("cat", [], stdin: :pipe)
242
+ # sink = h.stdin; sink.write("data\n"); sink.close # close sends EOF
198
243
  # control: handle.signal(15), handle.kill, handle.resize(rows, cols)
199
244
  end
200
245
  ```
@@ -303,8 +348,32 @@ Microsandbox.installed? # => true/false
303
348
  Microsandbox.install # download + install the runtime (idempotent)
304
349
  Microsandbox.runtime_path # => "/Users/you/.microsandbox/bin/msb"
305
350
  Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override
351
+ Microsandbox.libkrunfw_path = "/opt/microsandbox/lib/libkrunfw.dylib" # override (set-once)
306
352
  ```
307
353
 
354
+ ### Backend routing
355
+
356
+ As of v0.5.8 every operation runs through a backend. The default is the local
357
+ libkrun backend; without any configuration nothing changes. A backend can be
358
+ selected programmatically or via the environment:
359
+
360
+ ```ruby
361
+ Microsandbox.default_backend_kind # => :local (or :cloud)
362
+ Microsandbox.set_default_backend(:cloud, url: "https://api.example.com", api_key: ENV["MSB_API_KEY"])
363
+ # or a named profile from ~/.microsandbox/config.json:
364
+ Microsandbox.set_default_backend(:cloud, profile: "prod")
365
+
366
+ # Scoped override (restored afterward, even on error):
367
+ Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "alpine") { |sb| ... } }
368
+ ```
369
+
370
+ Resolution order when no backend is set programmatically: `MSB_BACKEND`
371
+ (`local`/`cloud`) → `MSB_API_URL` + `MSB_API_KEY` → `MSB_PROFILE` → the
372
+ `active_profile` in `~/.microsandbox/config.json` (path overridable via
373
+ `MSB_CONFIG_PATH`) → local. The cloud backend currently supports a subset of
374
+ operations (create/start/stop/remove/get/list, one-shot exec, follow log
375
+ streaming); unsupported operations raise `Microsandbox::UnsupportedError`.
376
+
308
377
  ## Development
309
378
 
310
379
  ```bash
@@ -331,21 +400,9 @@ bundle exec rake compile
331
400
  ## Releasing
332
401
 
333
402
  Releases are automated by `.github/workflows/release.yml` via RubyGems
334
- **Trusted Publishing** (OIDC) — there is no API key to store as a secret.
335
-
336
- **One-time setup** (before the first release), create a *pending* trusted
337
- publisher at <https://rubygems.org/profile/oidc/pending_trusted_publishers>:
338
-
339
- | Field | Value |
340
- |-------|-------|
341
- | RubyGems gem name | `microsandbox-rb` |
342
- | Repository owner | `ya-luotao` |
343
- | Repository name | `microsandbox-rb` |
344
- | Workflow filename | `release.yml` |
345
- | Environment | *(leave blank)* |
346
-
347
- On the first successful push the pending publisher auto-converts to a permanent
348
- one bound to the gem.
403
+ **Trusted Publishing** (OIDC) — there is no API key to store as a secret. The
404
+ trusted publisher is already configured for this gem, so no per-release secret
405
+ or credential setup is needed.
349
406
 
350
407
  **Each release:**
351
408
 
@@ -372,19 +429,34 @@ one bound to the gem.
372
429
  > Until promoted, users install the source gem (which compiles via `rb_sys`).
373
430
 
374
431
  See [DESIGN.md](DESIGN.md) for the architecture and the implemented-surface
375
- section for what's covered today vs. on the roadmap. Covered: full sandbox
376
- lifecycle (including the async `request_stop`/`request_kill`/`request_drain`/
377
- `wait_until_stopped`/`detach`/`owns_lifecycle?` controls and label-filtered
378
- `list_with`), `exec`/`shell` (collected and streaming), the full guest
379
- filesystem, metrics (per-sandbox, `Microsandbox.all_sandbox_metrics`, and
380
- streaming `metrics_stream`/`log_stream`), logs, OCI image-cache management,
381
- named volumes, and snapshots (create/list/verify/export/import +
382
- boot-from-snapshot). Create options span resources, network policy presets,
383
- `log_level`/`security`/`rlimits`/`pull_policy`/`secrets` and more; `exec`/`shell`
384
- take per-call `rlimits`, and `create` accepts `registry_auth`/`registry_insecure`/
385
- `registry_ca_certs` for private and authenticated registries. Still on the
386
- roadmap: custom per-rule network policies, file patches, interactive `attach`,
387
- SSH, and the raw agent client.
432
+ section. The binding now covers the full official-SDK surface: sandbox
433
+ lifecycle (the live `Sandbox` `stop`/`stop_and_wait`/`kill`/`drain`/`wait`/
434
+ `status`/`detach`/`owns_lifecycle?`, plus the `SandboxHandle` controls
435
+ `stop_with_timeout`/`request_stop`/`request_kill`/`request_drain`/
436
+ `wait_until_stopped` from `Sandbox.get`, and label-filtered `list_with`),
437
+ backend routing (`set_default_backend`/`with_backend`/`default_backend_kind`),
438
+ `exec`/`shell` (collected and streaming), interactive `attach`/
439
+ `attach_shell`, the full guest filesystem, metrics (per-sandbox,
440
+ `Microsandbox.all_sandbox_metrics`, and streaming `metrics_stream`/`log_stream`),
441
+ logs, OCI image-cache management, named volumes, snapshots (create/list/verify/
442
+ export/import + boot-from-snapshot), **rootfs patches** (`Microsandbox::Patch`),
443
+ **custom per-rule network policies** (`Microsandbox::NetworkPolicy`/`Rule`/
444
+ `Destination`, alongside the presets), **SSH** (`Sandbox#ssh` →
445
+ `SshClient`/`SftpClient`/`SshServer`), and the **raw agent client**
446
+ (`Microsandbox::AgentClient`). Create options span resources, network policy,
447
+ `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches` and more;
448
+ `exec`/`shell` take per-call `rlimits`, and `create` accepts
449
+ `registry_auth`/`registry_insecure`/`registry_ca_certs` for private and
450
+ authenticated registries.
451
+
452
+ ## Contributing
453
+
454
+ This is a community-maintained gem and **contributions are very welcome** —
455
+ bug reports, fixes, docs, and feature work alike. Open an
456
+ [issue](https://github.com/ya-luotao/microsandbox-rb/issues) or send a
457
+ [pull request](https://github.com/ya-luotao/microsandbox-rb/pulls); PRs target
458
+ `main`. Before pushing, run the local gate (Rust `fmt`/`clippy`, `standardrb`,
459
+ and unit specs) — see [Development](#development).
388
460
 
389
461
  ## License
390
462
 
@@ -6,8 +6,8 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
- # The core-crate dependency below stays pinned at its own tag (v0.5.7).
10
- version = "0.5.8"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.5.8).
10
+ version = "0.5.10"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -35,8 +35,8 @@ rb-sys = "0.9"
35
35
  # `.cargo/config.toml.example`). "ssh" matches the feature set the Python/Node
36
36
  # SDKs ship with; default features add "prebuilt" (provisions msb + libkrunfw at
37
37
  # build time), "net", and "keyring".
38
- microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.7", default-features = true, features = ["ssh"] }
39
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.7" }
38
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.8", default-features = true, features = ["ssh"] }
39
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.8" }
40
40
 
41
41
  # Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
42
42
  tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
@@ -13,12 +13,16 @@ MSRV = Gem::Version.new("1.91")
13
13
  rustc_version = begin
14
14
  out = `rustc --version 2>/dev/null`
15
15
  out[/\d+\.\d+(\.\d+)?/] && Gem::Version.new(out[/\d+\.\d+(\.\d+)?/])
16
- rescue StandardError
16
+ rescue
17
17
  nil
18
18
  end
19
19
 
20
20
  if rustc_version && rustc_version < MSRV
21
- which_rustc = (`which rustc 2>/dev/null`.strip rescue "")
21
+ which_rustc = begin
22
+ `which rustc 2>/dev/null`.strip
23
+ rescue
24
+ ""
25
+ end
22
26
  abort(<<~MSG)
23
27
 
24
28
  [microsandbox-rb] Rust #{rustc_version} is too old — the embedded core requires rustc >= #{MSRV}.
@@ -0,0 +1,166 @@
1
+ //! Raw agent client: `Microsandbox::Native::AgentClient`.
2
+ //!
3
+ //! Mirrors `sdk/python/src/agent.rs`. Wraps the core `AgentBridge` — the
4
+ //! FFI-shaped, bytes-in/bytes-out façade over a sandbox's agentd relay socket.
5
+ //! Frames are moved as raw CBOR bodies; (de)serialization stays in Ruby. Streams
6
+ //! are referenced by opaque `u64` handles so the Ruby layer never owns a tokio
7
+ //! receiver. Every call runs on the shared tokio runtime with the GVL released.
8
+
9
+ use std::sync::Arc;
10
+ use std::time::Duration;
11
+
12
+ use magnus::{function, method, prelude::*, Error, RHash, RModule, RString, Ruby};
13
+ use microsandbox::agent::AgentClient as CoreAgentClient;
14
+ use microsandbox::{AgentBridge, BridgeFrame, MicrosandboxError};
15
+
16
+ use crate::error;
17
+ use crate::runtime::{block_on, ruby};
18
+
19
+ /// Map an agent-client error onto the Ruby exception hierarchy (via the core
20
+ /// `MicrosandboxError::AgentClient` wrapper, exactly like the Python binding).
21
+ fn to_ruby_agent(err: microsandbox::AgentClientError) -> Error {
22
+ error::to_ruby(MicrosandboxError::AgentClient(err))
23
+ }
24
+
25
+ #[magnus::wrap(class = "Microsandbox::Native::AgentClient", free_immediately, size)]
26
+ pub struct AgentClient {
27
+ inner: Arc<AgentBridge>,
28
+ }
29
+
30
+ impl AgentClient {
31
+ fn from_bridge(bridge: AgentBridge) -> Self {
32
+ Self {
33
+ inner: Arc::new(bridge),
34
+ }
35
+ }
36
+
37
+ //----------------------------------------------------------------------
38
+ // Connection (singleton methods)
39
+ //----------------------------------------------------------------------
40
+
41
+ /// Connect to a running sandbox by name. `timeout` is optional seconds.
42
+ fn connect_sandbox(name: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
43
+ let bridge = match dur(timeout) {
44
+ Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t)),
45
+ None => block_on(AgentBridge::connect_sandbox(&name)),
46
+ }
47
+ .map_err(to_ruby_agent)?;
48
+ Ok(AgentClient::from_bridge(bridge))
49
+ }
50
+
51
+ /// Connect to an agentd relay socket by path. `timeout` is optional seconds.
52
+ fn connect_path(path: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
53
+ let bridge = match dur(timeout) {
54
+ Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t)),
55
+ None => block_on(AgentBridge::connect_path(&path)),
56
+ }
57
+ .map_err(to_ruby_agent)?;
58
+ Ok(AgentClient::from_bridge(bridge))
59
+ }
60
+
61
+ /// Resolve a sandbox's agent relay socket path without connecting.
62
+ fn socket_path(name: String) -> Result<String, Error> {
63
+ let path = CoreAgentClient::socket_path(&name).map_err(error::to_ruby)?;
64
+ Ok(path.to_string_lossy().into_owned())
65
+ }
66
+
67
+ //----------------------------------------------------------------------
68
+ // Instance methods
69
+ //----------------------------------------------------------------------
70
+
71
+ /// Send one frame and await a single response frame ({id, flags, body}).
72
+ fn request(&self, flags: u8, body: RString) -> Result<RHash, Error> {
73
+ let body = unsafe { body.as_slice() }.to_vec();
74
+ let inner = Arc::clone(&self.inner);
75
+ let frame =
76
+ block_on(async move { inner.request(flags, body).await }).map_err(to_ruby_agent)?;
77
+ Ok(frame_to_hash(frame))
78
+ }
79
+
80
+ /// Open a streaming session; returns {id, handle}.
81
+ fn stream_open(&self, flags: u8, body: RString) -> Result<RHash, Error> {
82
+ let body = unsafe { body.as_slice() }.to_vec();
83
+ let inner = Arc::clone(&self.inner);
84
+ let (id, handle) =
85
+ block_on(async move { inner.stream_open(flags, body).await }).map_err(to_ruby_agent)?;
86
+ let hash = ruby().hash_new();
87
+ hash.aset("id", id)?;
88
+ hash.aset("handle", handle)?;
89
+ Ok(hash)
90
+ }
91
+
92
+ /// Pull the next frame from a stream; nil at end-of-stream.
93
+ fn stream_next(&self, handle: u64) -> Result<Option<RHash>, Error> {
94
+ let inner = Arc::clone(&self.inner);
95
+ let frame =
96
+ block_on(async move { inner.stream_next(handle).await }).map_err(to_ruby_agent)?;
97
+ Ok(frame.map(frame_to_hash))
98
+ }
99
+
100
+ /// Close a stream handle. Idempotent.
101
+ fn stream_close(&self, handle: u64) -> Result<(), Error> {
102
+ let inner = Arc::clone(&self.inner);
103
+ block_on(async move { inner.stream_close(handle).await });
104
+ Ok(())
105
+ }
106
+
107
+ /// Send a follow-up frame on an existing correlation id.
108
+ fn send(&self, id: u32, flags: u8, body: RString) -> Result<(), Error> {
109
+ let body = unsafe { body.as_slice() }.to_vec();
110
+ let inner = Arc::clone(&self.inner);
111
+ block_on(async move { inner.send(id, flags, body).await }).map_err(to_ruby_agent)
112
+ }
113
+
114
+ /// Cached handshake `core.ready` frame body bytes (CBOR).
115
+ fn ready_bytes(&self) -> Result<RString, Error> {
116
+ let bytes = self.inner.ready_bytes().map_err(to_ruby_agent)?;
117
+ Ok(ruby().str_from_slice(&bytes))
118
+ }
119
+
120
+ /// Close the connection. Idempotent.
121
+ fn close(&self) -> Result<(), Error> {
122
+ let inner = Arc::clone(&self.inner);
123
+ block_on(async move { inner.close().await });
124
+ Ok(())
125
+ }
126
+ }
127
+
128
+ /// Convert seconds into a `Duration`, treating a non-positive/absent value as
129
+ /// "use the default handshake timeout".
130
+ fn dur(timeout: Option<f64>) -> Option<Duration> {
131
+ match timeout {
132
+ Some(t) if t.is_finite() && t > 0.0 => Some(Duration::from_secs_f64(t)),
133
+ _ => None,
134
+ }
135
+ }
136
+
137
+ /// Shape a `BridgeFrame` into a Ruby Hash. `body` is binary (ASCII-8BIT).
138
+ fn frame_to_hash(frame: BridgeFrame) -> RHash {
139
+ let r = ruby();
140
+ let hash = r.hash_new();
141
+ let _ = hash.aset("id", frame.id);
142
+ let _ = hash.aset("flags", frame.flags);
143
+ let _ = hash.aset("body", r.str_from_slice(&frame.body));
144
+ hash
145
+ }
146
+
147
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
148
+ let class = native.define_class("AgentClient", ruby.class_object())?;
149
+
150
+ class.define_singleton_method(
151
+ "connect_sandbox",
152
+ function!(AgentClient::connect_sandbox, 2),
153
+ )?;
154
+ class.define_singleton_method("connect_path", function!(AgentClient::connect_path, 2))?;
155
+ class.define_singleton_method("socket_path", function!(AgentClient::socket_path, 1))?;
156
+
157
+ class.define_method("request", method!(AgentClient::request, 2))?;
158
+ class.define_method("stream_open", method!(AgentClient::stream_open, 2))?;
159
+ class.define_method("stream_next", method!(AgentClient::stream_next, 1))?;
160
+ class.define_method("stream_close", method!(AgentClient::stream_close, 1))?;
161
+ class.define_method("send", method!(AgentClient::send, 3))?;
162
+ class.define_method("ready_bytes", method!(AgentClient::ready_bytes, 0))?;
163
+ class.define_method("close", method!(AgentClient::close, 0))?;
164
+
165
+ Ok(())
166
+ }
@@ -0,0 +1,170 @@
1
+ //! Backend routing: the ambient process-wide backend and its selection surface.
2
+ //!
3
+ //! Mirrors the official Python (`sdk/python/src/lib.rs`) and Node
4
+ //! (`sdk/node-ts/native/runtime_config.rs`) bindings. As of upstream v0.5.8
5
+ //! (PR #754) every operation routes through a [`microsandbox::Backend`]: the
6
+ //! ambient default is a lazily-initialised [`microsandbox::LocalBackend`], and
7
+ //! callers may install a different default process-wide or for a scoped block.
8
+ //!
9
+ //! Local-only operations (image cache, aggregate metrics, `msb` path) need a
10
+ //! concrete `&LocalBackend`; [`local_backend`] resolves the ambient default and
11
+ //! downcasts it, surfacing a clean [`MicrosandboxError::Unsupported`] under a
12
+ //! cloud backend (exactly as the pyo3 `resolve_local` helper does). The backend
13
+ //! selection setters (`set_default_backend` / `with_backend` push-pop /
14
+ //! `default_backend_kind`) deliberately expose only a `kind`/`url`/`api_key`/
15
+ //! `profile` facade — the raw `LocalBackend`/`CloudBackend` builders stay hidden,
16
+ //! matching Python and Node.
17
+
18
+ use std::collections::HashMap;
19
+ use std::sync::atomic::{AtomicU32, Ordering};
20
+ use std::sync::{Arc, Mutex, OnceLock};
21
+
22
+ use magnus::{function, prelude::*, Error, RModule, Ruby};
23
+ use microsandbox::{Backend, MicrosandboxError};
24
+
25
+ use crate::error;
26
+ use crate::runtime::block_on;
27
+
28
+ /// Resolve the ambient default backend, requiring it to be local.
29
+ ///
30
+ /// Returns the `Arc<dyn Backend>` so the caller can keep it alive while
31
+ /// borrowing `&LocalBackend` from it (`as_local()` borrows the `Arc`). Pure
32
+ /// Rust — safe to call inside `block_on` (no Ruby C API), and it returns a raw
33
+ /// `MicrosandboxError` so the Ruby-exception mapping happens *after* `block_on`
34
+ /// re-acquires the GVL. Cloud backends yield `Unsupported`, mirroring pyo3's
35
+ /// `resolve_local`.
36
+ pub fn local_backend() -> Result<Arc<dyn Backend>, MicrosandboxError> {
37
+ let backend = microsandbox::default_backend();
38
+ if backend.as_local().is_some() {
39
+ Ok(backend)
40
+ } else {
41
+ Err(MicrosandboxError::Unsupported {
42
+ feature: "this operation requires a local backend".into(),
43
+ available_when: "with the local backend (the default)".into(),
44
+ })
45
+ }
46
+ }
47
+
48
+ /// Resolve the ambient backend (requiring it to be local), then run `op` with a
49
+ /// borrowed `&LocalBackend` inside the blocking runtime, mapping any core error
50
+ /// to a Ruby exception. Local-only operations (image cache, aggregate metrics)
51
+ /// share this instead of repeating the resolve/downcast dance; the single
52
+ /// `as_local()` unwrap is provably infallible — [`local_backend`] just checked
53
+ /// it and returns the same `Arc` kept alive for the borrow — so it lives here
54
+ /// once rather than at every call site.
55
+ pub fn with_local_backend<T>(
56
+ op: impl AsyncFnOnce(&microsandbox::LocalBackend) -> Result<T, MicrosandboxError>,
57
+ ) -> Result<T, Error> {
58
+ block_on(async move {
59
+ let backend = local_backend()?;
60
+ let local = backend
61
+ .as_local()
62
+ .expect("local_backend() guarantees a local backend");
63
+ op(local).await
64
+ })
65
+ .map_err(error::to_ruby)
66
+ }
67
+
68
+ /// Build an `Arc<dyn Backend>` from the SDK facade. Ported from pyo3
69
+ /// `build_backend` (`sdk/python/src/lib.rs`) / node `build_backend`. Synchronous
70
+ /// (no network I/O): cloud construction only builds the HTTP client. Runs on the
71
+ /// Ruby thread with the GVL held, so it may map errors to Ruby directly.
72
+ fn build_backend(
73
+ kind: String,
74
+ url: Option<String>,
75
+ api_key: Option<String>,
76
+ profile: Option<String>,
77
+ ) -> Result<Arc<dyn Backend>, Error> {
78
+ match kind.trim().to_ascii_lowercase().as_str() {
79
+ "local" => Ok(Arc::new(microsandbox::LocalBackend::lazy())),
80
+ "cloud" => {
81
+ let cloud = match profile {
82
+ Some(profile) => microsandbox::CloudBackend::from_profile(&profile),
83
+ None => match (url, api_key) {
84
+ (Some(url), Some(api_key)) => microsandbox::CloudBackend::new(url, api_key),
85
+ _ => {
86
+ return Err(error::to_ruby(MicrosandboxError::InvalidConfig(
87
+ "cloud backend requires url + api_key or profile".into(),
88
+ )))
89
+ }
90
+ },
91
+ }
92
+ .map_err(error::to_ruby)?;
93
+ Ok(Arc::new(cloud))
94
+ }
95
+ other => Err(error::to_ruby(MicrosandboxError::InvalidConfig(format!(
96
+ "backend kind must be 'local' or 'cloud', got {other:?}"
97
+ )))),
98
+ }
99
+ }
100
+
101
+ /// Install a process-wide default backend. Synchronous (an `RwLock` write) — no
102
+ /// `block_on`. Mirrors Python `set_default_backend` / Node `setDefaultBackend`.
103
+ fn set_default_backend(
104
+ kind: String,
105
+ url: Option<String>,
106
+ api_key: Option<String>,
107
+ profile: Option<String>,
108
+ ) -> Result<(), Error> {
109
+ microsandbox::set_default_backend(build_backend(kind, url, api_key, profile)?);
110
+ Ok(())
111
+ }
112
+
113
+ // Scoped-override registry. `with_backend` in Ruby is a swap-and-restore around
114
+ // a synchronous block, so it cannot use the core's async task-local
115
+ // `with_backend`. We mirror Node's process-wide push/pop token registry: push
116
+ // swaps the default and stores the previous backend under a fresh token; pop
117
+ // restores it. The Ruby wrapper drives this through an `ensure` block.
118
+ static NEXT_BACKEND_SCOPE: AtomicU32 = AtomicU32::new(1);
119
+ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Arc<dyn Backend>>>> = OnceLock::new();
120
+
121
+ fn backend_scopes() -> &'static Mutex<HashMap<u32, Arc<dyn Backend>>> {
122
+ BACKEND_SCOPES.get_or_init(|| Mutex::new(HashMap::new()))
123
+ }
124
+
125
+ /// Swap in a new default backend and return a token for restoring the previous
126
+ /// one via [`pop_default_backend`]. Process-wide while active (not task-local):
127
+ /// concurrent Ruby threads observe the swapped default.
128
+ fn push_default_backend(
129
+ kind: String,
130
+ url: Option<String>,
131
+ api_key: Option<String>,
132
+ profile: Option<String>,
133
+ ) -> Result<u32, Error> {
134
+ let previous = microsandbox::swap_default_backend(build_backend(kind, url, api_key, profile)?);
135
+ let token = NEXT_BACKEND_SCOPE.fetch_add(1, Ordering::Relaxed);
136
+ backend_scopes()
137
+ .lock()
138
+ .map_err(|_| error::base_error("backend scope registry poisoned"))?
139
+ .insert(token, previous);
140
+ Ok(token)
141
+ }
142
+
143
+ /// Restore the backend saved by [`push_default_backend`].
144
+ fn pop_default_backend(token: u32) -> Result<(), Error> {
145
+ let previous = backend_scopes()
146
+ .lock()
147
+ .map_err(|_| error::base_error("backend scope registry poisoned"))?
148
+ .remove(&token)
149
+ .ok_or_else(|| error::base_error("unknown backend scope token"))?;
150
+ microsandbox::set_default_backend(previous);
151
+ Ok(())
152
+ }
153
+
154
+ /// The active default backend kind: `"local"` or `"cloud"`. First call lazily
155
+ /// resolves the env/profile/config ladder. Synchronous (an `RwLock` read).
156
+ fn default_backend_kind() -> String {
157
+ match microsandbox::default_backend().kind() {
158
+ microsandbox::BackendKind::Local => "local",
159
+ microsandbox::BackendKind::Cloud => "cloud",
160
+ }
161
+ .to_string()
162
+ }
163
+
164
+ pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
165
+ native.define_singleton_method("set_default_backend", function!(set_default_backend, 4))?;
166
+ native.define_singleton_method("push_default_backend", function!(push_default_backend, 4))?;
167
+ native.define_singleton_method("pop_default_backend", function!(pop_default_backend, 1))?;
168
+ native.define_singleton_method("default_backend_kind", function!(default_backend_kind, 0))?;
169
+ Ok(())
170
+ }
@@ -5,7 +5,7 @@
5
5
 
6
6
  use std::collections::HashMap;
7
7
 
8
- use magnus::{value::ReprValue, Error, RHash, TryConvert, Value};
8
+ use magnus::{value::ReprValue, Error, RArray, RHash, TryConvert, Value};
9
9
 
10
10
  /// Fetch a non-nil value for `key`, if present.
11
11
  fn get(hash: RHash, key: &str) -> Option<Value> {
@@ -62,6 +62,24 @@ pub fn opt_string_vec(hash: RHash, key: &str) -> Result<Vec<String>, Error> {
62
62
  }
63
63
  }
64
64
 
65
+ /// Array of `Hash`es (e.g. `patches`, custom-policy `rules`). Empty if absent.
66
+ ///
67
+ /// `RHash` is a GC-managed handle and so cannot be collected via the blanket
68
+ /// `Vec<T: TryConvert>` path; we walk the `Array` and convert each element.
69
+ pub fn opt_hash_vec(hash: RHash, key: &str) -> Result<Vec<RHash>, Error> {
70
+ match get(hash, key) {
71
+ Some(v) => {
72
+ let arr = RArray::try_convert(v)?;
73
+ let mut out = Vec::with_capacity(arr.len());
74
+ for i in 0..arr.len() {
75
+ out.push(arr.entry::<RHash>(i as isize)?);
76
+ }
77
+ Ok(out)
78
+ }
79
+ None => Ok(Vec::new()),
80
+ }
81
+ }
82
+
65
83
  /// `u16`→`u16` port map (host→guest TCP). Empty if absent.
66
84
  pub fn opt_port_map(hash: RHash, key: &str) -> Result<Vec<(u16, u16)>, Error> {
67
85
  match get(hash, key) {
@@ -28,6 +28,12 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
28
28
  MetricsDisabled(_) => "MetricsDisabledError",
29
29
  MetricsUnavailable(_) => "MetricsUnavailableError",
30
30
  AgentClient(AgentClientError::UnsupportedOperation { .. }) => "UnsupportedOperationError",
31
+ // Backend routing (v0.5.8 / PR #754). `Unsupported` is reachable on the
32
+ // local backend too (e.g. `Volume::path` on a cloud volume, snapshot
33
+ // ops), so it must map even for local-only use. Distinct from the agent
34
+ // client's `UnsupportedOperation` above.
35
+ CloudHttp { .. } => "CloudHttpError",
36
+ Unsupported { .. } => "UnsupportedError",
31
37
  _ => "Error",
32
38
  }
33
39
  }