microsandbox-rb 0.12.0 โ†’ 0.13.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.
data/README.md CHANGED
@@ -20,7 +20,13 @@ them. Our deepest thanks to the maintainers and community. ๐Ÿ™
20
20
  [Rust](https://github.com/superradcompany/microsandbox/tree/main/sdk) ยท
21
21
  [Python](https://github.com/superradcompany/microsandbox/tree/main/sdk/python) ยท
22
22
  [TypeScript / Node](https://github.com/superradcompany/microsandbox/tree/main/sdk/node-ts) ยท
23
- [Go](https://github.com/superradcompany/microsandbox/tree/main/sdk/go)
23
+ [Go](https://github.com/superradcompany/microsandbox/tree/main/sdk/go) ยท
24
+ [Ruby](https://github.com/superradcompany/microsandbox/tree/main/sdk/ruby)
25
+ (since upstream `v0.6.9` there is an **official** `microsandbox` gem โ€” a
26
+ compact veneer over the same Rust SDK. This gem predates it and covers a
27
+ larger surface (snapshots, SSH, streaming, volumes fs, network policy DSL,
28
+ RBS types); both define the `Microsandbox` module, so use one or the other,
29
+ not both, in a single process.)
24
30
  - **Agents** โ€” [Agent Skills](https://github.com/superradcompany/skills) ยท [MCP server](https://github.com/superradcompany/microsandbox-mcp)
25
31
  - **Community** โ€” [Discord](https://discord.gg/T95Y3XnEAK)
26
32
 
@@ -193,6 +199,22 @@ end
193
199
  A non-zero exit is **not** an error โ€” inspect `exit_code`/`success?`. Spawn-time
194
200
  failures (e.g. command not found) and timeouts raise typed errors (see below).
195
201
 
202
+ **Default workload** (runtime `v0.6.9`): `create` is strictly boot-only โ€” it
203
+ never runs the image's `ENTRYPOINT`/`CMD`. Execute the image's own command
204
+ explicitly:
205
+
206
+ ```ruby
207
+ Microsandbox::Sandbox.create("worker", image: "example/worker:latest",
208
+ cmd: ["worker.py", "--once"]) do |sb| # cmd: overrides the durable image CMD
209
+ out = sb.exec_default(timeout: 300) # buffered; exec-style options
210
+ handle = sb.exec_default_stream # or streaming (returns an ExecHandle)
211
+ sb.attach_default # or interactive (host TTY)
212
+ end
213
+ ```
214
+
215
+ An image whose entrypoint and CMD resolve to no executable command raises
216
+ `Microsandbox::NoDefaultCommandError`.
217
+
196
218
  ### Guest filesystem
197
219
 
198
220
  ```ruby
@@ -256,6 +278,10 @@ Microsandbox::Sandbox.create("live", image: "public.ecr.aws/docker/library/alpin
256
278
  # Live resize โ€” applies to the running VM under the default :no_restart policy:
257
279
  sb.modify(cpus: 2, memory: 1024)
258
280
 
281
+ # Grow the root disk (managed upper or flat, MiB โ€” runtime v0.6.9). Applied
282
+ # while stopped; growth-only:
283
+ sb.modify(root_disk_size: 8192, policy: :next_start)
284
+
259
285
  # env/labels/workdir changes on a *running* sandbox require a restart, so the
260
286
  # default :no_restart policy rejects the whole apply (it raises rather than
261
287
  # partially applying). Persist them for the next start โ€” or restart now โ€”
@@ -430,9 +456,15 @@ Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "
430
456
  ```
431
457
 
432
458
  Resolution order when no backend is set programmatically: `MSB_BACKEND`
433
- (`local`/`cloud`) โ†’ `MSB_API_URL` + `MSB_API_KEY` โ†’ `MSB_PROFILE` โ†’ the
434
- `active_profile` in `~/.microsandbox/config.json` (path overridable via
435
- `MSB_CONFIG_PATH`) โ†’ local. The cloud backend currently supports a subset of
459
+ (`local`/`cloud`) โ†’ `MSB_PROFILE` โ†’ the `active_profile` in
460
+ `~/.microsandbox/config.json` (path overridable via `MSB_CONFIG_PATH`) โ†’
461
+ local. **Cloud intent must be explicit** (since runtime `v0.6.9`): a bare
462
+ `MSB_API_KEY` is treated as credential material, not backend intent, and no
463
+ longer selects the cloud on its own โ€” pair it with `MSB_BACKEND=cloud` (which
464
+ reads `MSB_API_URL`/`MSB_API_KEY`), or select a cloud profile. Invalid cloud
465
+ configuration (e.g. `MSB_BACKEND=cloud` without a usable API key or cloud
466
+ profile) fails closed with `Microsandbox::InvalidConfigError` instead of
467
+ silently running locally. The cloud backend currently supports a subset of
436
468
  operations (create/start/stop/remove/get/list, one-shot exec, follow log
437
469
  streaming); unsupported operations raise `Microsandbox::UnsupportedError`.
438
470
 
@@ -445,8 +477,8 @@ change diverged the two numbers โ€” the gem version is **not** a reliable indica
445
477
  of the embedded runtime version. To learn which runtime a build wraps, ask it:
446
478
 
447
479
  ```ruby
448
- Microsandbox::VERSION # => "0.12.0" (the gem's own version)
449
- Microsandbox.runtime_version # => "v0.6.8" (the embedded upstream runtime tag)
480
+ Microsandbox::VERSION # => "0.13.0" (the gem's own version)
481
+ Microsandbox.runtime_version # => "v0.6.9" (the embedded upstream runtime tag)
450
482
  ```
451
483
 
452
484
  | Gem version | Upstream runtime | Notes |
@@ -469,6 +501,7 @@ Microsandbox.runtime_version # => "v0.6.8" (the embedded upstream runtime tag
469
501
  | `0.10.0` | `v0.6.6` | `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
470
502
  | `0.11.0` | `v0.6.7` | adopts upstream `v0.6.7` (**breaking**): network profiles replace `public_only`/`non_local`, structured `root_disk:` replaces `oci_upper_size:` (deprecated alias kept), snapshot descriptor contract (`create` re-keyed by name, `save`/`load` rename, `snapshot_to` removed, on-disk auto-migration), `Image.load`/`Image.save`, `follow_root_symlinks:`; runtime carries the GHSA-4vq3-cjpp-v7fg `msb copy` fix |
471
503
  | `0.12.0` | `v0.6.8` | adopts upstream `v0.6.8` (**breaking**): `Sandbox.list`/`.list_with` return a cursor-paginated `SandboxPage` (`limit:`/`cursor:` keywords), `UnsupportedError` re-keyed by structured operations with `#operation`/`#hint`; runtime adds a shared log registry for followed streams and cloud exec/ssh reconnects |
504
+ | `0.13.0` | `v0.6.9` | adopts upstream `v0.6.9` (**breaking**): a bare `MSB_API_KEY` no longer selects the cloud backend (explicit `MSB_BACKEND=cloud` or a cloud profile required; invalid cloud config fails closed with `InvalidConfigError`); snapshot payload integrity becomes opt-in (`record_integrity:`, `verify` can report `:not_recorded`). Parity: default-workload execution (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`), flat root disks (`RootDisk.flat`), `modify(root_disk_size:)`, `rate_limiter:`, `vsock:`, `default_backend_info`, `Volume.get_default` |
472
505
 
473
506
  **Going forward** โ€” the gem version moves on its own semver track and no longer
474
507
  mirrors the upstream tag:
@@ -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.6.7).
10
- version = "0.12.0"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.6.9).
10
+ version = "0.13.0"
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.6.8", default-features = true, features = ["ssh"] }
39
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.8" }
38
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9", default-features = true, features = ["ssh"] }
39
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9" }
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"] }
@@ -159,10 +159,32 @@ fn default_backend_kind() -> String {
159
159
  .to_string()
160
160
  }
161
161
 
162
+ /// Secret-safe description of the active default backend (v0.6.9):
163
+ /// {kind, api_url, source, profile}. `source` names what selected the backend
164
+ /// (e.g. "MSB_BACKEND", "programmatic", "default"); the API key is never
165
+ /// included. Like `default_backend_kind`, the first call freezes ambient
166
+ /// env/profile resolution for the process.
167
+ fn default_backend_info() -> magnus::RHash {
168
+ let info = microsandbox::default_backend_info();
169
+ let hash = crate::runtime::ruby().hash_new();
170
+ let _ = hash.aset(
171
+ "kind",
172
+ match info.kind {
173
+ microsandbox::BackendKind::Local => "local",
174
+ microsandbox::BackendKind::Cloud => "cloud",
175
+ },
176
+ );
177
+ let _ = hash.aset("api_url", info.api_url);
178
+ let _ = hash.aset("source", info.source.as_str());
179
+ let _ = hash.aset("profile", info.profile);
180
+ hash
181
+ }
182
+
162
183
  pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
163
184
  native.define_singleton_method("set_default_backend", function!(set_default_backend, 4))?;
164
185
  native.define_singleton_method("push_default_backend", function!(push_default_backend, 4))?;
165
186
  native.define_singleton_method("pop_default_backend", function!(pop_default_backend, 1))?;
166
187
  native.define_singleton_method("default_backend_kind", function!(default_backend_kind, 0))?;
188
+ native.define_singleton_method("default_backend_info", function!(default_backend_info, 0))?;
167
189
  Ok(())
168
190
  }
@@ -60,6 +60,10 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
60
60
  // unconditionally enables the core's `net` feature (default-features),
61
61
  // so this variant is always present.
62
62
  NetworkBuilder(_) => "NetworkPolicyError",
63
+ // v0.6.9: `exec_default`/`attach_default` on an image whose resolved
64
+ // ENTRYPOINT+CMD provide no executable command. Mirrors the Python
65
+ // SDK's `NoDefaultCommandError`.
66
+ NoDefaultCommand => "NoDefaultCommandError",
63
67
  _ => "Error",
64
68
  }
65
69
  }
@@ -18,7 +18,7 @@ use microsandbox::logs::{
18
18
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
19
19
  };
20
20
  use microsandbox::sandbox::{
21
- AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
21
+ AttachOptionsBuilder, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind, FsMetadata,
22
22
  HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
23
  RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
24
  SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
@@ -128,13 +128,41 @@ impl Sandbox {
128
128
  for (k, v) in conv::opt_string_map(opts, "scripts")? {
129
129
  b = b.script(k, v);
130
130
  }
131
- let entrypoint = conv::opt_string_vec(opts, "entrypoint")?;
132
- if !entrypoint.is_empty() {
131
+ // entrypoint/cmd: presence-keyed, NOT non-emptiness-keyed โ€” an
132
+ // explicitly *empty* array is meaningful for both (it clears the
133
+ // image's ENTRYPOINT / CMD, blocking the image-config merge), exactly
134
+ // like the Python binding. Keying on non-emptiness silently resurrects
135
+ // the image ENTRYPOINT and runs the wrong command under
136
+ // `exec_default`/`attach_default`.
137
+ if let Some(entrypoint) = conv::opt::<Vec<String>>(opts, "entrypoint")? {
133
138
  b = b.entrypoint(entrypoint);
134
139
  }
140
+ if let Some(cmd) = conv::opt::<Vec<String>>(opts, "cmd")? {
141
+ b = b.cmd(cmd);
142
+ }
135
143
  for (host, guest) in conv::opt_port_map(opts, "ports")? {
136
144
  b = b.port(host, guest);
137
145
  }
146
+ // vsock: host Unix sockets exposed on guest-to-host vsock ports
147
+ // (v0.6.9). Each route is normalized by the Ruby layer to a
148
+ // string-keyed Hash {host_socket:, port:, socket_type: "stream"|"dgram"}.
149
+ for route in conv::opt_hash_vec(opts, "vsock")? {
150
+ let Some(host_socket) = conv::opt_string(route, "host_socket")? else {
151
+ return Err(error::base_error("vsock route requires host_socket:"));
152
+ };
153
+ let Some(port) = conv::opt_u32(route, "port")? else {
154
+ return Err(error::base_error("vsock route requires port:"));
155
+ };
156
+ match conv::opt_string(route, "socket_type")?.as_deref() {
157
+ None | Some("stream") => b = b.vsock(host_socket, port),
158
+ Some("dgram") => b = b.vsock_dgram(host_socket, port),
159
+ Some(other) => {
160
+ return Err(error::base_error(format!(
161
+ "unknown vsock socket_type {other:?} (expected stream/dgram)"
162
+ )))
163
+ }
164
+ }
165
+ }
138
166
  // volumes: each mount is normalized by the Ruby layer to a string-keyed
139
167
  // Hash โ€” guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
140
168
  // (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
@@ -413,6 +441,28 @@ impl Sandbox {
413
441
  n
414
442
  });
415
443
  }
444
+ // rate_limiter: per-sandbox egress/ingress token-bucket limits
445
+ // (v0.6.9). The Ruby layer normalizes the option to a string-keyed
446
+ // Hash {egress:, ingress:} of {bandwidth:, ops:} buckets
447
+ // {size:, refill_time_ms:, one_time_burst:}; mirrors the Python SDK's
448
+ // `NetworkRateLimiter`. Applied via the network builder, accumulating
449
+ // on top of any configuration above.
450
+ if let Some(spec) = conv::opt::<RHash>(opts, "rate_limiter")?
451
+ .map(parse_rate_limiter)
452
+ .transpose()?
453
+ {
454
+ b = b.network(move |n| {
455
+ n.rate_limiter(move |mut r| {
456
+ if let Some(egress) = spec.egress {
457
+ r = r.egress(move |rl| egress.apply(rl));
458
+ }
459
+ if let Some(ingress) = spec.ingress {
460
+ r = r.ingress(move |rl| ingress.apply(rl));
461
+ }
462
+ r
463
+ })
464
+ });
465
+ }
416
466
  // init: hand guest PID 1 to an init system. The Ruby layer normalizes
417
467
  // `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
418
468
  // args/env builds the same HandoffInit as the plain `init(cmd)`, so route
@@ -560,6 +610,29 @@ impl Sandbox {
560
610
  Ok(ExecHandle::from_core(handle))
561
611
  }
562
612
 
613
+ /// Run the image's resolved OCI ENTRYPOINT and CMD (the default workload,
614
+ /// v0.6.9) and wait for completion. `create` is strictly boot-only, so this
615
+ /// is how the image's own command is executed. `opts` matches `exec` minus
616
+ /// the command: cwd, user, env, timeout, tty, stdin, rlimits. Raises
617
+ /// NoDefaultCommandError when the image resolves no executable command.
618
+ fn exec_default(&self, opts: RHash) -> Result<RHash, Error> {
619
+ let parsed = ExecOpts::parse(Vec::new(), opts)?;
620
+ let output = block_on(self.inner.exec_default_with(move |b| parsed.apply(b)))
621
+ .map_err(error::to_ruby)?;
622
+ exec_output_to_hash(output)
623
+ }
624
+
625
+ /// Streaming default-workload execution. Returns an ExecHandle.
626
+ fn exec_default_stream(&self, opts: RHash) -> Result<ExecHandle, Error> {
627
+ let parsed = ExecOpts::parse(Vec::new(), opts)?;
628
+ let handle = block_on(
629
+ self.inner
630
+ .exec_default_stream_with(move |b| parsed.apply(b)),
631
+ )
632
+ .map_err(error::to_ruby)?;
633
+ Ok(ExecHandle::from_core(handle))
634
+ }
635
+
563
636
  /// Streaming shell execution.
564
637
  fn shell_stream(&self, script: String, opts: RHash) -> Result<ExecHandle, Error> {
565
638
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
@@ -853,6 +926,14 @@ impl Sandbox {
853
926
  fn attach_shell(&self) -> Result<i32, Error> {
854
927
  block_on(self.inner.attach_shell()).map_err(error::to_ruby)
855
928
  }
929
+
930
+ /// Attach an interactive terminal to the image's resolved OCI ENTRYPOINT
931
+ /// and CMD (the default workload, v0.6.9); returns its exit code. `opts`:
932
+ /// cwd, user, env, detach_keys, rlimits.
933
+ fn attach_default(&self, opts: RHash) -> Result<i32, Error> {
934
+ let parsed = AttachOpts::parse(Vec::new(), opts)?;
935
+ block_on(self.inner.attach_default_with(move |b| parsed.apply(b))).map_err(error::to_ruby)
936
+ }
856
937
  }
857
938
 
858
939
  //--------------------------------------------------------------------------------------------------
@@ -1268,6 +1349,83 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
1268
1349
  })
1269
1350
  }
1270
1351
 
1352
+ /// One token bucket of the `rate_limiter` create option (v0.6.9):
1353
+ /// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
1354
+ /// buckets, frames for ops buckets.
1355
+ type TokenBucketSpec = (u64, u64, u64);
1356
+
1357
+ /// One direction of the `rate_limiter` create option.
1358
+ struct RateLimiterSpec {
1359
+ bandwidth: Option<TokenBucketSpec>,
1360
+ ops: Option<TokenBucketSpec>,
1361
+ }
1362
+
1363
+ /// Parsed `rate_limiter` create option (v0.6.9, mirrors the Python SDK's
1364
+ /// `NetworkRateLimiter`): per-direction bandwidth/ops token buckets. Parsed up
1365
+ /// front because the network builder closure cannot return an error.
1366
+ struct NetworkRateLimiterSpec {
1367
+ egress: Option<RateLimiterSpec>,
1368
+ ingress: Option<RateLimiterSpec>,
1369
+ }
1370
+
1371
+ fn parse_token_bucket(b: RHash, dir: &str, dim: &str) -> Result<TokenBucketSpec, Error> {
1372
+ let Some(size) = conv::opt::<u64>(b, "size")? else {
1373
+ return Err(error::base_error(format!(
1374
+ "rate_limiter {dir} {dim} bucket requires size:"
1375
+ )));
1376
+ };
1377
+ let Some(refill) = conv::opt::<u64>(b, "refill_time_ms")? else {
1378
+ return Err(error::base_error(format!(
1379
+ "rate_limiter {dir} {dim} bucket requires refill_time_ms:"
1380
+ )));
1381
+ };
1382
+ let burst = conv::opt::<u64>(b, "one_time_burst")?.unwrap_or(0);
1383
+ Ok((size, refill, burst))
1384
+ }
1385
+
1386
+ fn parse_rate_limiter_direction(h: RHash, dir: &str) -> Result<RateLimiterSpec, Error> {
1387
+ Ok(RateLimiterSpec {
1388
+ bandwidth: conv::opt::<RHash>(h, "bandwidth")?
1389
+ .map(|b| parse_token_bucket(b, dir, "bandwidth"))
1390
+ .transpose()?,
1391
+ ops: conv::opt::<RHash>(h, "ops")?
1392
+ .map(|b| parse_token_bucket(b, dir, "ops"))
1393
+ .transpose()?,
1394
+ })
1395
+ }
1396
+
1397
+ fn parse_rate_limiter(h: RHash) -> Result<NetworkRateLimiterSpec, Error> {
1398
+ Ok(NetworkRateLimiterSpec {
1399
+ egress: conv::opt::<RHash>(h, "egress")?
1400
+ .map(|d| parse_rate_limiter_direction(d, "egress"))
1401
+ .transpose()?,
1402
+ ingress: conv::opt::<RHash>(h, "ingress")?
1403
+ .map(|d| parse_rate_limiter_direction(d, "ingress"))
1404
+ .transpose()?,
1405
+ })
1406
+ }
1407
+
1408
+ impl RateLimiterSpec {
1409
+ fn apply(
1410
+ self,
1411
+ mut rl: microsandbox_network::builder::RateLimiterBuilder,
1412
+ ) -> microsandbox_network::builder::RateLimiterBuilder {
1413
+ if let Some((size, refill_ms, burst)) = self.bandwidth {
1414
+ rl = rl.bandwidth(size, Duration::from_millis(refill_ms));
1415
+ if burst > 0 {
1416
+ rl = rl.bandwidth_burst(burst);
1417
+ }
1418
+ }
1419
+ if let Some((count, refill_ms, burst)) = self.ops {
1420
+ rl = rl.ops(count, Duration::from_millis(refill_ms));
1421
+ if burst > 0 {
1422
+ rl = rl.ops_burst(burst);
1423
+ }
1424
+ }
1425
+ rl
1426
+ }
1427
+ }
1428
+
1271
1429
  struct TlsSpec {
1272
1430
  bypass: Vec<String>,
1273
1431
  verify_upstream: Option<bool>,
@@ -1409,12 +1567,14 @@ struct RootDiskSpec {
1409
1567
  size_mib: Option<u32>,
1410
1568
  format: Option<DiskImageFormat>,
1411
1569
  fstype: Option<String>,
1570
+ clone: Option<FlatClone>,
1412
1571
  }
1413
1572
 
1414
1573
  enum RootDiskKindSpec {
1415
1574
  Managed,
1416
1575
  Tmpfs,
1417
1576
  Disk(String),
1577
+ Flat,
1418
1578
  }
1419
1579
 
1420
1580
  impl RootDiskSpec {
@@ -1423,6 +1583,7 @@ impl RootDiskSpec {
1423
1583
  RootDiskKindSpec::Managed => {}
1424
1584
  RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
1425
1585
  RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
1586
+ RootDiskKindSpec::Flat => d = d.flat(),
1426
1587
  }
1427
1588
  if let Some(mib) = self.size_mib {
1428
1589
  d = d.size(mib);
@@ -1433,6 +1594,9 @@ impl RootDiskSpec {
1433
1594
  if let Some(ft) = self.fstype {
1434
1595
  d = d.fstype(ft);
1435
1596
  }
1597
+ if let Some(c) = self.clone {
1598
+ d = d.clone_strategy(c);
1599
+ }
1436
1600
  d
1437
1601
  }
1438
1602
  }
@@ -1444,12 +1608,13 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1444
1608
  size_mib: Some(mib),
1445
1609
  format: None,
1446
1610
  fstype: None,
1611
+ clone: None,
1447
1612
  });
1448
1613
  }
1449
1614
  let Ok(h) = RHash::try_convert(v) else {
1450
1615
  return Err(error::base_error(
1451
1616
  "root_disk: expects an Integer (managed size in MiB) or a Hash \
1452
- (use Microsandbox::RootDisk.managed/tmpfs/disk)",
1617
+ (use Microsandbox::RootDisk.managed/tmpfs/disk/flat)",
1453
1618
  ));
1454
1619
  };
1455
1620
  let kind = match conv::opt_string(h, "kind")?.as_deref() {
@@ -1461,20 +1626,35 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1461
1626
  };
1462
1627
  RootDiskKindSpec::Disk(path)
1463
1628
  }
1629
+ Some("flat") => RootDiskKindSpec::Flat,
1464
1630
  Some(other) => {
1465
1631
  return Err(error::base_error(format!(
1466
- "unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
1632
+ "unknown root_disk kind {other:?} (expected managed/tmpfs/disk/flat)"
1467
1633
  )))
1468
1634
  }
1469
1635
  };
1470
1636
  let format = conv::opt_string(h, "format")?
1471
1637
  .map(|f| disk_format_from_str(&f))
1472
1638
  .transpose()?;
1639
+ // clone: flat-only private-disk clone strategy (v0.6.9). Validated here
1640
+ // (unknown values error) but kind cross-validation stays in the core
1641
+ // builder, matching the other fields.
1642
+ let clone = conv::opt_string(h, "clone")?
1643
+ .map(|c| match c.as_str() {
1644
+ "auto" => Ok(FlatClone::Auto),
1645
+ "copy" => Ok(FlatClone::Copy),
1646
+ "reflink" => Ok(FlatClone::Reflink),
1647
+ other => Err(error::base_error(format!(
1648
+ "unknown root_disk clone strategy {other:?} (expected auto/copy/reflink)"
1649
+ ))),
1650
+ })
1651
+ .transpose()?;
1473
1652
  Ok(RootDiskSpec {
1474
1653
  kind,
1475
1654
  size_mib: conv::opt_u32(h, "size_mib")?,
1476
1655
  format,
1477
1656
  fstype: conv::opt_string(h, "fstype")?,
1657
+ clone,
1478
1658
  })
1479
1659
  }
1480
1660
 
@@ -1969,9 +2149,9 @@ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String
1969
2149
 
1970
2150
  /// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
1971
2151
  /// label pairs are sorted so repeated calls with the same arguments produce the
1972
- /// same patch (and plan) ordering, mirroring the Python binding. `oci_upper_size`
1973
- /// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
1974
- /// too), so it stays unset via `..Default::default()`.
2152
+ /// same patch (and plan) ordering, mirroring the Python binding. As of v0.6.9
2153
+ /// every patch field is surfaced (`root_disk_size` covers what the deprecated
2154
+ /// CLI-only `oci_upper_size` used to mean).
1975
2155
  fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1976
2156
  let mut env_pairs = conv::opt_string_map(opts, "env")?;
1977
2157
  env_pairs.sort();
@@ -1983,6 +2163,10 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1983
2163
  max_cpus: conv::opt_u8(opts, "max_cpus")?,
1984
2164
  memory_mib: conv::opt_u32(opts, "memory")?,
1985
2165
  max_memory_mib: conv::opt_u32(opts, "max_memory")?,
2166
+ // v0.6.9 ("root disk resizing in every SDK"): grow the sandbox-owned
2167
+ // layered upper or flat root disk. Restart/next-start semantics and
2168
+ // backing-specific limits are enforced by the core.
2169
+ root_disk_size_mib: conv::opt_u32(opts, "root_disk_size")?,
1986
2170
  env: env_pairs
1987
2171
  .into_iter()
1988
2172
  .map(|(k, v)| EnvVar::new(k, v))
@@ -1993,7 +2177,6 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1993
2177
  workdir: conv::opt_string(opts, "workdir")?,
1994
2178
  secrets: parse_modify_secrets(opts)?,
1995
2179
  secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
1996
- ..Default::default()
1997
2180
  })
1998
2181
  }
1999
2182
 
@@ -2422,6 +2605,11 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2422
2605
  class.define_method("shell", method!(Sandbox::shell, 2))?;
2423
2606
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
2424
2607
  class.define_method("shell_stream", method!(Sandbox::shell_stream, 2))?;
2608
+ class.define_method("exec_default", method!(Sandbox::exec_default, 1))?;
2609
+ class.define_method(
2610
+ "exec_default_stream",
2611
+ method!(Sandbox::exec_default_stream, 1),
2612
+ )?;
2425
2613
  class.define_method("stop", method!(Sandbox::stop, 0))?;
2426
2614
  class.define_method("stop_and_wait", method!(Sandbox::stop_and_wait, 0))?;
2427
2615
  class.define_method("kill", method!(Sandbox::kill, 0))?;
@@ -2461,6 +2649,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2461
2649
  )?;
2462
2650
 
2463
2651
  class.define_method("attach", method!(Sandbox::attach, 3))?;
2652
+ class.define_method("attach_default", method!(Sandbox::attach_default, 1))?;
2464
2653
  class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
2465
2654
 
2466
2655
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
@@ -171,9 +171,10 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
171
171
  }
172
172
 
173
173
  /// Verify a snapshot's recorded upper-layer integrity. Returns
174
- /// {digest, path, upper_status, upper_algorithm, upper_digest}. Schema-1
175
- /// descriptors always record integrity, so the status is always "verified"
176
- /// on success (mismatches raise SnapshotIntegrityError instead).
174
+ /// {digest, path, upper_status, upper_algorithm, upper_digest}. As of v0.6.9
175
+ /// payload integrity is opt-in at create time: opted-in snapshots verify to
176
+ /// "verified" (mismatches raise SnapshotIntegrityError), snapshots without
177
+ /// recorded integrity report "not_recorded" with no algorithm/digest keys.
177
178
  fn verify(name_or_path: String) -> Result<RHash, Error> {
178
179
  let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
179
180
  let report = block_on(snap.verify()).map_err(error::to_ruby)?;
@@ -248,6 +249,12 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
248
249
  let _ = hash.aset("upper_algorithm", algorithm.clone());
249
250
  let _ = hash.aset("upper_digest", digest.clone());
250
251
  }
252
+ // v0.6.9 (#1346): payload integrity is opt-in at create time
253
+ // (`record_integrity`); a snapshot without it verifies structurally
254
+ // but has no content digest to check.
255
+ UpperVerifyStatus::NotRecorded => {
256
+ let _ = hash.aset("upper_status", "not_recorded");
257
+ }
251
258
  }
252
259
  hash
253
260
  }
@@ -18,6 +18,7 @@ fn handle_to_hash(h: &VolumeHandle) -> RHash {
18
18
  let hash = ruby().hash_new();
19
19
  let _ = hash.aset("name", h.name().to_string());
20
20
  let _ = hash.aset("kind", h.kind().as_str().to_string());
21
+ let _ = hash.aset("default", h.is_default());
21
22
  let _ = hash.aset("quota_mib", h.quota_mib());
22
23
  let _ = hash.aset("used_bytes", h.used_bytes());
23
24
  let _ = hash.aset("capacity_bytes", h.capacity_bytes());
@@ -83,6 +84,13 @@ fn get(name: String) -> Result<RHash, Error> {
83
84
  Ok(handle_to_hash(&handle))
84
85
  }
85
86
 
87
+ /// The backend's default volume (v0.6.9). Cloud-backend only โ€” the local
88
+ /// backend rejects it (Unsupported) to avoid accidental host access.
89
+ fn get_default() -> Result<RHash, Error> {
90
+ let handle = block_on(microsandbox::Volume::get_default()).map_err(error::to_ruby)?;
91
+ Ok(handle_to_hash(&handle))
92
+ }
93
+
86
94
  fn list() -> Result<RArray, Error> {
87
95
  let handles = block_on(microsandbox::Volume::list()).map_err(error::to_ruby)?;
88
96
  let arr = ruby().ary_new();
@@ -196,6 +204,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
196
204
  let class = native.define_class("Volume", ruby.class_object())?;
197
205
  class.define_singleton_method("create", function!(create, 2))?;
198
206
  class.define_singleton_method("get", function!(get, 1))?;
207
+ class.define_singleton_method("get_default", function!(get_default, 0))?;
199
208
  class.define_singleton_method("list", function!(list, 0))?;
200
209
  class.define_singleton_method("remove", function!(remove, 1))?;
201
210
  class.define_singleton_method("fs", function!(fs, 1))?;
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Secret-safe description of the active default backend, from
5
+ # {Microsandbox.default_backend_info} (runtime v0.6.9). Describes what
6
+ # selected the backend and where it points โ€” never its credential: the API
7
+ # key is deliberately absent.
8
+ class BackendInfo
9
+ # @return [String, nil] effective cloud API endpoint (nil for local)
10
+ attr_reader :api_url
11
+ # @return [String, nil] the selected profile name, when a profile chose
12
+ # the backend
13
+ attr_reader :profile
14
+
15
+ def initialize(data)
16
+ @kind = data["kind"]
17
+ @api_url = data["api_url"]
18
+ @source = data["source"]
19
+ @profile = data["profile"]
20
+ end
21
+
22
+ # @return [Symbol] :local or :cloud
23
+ def kind
24
+ @kind.to_sym
25
+ end
26
+
27
+ # What selected this backend: `:programmatic` (an SDK setter),
28
+ # `:MSB_BACKEND` / `:MSB_API_KEY` / `:MSB_PROFILE` (environment),
29
+ # `:profile` (an explicit SDK profile constructor), `:active_profile`
30
+ # (the SDK config file), or `:default` (the final local fallback).
31
+ # @return [Symbol]
32
+ def source
33
+ @source.to_sym
34
+ end
35
+
36
+ def local? = kind == :local
37
+
38
+ def cloud? = kind == :cloud
39
+
40
+ def inspect
41
+ "#<Microsandbox::BackendInfo kind=#{@kind} source=#{@source.inspect}" \
42
+ "#{" api_url=#{@api_url.inspect}" if @api_url}" \
43
+ "#{" profile=#{@profile.inspect}" if @profile}>"
44
+ end
45
+ end
46
+ end
@@ -39,6 +39,10 @@ module Microsandbox
39
39
  # Execution errors --------------------------------------------------------
40
40
  define_error(:ExecTimeoutError, "exec-timeout")
41
41
  define_error(:ExecFailedError, "exec-failed")
42
+ # v0.6.9: `exec_default`/`attach_default` on an image whose resolved
43
+ # ENTRYPOINT+CMD provide no executable command. Mirrors the Python SDK's
44
+ # NoDefaultCommandError.
45
+ define_error(:NoDefaultCommandError, "no-default-command")
42
46
 
43
47
  # Filesystem errors -------------------------------------------------------
44
48
  define_error(:FilesystemError, "filesystem-error")
@@ -13,12 +13,18 @@ module Microsandbox
13
13
  # - {disk} โ€” a user-supplied disk image attached writable as the upper. The
14
14
  # file determines its own size; the runtime never creates, resizes, or
15
15
  # deletes it. Cannot be snapshotted or patched.
16
+ # - {flat} โ€” a single complete ext4 root disk materialized directly from the
17
+ # OCI image (runtime v0.6.9), skipping the layered EROFS+OverlayFS stack at
18
+ # runtime. Content-addressed and cached across sandboxes; resizable.
19
+ # Pre-materialize with `msb pull IMAGE --materialize flat`.
16
20
  #
17
21
  # @example
18
22
  # Sandbox.create("worker", image: "python", root_disk: 8192)
19
23
  # Sandbox.create("ci", image: "python", root_disk: Microsandbox::RootDisk.tmpfs(2048))
20
24
  # Sandbox.create("warm", image: "python",
21
25
  # root_disk: Microsandbox::RootDisk.disk("./scratch.img", fstype: "ext4"))
26
+ # Sandbox.create("fast", image: "python",
27
+ # root_disk: Microsandbox::RootDisk.flat(8192, clone: :reflink))
22
28
  #
23
29
  # Mirrors the `RootDisk` factory in the official Python/Node/Go SDKs.
24
30
  module RootDisk
@@ -54,5 +60,23 @@ module Microsandbox
54
60
  h["fstype"] = fstype.to_s if fstype
55
61
  h
56
62
  end
63
+
64
+ # A complete, microsandbox-owned root disk materialized from the OCI image
65
+ # (runtime v0.6.9).
66
+ # @param size_mib [Integer, nil] resizable ext4 size in MiB
67
+ # @param fstype [String, nil] generated filesystem type (default "ext4")
68
+ # @param clone [Symbol, String, nil] how each sandbox's private disk is
69
+ # created from the cached artifact: `:auto` (CoW clone when the host
70
+ # filesystem supports it, else a sparse copy โ€” the default), `:copy`
71
+ # (always an independent sparse copy), or `:reflink` (require a CoW
72
+ # clone; fail where unsupported)
73
+ # @return [Hash]
74
+ def flat(size_mib = nil, fstype: nil, clone: nil)
75
+ h = {"kind" => "flat"}
76
+ h["size_mib"] = Integer(size_mib) if size_mib
77
+ h["fstype"] = fstype.to_s if fstype
78
+ h["clone"] = clone.to_s if clone
79
+ h
80
+ end
57
81
  end
58
82
  end