microsandbox-rb 0.15.0 → 0.17.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/DESIGN.md CHANGED
@@ -184,7 +184,7 @@ extension.
184
184
 
185
185
  `ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
186
186
  (`microsandbox` / `microsandbox-network`, pinned to the same tag as
187
- `Microsandbox::RUNTIME_VERSION` — currently `v0.6.14`), so the gem builds anywhere
187
+ `Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
188
188
  — CI, `rake-compiler-dock` release containers, and end-user source installs —
189
189
  without an adjacent checkout. For fast local development against a sibling
190
190
  microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
data/README.md CHANGED
@@ -186,6 +186,39 @@ Microsandbox::Sandbox.start("box") # restart a stopped sandbox
186
186
  Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
187
187
  ```
188
188
 
189
+ **Convergent lifecycle** (runtime `v0.6.16`) — idempotent operations that take a
190
+ name to the state you want, whatever state it is in now:
191
+
192
+ ```ruby
193
+ # Create it, or connect to (and start) the one that is already there. The
194
+ # keyword options are `create`'s and apply only when a create actually happens.
195
+ sb = Microsandbox::Sandbox.connect_or_create("box", image: "public.ecr.aws/docker/library/alpine:latest")
196
+
197
+ sb.id # opaque identity of the *persisted* sandbox — unlike
198
+ # the reusable name, it changes on remove+recreate
199
+ sb.wait_for_status(:running) # => SandboxHandle (no built-in timeout)
200
+ sb = sb.restart # stop + start; => a new live Sandbox
201
+ sb.destroy # stop + remove, in one step
202
+
203
+ h = Microsandbox::Sandbox.get("box")
204
+ h.connect_or_start # connect if running, start if not => Sandbox
205
+ h.restart(force: true, timeout: 5)
206
+ h.destroy
207
+ ```
208
+
209
+ Everything that acts on an *existing* receiver — `wait_for_status`, `restart`,
210
+ `destroy`, `connect_or_start` — compares the identity it was bound to against
211
+ the name's current owner first, raising `Microsandbox::SandboxReplacedError`
212
+ rather than touching a sandbox someone else recreated under the same name.
213
+ (`connect_or_create` is the entry point, so it has no prior identity to check:
214
+ it simply converges on whichever sandbox now owns the name.)
215
+
216
+ `connect_or_create`'s block form stops the sandbox only when the call owns its
217
+ lifecycle — i.e. when it created it attached. A sandbox it merely connected to,
218
+ or started with `detached: true`, is left running. And the stop it does issue is
219
+ scoped to that sandbox's `id`, so a name removed and recreated while the block
220
+ ran cannot be taken down by the teardown either.
221
+
189
222
  > **v0.5.8 lifecycle change.** Upstream split the lifecycle into the live
190
223
  > `Sandbox` and a controllable `SandboxHandle`, and the gem mirrors it. The live
191
224
  > `Sandbox#stop`/`#kill` no longer take a `timeout:`; `#request_stop`/
@@ -212,6 +245,47 @@ Microsandbox::Sandbox.create(
212
245
  end
213
246
  ```
214
247
 
248
+ **Outbound proxy** (runtime `v0.6.17`) — route the sandbox's egress through a
249
+ SOCKS4 (TCP) or SOCKS5 (TCP + non-DNS UDP) proxy with `proxy:`. The proxy is
250
+ dialed by the runtime's host-side network stack, so its address is resolved
251
+ from the host (`127.0.0.1` is the host's loopback), and the egress policy
252
+ (`network:`) still governs which destinations may be reached. A SOCKS5 password
253
+ comes from a host environment variable via `SecretSource.env` — only the
254
+ variable's *name* is handed to the runtime. Local backend only.
255
+
256
+ ```ruby
257
+ Microsandbox::Sandbox.create("worker", image: "python",
258
+ proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
259
+
260
+ Microsandbox::Sandbox.create("worker", image: "python",
261
+ proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
262
+ .credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
263
+
264
+ Microsandbox::Sandbox.create("worker", image: "python",
265
+ proxy: Microsandbox::OutboundProxy.socks4("127.0.0.1:1080", user_id: "ci"))
266
+
267
+ # The equivalent plain Hash works too:
268
+ Microsandbox::Sandbox.create("worker", image: "python",
269
+ proxy: { protocol: :socks5, address: "10.0.0.5:1080",
270
+ credentials: { username: "sandbox", password: { env: "PROXY_PASSWORD" } } })
271
+ ```
272
+
273
+ **Strict hostname policy** (runtime `v0.6.18`) — `strict: true` makes a
274
+ hostname-rule allow fail closed unless the runtime can actually see the request
275
+ authority (plain-HTTP `Host`, or SNI/`:authority` under TLS interception); a
276
+ bypassed or non-intercepted HTTPS flow allowed only by a hostname rule is then
277
+ denied before the upstream dial. Default `false`; create-only. Independently of
278
+ `strict:`, any policy with domain rules now checks plain-HTTP `Host` headers
279
+ against it.
280
+
281
+ ```ruby
282
+ Microsandbox::Sandbox.create("worker", image: "python",
283
+ network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
284
+ rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
285
+ destination: Microsandbox::Destination.domain("api.example.com") }]),
286
+ strict: true)
287
+ ```
288
+
215
289
  ### Executing commands
216
290
 
217
291
  ```ruby
@@ -430,7 +504,9 @@ Microsandbox::Volume.remove("cache")
430
504
  ```
431
505
 
432
506
  `volumes:` accepts a host path String (bind mount) or `{ bind: "/host" }` /
433
- `{ named: "volume-name" }` per guest path. Boot from a snapshot with
507
+ `{ named: "volume-name" }` per guest path. A bind or named mount may pin the
508
+ fallback guest owner for host files with `uid:`/`gid:` (both required together,
509
+ runtime `v0.6.15`). Boot from a snapshot with
434
510
  `Sandbox.create(name, from_snapshot: "snap-name-or-path")`.
435
511
 
436
512
  ### Error handling
@@ -524,8 +600,8 @@ change diverged the two numbers — the gem version is **not** a reliable indica
524
600
  of the embedded runtime version. To learn which runtime a build wraps, ask it:
525
601
 
526
602
  ```ruby
527
- Microsandbox::VERSION # => "0.15.0" (the gem's own version)
528
- Microsandbox.runtime_version # => "v0.6.14" (the embedded upstream runtime tag)
603
+ Microsandbox::VERSION # => "0.17.0" (the gem's own version)
604
+ Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
529
605
  ```
530
606
 
531
607
  The companion [`microsandbox-rb-binaries`](#the-runtime-binaries) gem is
@@ -558,6 +634,8 @@ stale.
558
634
  | `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` |
559
635
  | `0.14.0` | `v0.6.9` | two-gem split: SDK-only gem (no build-time runtime download) + companion `microsandbox-rb-binaries` platform gems |
560
636
  | `0.15.0` | `v0.6.14` | adopts upstream `v0.6.10`–`v0.6.14` step by step: bind-mount correctness, guest bootstrap off the kernel command line, DNS pins for deferred domain allows, Linux glibc 2.28 baseline for the prebuilt runtime, legacy ext4 upper-disk resize, `msb_krun` 0.1.32. Parity: `ssh.open_client`/`prepare_server` accept `inactivity_timeout:` (seconds; `0` disables, `nil` inherits the 600s global default) |
637
+ | `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
638
+ | `0.17.0` | `v0.6.18` | adopts upstream `v0.6.17`+`v0.6.18` step by step: outbound SOCKS4/SOCKS5 proxies (SOCKS5 UDP + credentials), migration-order fix for databases last opened by a `v0.6.15` `msb`, security hardening — plain-HTTP `Host`/`:authority` now checked against domain-rule policies even in non-strict mode, plus fail-closed strict hostname mode. Parity: `proxy:` (`Microsandbox::OutboundProxy` / `SecretSource`), `strict:` |
561
639
 
562
640
  **Going forward** — the gem version moves on its own semver track and no longer
563
641
  mirrors the upstream tag:
@@ -672,7 +750,8 @@ image-pull progress (`Sandbox.create_with_progress` → `PullSession`),
672
750
  (`root_disk:` managed/tmpfs/disk via `Microsandbox::RootDisk`), **network
673
751
  configuration** (composable profiles, custom per-rule
674
752
  `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, plus DNS, TLS interception,
675
- IPv4/IPv6 pools, `max_connections`, `trust_host_cas`), **secrets** (multi-host /
753
+ IPv4/IPv6 pools, `max_connections`, `trust_host_cas`, `strict` hostname mode,
754
+ outbound SOCKS4/SOCKS5 `proxy:`), **secrets** (multi-host /
676
755
  wildcard allow-lists, injection toggles, per-secret + sandbox-level violation
677
756
  policy), **SSH** (`Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`), and the
678
757
  **raw agent client** (`Microsandbox::AgentClient`). Create options span
@@ -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.14).
10
- version = "0.15.0"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.6.16).
10
+ version = "0.17.0"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -42,8 +42,8 @@ rb-sys = "0.9"
42
42
  # first-use download into ~/.microsandbox (`setup::install`, which is not
43
43
  # gated on "prebuilt"). "net"/"ssh"/"keyring" are the features the official
44
44
  # SDKs ship with.
45
- microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.14", default-features = false, features = ["keyring", "net", "ssh"] }
46
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.14" }
45
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18", default-features = false, features = ["keyring", "net", "ssh"] }
46
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18" }
47
47
  # The *guest* agent (`agentd`, PID 1 inside every microVM) is embedded into the
48
48
  # extension by microsandbox-filesystem's build.rs, which without its "prebuilt"
49
49
  # feature demands a locally built `build/agentd` (workspace-only, `just
@@ -52,7 +52,7 @@ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox"
52
52
  # prebuilt agentd for the target arch is fetched (same behaviour as before)
53
53
  # while the SDK-level msb download above stays off. Feature unification means
54
54
  # this adds no new crates; keep it on the same tag as the deps above.
55
- microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.14", default-features = false, features = ["prebuilt"] }
55
+ microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18", default-features = false, features = ["prebuilt"] }
56
56
 
57
57
  # Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
58
58
  tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
@@ -7,6 +7,7 @@
7
7
 
8
8
  use magnus::{value::ReprValue, Error, ExceptionClass, Module, RClass, RModule, Ruby};
9
9
  use microsandbox::{AgentClientError, MicrosandboxError, Operation, UnsupportedReason};
10
+ use microsandbox_network::policy::BuildError;
10
11
 
11
12
  /// The Ruby class (relative to the `Microsandbox` module) for a core error.
12
13
  /// `"Error"` is the base class; anything else is a named subclass.
@@ -17,6 +18,11 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
17
18
  SandboxNotFound(_) => "SandboxNotFoundError",
18
19
  SandboxAlreadyExists(_) => "SandboxAlreadyExistsError",
19
20
  SandboxStillRunning(_) => "SandboxStillRunningError",
21
+ // v0.6.16 (#1462): the receiver's captured identity no longer owns the
22
+ // name — it was removed and recreated. Raised by the identity-checked
23
+ // convergent lifecycle APIs instead of acting on the replacement.
24
+ // Mirrors the Python `SandboxReplacedError`.
25
+ SandboxReplaced { .. } => "SandboxReplacedError",
20
26
  // v0.6.6 (#1099): the sandbox exists but isn't running. Raised by the
21
27
  // handle's exec/attach/ping/touch not-running guards and the fs
22
28
  // agent-endpoint lookup. The `SandboxNotRunningError` class already
@@ -55,6 +61,12 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
55
61
  // migration (run at backend connect / artifact open) failed and needs
56
62
  // repair. Mirrors the Python `SnapshotMigrationError`.
57
63
  SnapshotMigration { .. } => "SnapshotMigrationError",
64
+ // v0.6.17: a malformed `proxy:` (unparseable `IP:port`, invalid SOCKS4
65
+ // user ID, ...) also arrives as a `NetworkBuilder` error, but it is a
66
+ // *configuration* mistake, not a policy one — route it to
67
+ // `InvalidConfigError` like every other bad create option. Matched on
68
+ // the variant, never on the message text.
69
+ NetworkBuilder(BuildError::InvalidOutboundProxy { .. }) => "InvalidConfigError",
58
70
  // Give the already-defined-but-orphaned `NetworkPolicyError` a mapping:
59
71
  // a builder parse/validation error from `network(|n| ...)`. The gem
60
72
  // unconditionally enables the core's `net` feature (default-features),
@@ -18,11 +18,11 @@ use microsandbox::logs::{
18
18
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
19
19
  };
20
20
  use microsandbox::sandbox::{
21
- AttachOptionsBuilder, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind, FsMetadata,
22
- HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
- RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
- SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
25
- SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
21
+ AttachOptionsBuilder, DestroyOptions, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind,
22
+ FsMetadata, HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle,
23
+ RestartOptions, RlimitResource, RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics,
24
+ SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
25
+ SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
26
26
  };
27
27
  use microsandbox::LogLevel;
28
28
  use microsandbox::MicrosandboxResult;
@@ -167,7 +167,8 @@ impl Sandbox {
167
167
  // Hash — guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
168
168
  // (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
169
169
  // readonly/noexec/nosuid/nodev (bool), stat_virtualization,
170
- // host_permissions. Enum-valued options are validated up front (the
170
+ // host_permissions, override_uid/override_gid (u32 pair).
171
+ // Enum-valued options are validated up front (the
171
172
  // volume closure can't return an error); the core validates the rest
172
173
  // (e.g. rejecting stat_virtualization on tmpfs/disk) at create().
173
174
  for m in conv::opt_hash_vec(opts, "volumes")? {
@@ -195,6 +196,14 @@ impl Sandbox {
195
196
  let host_perms = conv::opt_string(m, "host_permissions")?
196
197
  .map(|s| host_permissions_from_str(&s))
197
198
  .transpose()?;
199
+ // v0.6.15: the fallback guest owner presented for host files that
200
+ // carry no per-file stat override. The Ruby layer validates the pair
201
+ // (both-or-neither, u32 range, bind/named only, not with
202
+ // stat_virtualization=off); whether a *named* volume is directory-
203
+ // or disk-backed is known only to the core, which rejects the
204
+ // disk-backed case at build().
205
+ let override_uid = conv::opt_u32(m, "override_uid")?;
206
+ let override_gid = conv::opt_u32(m, "override_gid")?;
198
207
  // v0.6.7: mount-root symlink protection is on by default; this is
199
208
  // the per-mount opt-out. Valid for bind/named-directory mounts —
200
209
  // the core rejects it elsewhere at build().
@@ -251,6 +260,9 @@ impl Sandbox {
251
260
  if let Some(hp) = host_perms {
252
261
  mb = mb.host_permissions(hp);
253
262
  }
263
+ if let (Some(uid), Some(gid)) = (override_uid, override_gid) {
264
+ mb = mb.owner(uid, gid);
265
+ }
254
266
  if let Some(follow) = follow_root_symlinks {
255
267
  mb = mb.follow_root_symlinks(follow);
256
268
  }
@@ -378,12 +390,22 @@ impl Sandbox {
378
390
  .transpose()?;
379
391
  let max_connections = conv::opt::<usize>(opts, "max_connections")?;
380
392
  let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
393
+ // strict: fail-closed hostname-policy enforcement (v0.6.18). A
394
+ // hostname-rule allow must be backed by an inspectable request
395
+ // authority (plain-HTTP Host, or SNI/authority under TLS interception);
396
+ // otherwise the flow is denied before the upstream dial. Mirrors the
397
+ // Python SDK's `Network(strict=...)`. Create-only here to match the
398
+ // Python SDK's `modify` surface, which does not expose `strict`; the
399
+ // core's generated network config patch (ConfigPatch derive) does
400
+ // carry a `strict` field, so this is SDK parity, not a core limit.
401
+ let strict = conv::opt::<bool>(opts, "strict")?;
381
402
  if dns.is_some()
382
403
  || tls.is_some()
383
404
  || ipv4_pool.is_some()
384
405
  || ipv6_pool.is_some()
385
406
  || max_connections.is_some()
386
407
  || trust_host_cas.is_some()
408
+ || strict.is_some()
387
409
  {
388
410
  b = b.network(move |mut n| {
389
411
  if let Some(dns) = dns {
@@ -438,6 +460,9 @@ impl Sandbox {
438
460
  if let Some(t) = trust_host_cas {
439
461
  n = n.trust_host_cas(t);
440
462
  }
463
+ if let Some(s) = strict {
464
+ n = n.strict(s);
465
+ }
441
466
  n
442
467
  });
443
468
  }
@@ -463,6 +488,18 @@ impl Sandbox {
463
488
  })
464
489
  });
465
490
  }
491
+ // proxy: outbound SOCKS4/SOCKS5 proxy for egress traffic (v0.6.17,
492
+ // upstream #1234/#1507). The Ruby layer (`OutboundProxy.coerce`) has
493
+ // already validated the shape and normalized it to the Python SDK's
494
+ // `_to_dict()` wire form: {protocol:, address:, user_id?:,
495
+ // credentials?: {username:, password: {kind: "env", var:}}}. Only the
496
+ // password's env var NAME travels; the core resolves it host-side.
497
+ // Applied exactly as sdk/python/src/helpers.rs does; the address is
498
+ // parsed by the core's proxy builder (an invalid one surfaces as
499
+ // NetworkBuilder(InvalidOutboundProxy) → InvalidConfigError, see error.rs).
500
+ if let Some(proxy) = conv::opt::<RHash>(opts, "proxy")? {
501
+ b = apply_outbound_proxy(b, proxy)?;
502
+ }
466
503
  // init: hand guest PID 1 to an init system. The Ruby layer normalizes
467
504
  // `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
468
505
  // args/env builds the same HandoffInit as the plain `init(cmd)`, so route
@@ -509,6 +546,16 @@ impl Sandbox {
509
546
  Ok(PullSession::new(handle, join))
510
547
  }
511
548
 
549
+ /// Converge on a live sandbox with this name: connect to (or start) the
550
+ /// persisted one when it already exists, otherwise create it from `opts`.
551
+ /// Concurrent callers converge on the winning identity. The core rejects
552
+ /// the combination with `replace`.
553
+ fn connect_or_create(name: String, opts: RHash) -> Result<Sandbox, Error> {
554
+ let b = Self::build_builder(name, opts)?;
555
+ let inner = block_on(b.connect_or_create()).map_err(error::to_ruby)?;
556
+ Ok(Sandbox::from_inner(inner))
557
+ }
558
+
512
559
  /// Restart a previously-defined sandbox by name.
513
560
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
514
561
  let detached = conv::opt_bool(opts, "detached")?;
@@ -580,6 +627,13 @@ impl Sandbox {
580
627
  self.inner.name().to_string()
581
628
  }
582
629
 
630
+ /// The opaque, backend-assigned identity of this persisted sandbox
631
+ /// (v0.6.16). Stable for the sandbox's lifetime, and different once the
632
+ /// same name is removed and recreated.
633
+ fn id(&self) -> String {
634
+ self.inner.id().to_string()
635
+ }
636
+
583
637
  /// Run a command (no shell). `args` is an Array of strings; `opts` is a
584
638
  /// string-keyed Hash (cwd, user, env, timeout, tty, stdin).
585
639
  fn exec(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<RHash, Error> {
@@ -644,17 +698,23 @@ impl Sandbox {
644
698
  Ok(ExecHandle::from_core(handle))
645
699
  }
646
700
 
647
- /// Graceful stop. Mirrors the official SDKs: the live handle routes through
648
- /// a freshly fetched `SandboxHandle::stop` (SIGTERM→SIGKILL escalation with
649
- /// a 10s default). Fine-grained control — a custom timeout or fire-and-
650
- /// return `request_*` — lives on `SandboxHandle`, obtained via `Sandbox.get`.
701
+ /// Graceful stop (SIGTERM→SIGKILL escalation with a 10s default), routed
702
+ /// straight through the live sandbox like every other lifecycle method here
703
+ /// and like both official bindings (`sdk/python/src/sandbox.rs`,
704
+ /// `sdk/ruby/ext/.../lib.rs`).
705
+ ///
706
+ /// This deliberately does NOT re-fetch a `SandboxHandle` by name first. It
707
+ /// used to, back when the core's live `stop` was not identity-scoped; as of
708
+ /// v0.6.16 the core routes `stop` → `request_stop` → `stop_identified(name,
709
+ /// self.identity())`, so going through `self.inner` is what makes the stop
710
+ /// refuse a same-name replacement (`SandboxReplacedError`) instead of
711
+ /// terminating whatever sandbox happens to own the name right now. The old
712
+ /// by-name refetch threw that identity away.
713
+ ///
714
+ /// Fine-grained control — a custom timeout or fire-and-return `request_*` —
715
+ /// lives on `SandboxHandle`, obtained via `Sandbox.get`.
651
716
  fn stop(&self) -> Result<(), Error> {
652
- let name = self.inner.name().to_string();
653
- block_on(async move {
654
- let handle = microsandbox::sandbox::Sandbox::get(&name).await?;
655
- handle.stop().await
656
- })
657
- .map_err(error::to_ruby)
717
+ block_on(self.inner.stop()).map_err(error::to_ruby)
658
718
  }
659
719
 
660
720
  /// Graceful stop, then wait for the process to exit. Returns an exit-status
@@ -680,6 +740,29 @@ impl Sandbox {
680
740
  Ok(exit_status_to_hash(status))
681
741
  }
682
742
 
743
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
744
+ /// returning a fresh handle. A same-name replacement raises
745
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
746
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
747
+ let status = sandbox_status_from_str(&status)?;
748
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
749
+ Ok(SbHandle::from_inner(handle))
750
+ }
751
+
752
+ /// Stop and start this exact sandbox, returning the new live sandbox.
753
+ /// `opts` carries `force`/`timeout`/`detached`.
754
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
755
+ let options = restart_options(opts)?;
756
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
757
+ Ok(Sandbox::from_inner(inner))
758
+ }
759
+
760
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
761
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
762
+ let options = destroy_options(opts)?;
763
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
764
+ }
765
+
683
766
  /// Live status fetched from the backend (a round-trip per call).
684
767
  fn status(&self) -> Result<String, Error> {
685
768
  let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
@@ -1362,6 +1445,63 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
1362
1445
  })
1363
1446
  }
1364
1447
 
1448
+ /// Apply the normalized `proxy` create option (v0.6.17) to the builder.
1449
+ /// Mirrors `sdk/python/src/helpers.rs`: `socks4` takes an optional `user_id`,
1450
+ /// `socks5` optional `credentials` whose password is an env-backed
1451
+ /// [`SecretSource`] (the only kind). Unknown protocols and non-env password
1452
+ /// sources are rejected here (the Ruby layer already does, so these are
1453
+ /// belt-and-braces for callers of the native API).
1454
+ fn apply_outbound_proxy(b: SandboxBuilder, proxy: RHash) -> Result<SandboxBuilder, Error> {
1455
+ let protocol = conv::opt_string(proxy, "protocol")?
1456
+ .ok_or_else(|| error::base_error("proxy requires protocol:"))?;
1457
+ let address = conv::opt_string(proxy, "address")?
1458
+ .ok_or_else(|| error::base_error("proxy requires address:"))?;
1459
+ match protocol.as_str() {
1460
+ "socks4" => {
1461
+ let user_id = conv::opt_string(proxy, "user_id")?;
1462
+ Ok(b.proxy(move |p| {
1463
+ let p = p.socks4(address);
1464
+ match user_id {
1465
+ Some(user_id) => p.user_id(user_id),
1466
+ None => p,
1467
+ }
1468
+ }))
1469
+ }
1470
+ "socks5" => {
1471
+ let credentials = match conv::opt::<RHash>(proxy, "credentials")? {
1472
+ None => None,
1473
+ Some(c) => {
1474
+ let username = conv::opt_string(c, "username")?
1475
+ .ok_or_else(|| error::base_error("SOCKS5 credentials require username:"))?;
1476
+ let password = conv::opt::<RHash>(c, "password")?
1477
+ .ok_or_else(|| error::base_error("SOCKS5 credentials require password:"))?;
1478
+ let kind = conv::opt_string(password, "kind")?.ok_or_else(|| {
1479
+ error::base_error("SOCKS5 password source requires kind:")
1480
+ })?;
1481
+ if kind != "env" {
1482
+ return Err(error::base_error(format!(
1483
+ "unsupported SOCKS5 password source {kind:?}; only env is supported"
1484
+ )));
1485
+ }
1486
+ let var = conv::opt_string(password, "var")?
1487
+ .ok_or_else(|| error::base_error("SOCKS5 password source requires var:"))?;
1488
+ Some((username, SecretSource::env(var)))
1489
+ }
1490
+ };
1491
+ Ok(b.proxy(move |p| {
1492
+ let p = p.socks5(address);
1493
+ match credentials {
1494
+ Some((username, password)) => p.credentials(username, password),
1495
+ None => p,
1496
+ }
1497
+ }))
1498
+ }
1499
+ other => Err(error::base_error(format!(
1500
+ "unsupported outbound proxy protocol {other:?}; expected socks4 or socks5"
1501
+ ))),
1502
+ }
1503
+ }
1504
+
1365
1505
  /// One token bucket of the `rate_limiter` create option (v0.6.9):
1366
1506
  /// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
1367
1507
  /// buckets, frames for ops buckets.
@@ -2096,6 +2236,55 @@ fn sandbox_status_str(status: SandboxStatus) -> &'static str {
2096
2236
  }
2097
2237
  }
2098
2238
 
2239
+ /// Parse a Ruby-facing status name back into a core `SandboxStatus`. The Ruby
2240
+ /// layer already validates the seven names (raising `ArgumentError`, the repo's
2241
+ /// idiom for a bad argument value), so reaching the fallback here means the
2242
+ /// native layer was called directly; keep it exhaustive rather than silent.
2243
+ fn sandbox_status_from_str(status: &str) -> Result<SandboxStatus, Error> {
2244
+ match status {
2245
+ "created" => Ok(SandboxStatus::Created),
2246
+ "starting" => Ok(SandboxStatus::Starting),
2247
+ "running" => Ok(SandboxStatus::Running),
2248
+ "draining" => Ok(SandboxStatus::Draining),
2249
+ "paused" => Ok(SandboxStatus::Paused),
2250
+ "stopped" => Ok(SandboxStatus::Stopped),
2251
+ "crashed" => Ok(SandboxStatus::Crashed),
2252
+ other => Err(error::base_error(format!(
2253
+ "unknown sandbox status {other:?} (expected created/starting/running/draining/\
2254
+ paused/stopped/crashed)"
2255
+ ))),
2256
+ }
2257
+ }
2258
+
2259
+ /// Build the core's `RestartOptions` from a string-keyed Hash
2260
+ /// (`force`/`timeout`/`detached`). An absent `timeout` keeps the core's
2261
+ /// ten-second graceful-shutdown default.
2262
+ fn restart_options(opts: RHash) -> Result<RestartOptions, Error> {
2263
+ let mut options = RestartOptions {
2264
+ force: conv::opt_bool(opts, "force")?,
2265
+ detached: conv::opt_bool(opts, "detached")?,
2266
+ ..Default::default()
2267
+ };
2268
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2269
+ options.timeout = secs_to_duration(secs)?;
2270
+ }
2271
+ Ok(options)
2272
+ }
2273
+
2274
+ /// Build the core's `DestroyOptions` from a string-keyed Hash
2275
+ /// (`force`/`timeout`). An absent `timeout` keeps the core's ten-second
2276
+ /// graceful-shutdown default.
2277
+ fn destroy_options(opts: RHash) -> Result<DestroyOptions, Error> {
2278
+ let mut options = DestroyOptions {
2279
+ force: conv::opt_bool(opts, "force")?,
2280
+ ..Default::default()
2281
+ };
2282
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2283
+ options.timeout = secs_to_duration(secs)?;
2284
+ }
2285
+ Ok(options)
2286
+ }
2287
+
2099
2288
  /// A `std::process::ExitStatus` as a Ruby Hash: `exit_code` (Integer or nil) and
2100
2289
  /// `success` (Boolean). Returned by the live `Sandbox#wait` / `#stop_and_wait`.
2101
2290
  fn exit_status_to_hash(status: std::process::ExitStatus) -> RHash {
@@ -2422,6 +2611,13 @@ impl SbHandle {
2422
2611
  self.inner.name().to_string()
2423
2612
  }
2424
2613
 
2614
+ /// The opaque, backend-assigned identity of the persisted sandbox this
2615
+ /// handle is bound to (v0.6.16). The lifecycle operations below refuse to
2616
+ /// act on a same-name replacement by comparing against it.
2617
+ fn id(&self) -> String {
2618
+ self.inner.id().to_string()
2619
+ }
2620
+
2425
2621
  /// Status snapshot captured when the handle was fetched (synchronous).
2426
2622
  fn status(&self) -> String {
2427
2623
  sandbox_status_str(self.inner.status_snapshot()).to_string()
@@ -2470,6 +2666,45 @@ impl SbHandle {
2470
2666
  block_on(self.inner.request_drain()).map_err(error::to_ruby)
2471
2667
  }
2472
2668
 
2669
+ /// Connect when this exact sandbox is running, wait while it is starting,
2670
+ /// or start it when it is created/stopped/crashed. `opts` carries
2671
+ /// `detached`, which applies only when a start is actually required.
2672
+ fn connect_or_start(&self, opts: RHash) -> Result<Sandbox, Error> {
2673
+ let detached = conv::opt_bool(opts, "detached")?;
2674
+ let inner = block_on(async {
2675
+ if detached {
2676
+ self.inner.connect_or_start_detached().await
2677
+ } else {
2678
+ self.inner.connect_or_start().await
2679
+ }
2680
+ })
2681
+ .map_err(error::to_ruby)?;
2682
+ Ok(Sandbox::from_inner(inner))
2683
+ }
2684
+
2685
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
2686
+ /// returning a fresh handle. A same-name replacement raises
2687
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
2688
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2689
+ let status = sandbox_status_from_str(&status)?;
2690
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
2691
+ Ok(SbHandle::from_inner(handle))
2692
+ }
2693
+
2694
+ /// Stop and start this exact sandbox, returning the new live sandbox.
2695
+ /// `opts` carries `force`/`timeout`/`detached`.
2696
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2697
+ let options = restart_options(opts)?;
2698
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
2699
+ Ok(Sandbox::from_inner(inner))
2700
+ }
2701
+
2702
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2703
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
2704
+ let options = destroy_options(opts)?;
2705
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
2706
+ }
2707
+
2473
2708
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2474
2709
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2475
2710
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
@@ -2607,6 +2842,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2607
2842
  "create_with_progress",
2608
2843
  function!(Sandbox::create_with_progress, 2),
2609
2844
  )?;
2845
+ class.define_singleton_method(
2846
+ "connect_or_create",
2847
+ function!(Sandbox::connect_or_create, 2),
2848
+ )?;
2610
2849
  class.define_singleton_method("start", function!(Sandbox::start, 2))?;
2611
2850
  class.define_singleton_method("get", function!(Sandbox::get, 1))?;
2612
2851
  class.define_singleton_method("list", function!(Sandbox::list, 0))?;
@@ -2614,6 +2853,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2614
2853
  class.define_singleton_method("remove", function!(Sandbox::remove, 1))?;
2615
2854
 
2616
2855
  class.define_method("name", method!(Sandbox::name, 0))?;
2856
+ class.define_method("id", method!(Sandbox::id, 0))?;
2617
2857
  class.define_method("exec", method!(Sandbox::exec, 3))?;
2618
2858
  class.define_method("shell", method!(Sandbox::shell, 2))?;
2619
2859
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
@@ -2628,6 +2868,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2628
2868
  class.define_method("kill", method!(Sandbox::kill, 0))?;
2629
2869
  class.define_method("drain", method!(Sandbox::drain, 0))?;
2630
2870
  class.define_method("wait", method!(Sandbox::wait, 0))?;
2871
+ class.define_method("wait_for_status", method!(Sandbox::wait_for_status, 1))?;
2872
+ class.define_method("restart", method!(Sandbox::restart, 1))?;
2873
+ class.define_method("destroy", method!(Sandbox::destroy, 1))?;
2631
2874
  class.define_method("status", method!(Sandbox::status, 0))?;
2632
2875
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
2633
2876
  class.define_method("detach", method!(Sandbox::detach, 0))?;
@@ -2667,6 +2910,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2667
2910
 
2668
2911
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
2669
2912
  handle.define_method("name", method!(SbHandle::name, 0))?;
2913
+ handle.define_method("id", method!(SbHandle::id, 0))?;
2670
2914
  handle.define_method("status", method!(SbHandle::status, 0))?;
2671
2915
  handle.define_method("created_at_ms", method!(SbHandle::created_at_ms, 0))?;
2672
2916
  handle.define_method("updated_at_ms", method!(SbHandle::updated_at_ms, 0))?;
@@ -2674,6 +2918,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2674
2918
  handle.define_method("stop_with_timeout", method!(SbHandle::stop_with_timeout, 1))?;
2675
2919
  handle.define_method("kill", method!(SbHandle::kill, 0))?;
2676
2920
  handle.define_method("kill_with_timeout", method!(SbHandle::kill_with_timeout, 1))?;
2921
+ handle.define_method("connect_or_start", method!(SbHandle::connect_or_start, 1))?;
2922
+ handle.define_method("wait_for_status", method!(SbHandle::wait_for_status, 1))?;
2923
+ handle.define_method("restart", method!(SbHandle::restart, 1))?;
2924
+ handle.define_method("destroy", method!(SbHandle::destroy, 1))?;
2677
2925
  handle.define_method("request_stop", method!(SbHandle::request_stop, 0))?;
2678
2926
  handle.define_method("request_kill", method!(SbHandle::request_kill, 0))?;
2679
2927
  handle.define_method("request_drain", method!(SbHandle::request_drain, 0))?;
@@ -35,6 +35,13 @@ module Microsandbox
35
35
  define_error(:SandboxNotRunningError, "sandbox-not-running")
36
36
  define_error(:SandboxAlreadyExistsError, "sandbox-already-exists")
37
37
  define_error(:SandboxStillRunningError, "sandbox-still-running")
38
+ # v0.6.16: a lifecycle operation was aimed at a sandbox identity that no
39
+ # longer owns the name — the name was removed and recreated behind the
40
+ # caller's back. Raised by the identity-checked convergent APIs
41
+ # (`wait_for_status`, `restart`, `destroy`, `connect_or_start`) rather than
42
+ # letting them act on the replacement. Mirrors the Python SDK's
43
+ # SandboxReplacedError.
44
+ define_error(:SandboxReplacedError, "sandbox-replaced")
38
45
 
39
46
  # Execution errors --------------------------------------------------------
40
47
  define_error(:ExecTimeoutError, "exec-timeout")