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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -0
- data/Cargo.lock +90 -45
- data/DESIGN.md +32 -19
- data/README.md +108 -36
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/extconf.rb +6 -2
- data/ext/microsandbox/src/agent.rs +166 -0
- data/ext/microsandbox/src/backend.rs +170 -0
- data/ext/microsandbox/src/conv.rs +19 -1
- data/ext/microsandbox/src/error.rs +6 -0
- data/ext/microsandbox/src/image.rs +7 -7
- data/ext/microsandbox/src/lib.rs +31 -4
- data/ext/microsandbox/src/sandbox.rs +666 -61
- data/ext/microsandbox/src/ssh.rs +317 -0
- data/ext/microsandbox/src/volume.rs +6 -1
- data/lib/microsandbox/agent.rb +181 -0
- data/lib/microsandbox/errors.rb +6 -0
- data/lib/microsandbox/exec_handle.rb +14 -11
- data/lib/microsandbox/fs.rb +7 -7
- data/lib/microsandbox/image.rb +1 -1
- data/lib/microsandbox/network.rb +300 -0
- data/lib/microsandbox/patch.rb +98 -0
- data/lib/microsandbox/sandbox.rb +285 -77
- data/lib/microsandbox/snapshot.rb +2 -2
- data/lib/microsandbox/ssh.rb +247 -0
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +3 -3
- data/lib/microsandbox.rb +65 -0
- data/sig/microsandbox.rbs +164 -14
- metadata +8 -1
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
|
|
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 (
|
|
97
|
-
# sb.
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
376
|
-
lifecycle (
|
|
377
|
-
`
|
|
378
|
-
`
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
`
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -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.
|
|
10
|
-
version = "0.5.
|
|
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.
|
|
39
|
-
microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.
|
|
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"] }
|
data/ext/microsandbox/extconf.rb
CHANGED
|
@@ -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
|
|
16
|
+
rescue
|
|
17
17
|
nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
if rustc_version && rustc_version < MSRV
|
|
21
|
-
which_rustc =
|
|
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(µsandbox::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
|
}
|