microsandbox-rb 0.9.2 → 0.11.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.
@@ -18,10 +18,11 @@ use microsandbox::logs::{
18
18
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
19
19
  };
20
20
  use microsandbox::sandbox::{
21
- AttachOptionsBuilder, DiskImageFormat, FsEntry, FsEntryKind, FsMetadata, HostPermissions,
22
- Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource, SandboxBuilder,
23
- SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult, SecretBuilder,
24
- SecurityProfile, StatVirtualization,
21
+ AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
22
+ HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
+ RootDiskBuilder, SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics,
24
+ SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
25
+ SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
25
26
  };
26
27
  use microsandbox::LogLevel;
27
28
  use microsandbox::MicrosandboxResult;
@@ -29,7 +30,8 @@ use microsandbox::RegistryAuth;
29
30
  use microsandbox_network::builder::ViolationActionBuilder;
30
31
  use microsandbox_network::dns::Nameserver;
31
32
  use microsandbox_network::policy::{
32
- Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
33
+ Action, Destination, DestinationGroup, Direction, NetworkPolicy, NetworkProfile, PortRange,
34
+ Protocol, Rule,
33
35
  };
34
36
  use tokio::sync::Mutex;
35
37
  use tokio::task::JoinHandle;
@@ -92,9 +94,19 @@ impl Sandbox {
92
94
  if let Some(v) = conv::opt_u8(opts, "cpus")? {
93
95
  b = b.cpus(v);
94
96
  }
97
+ // v0.6.6 (#1099): boot-time maximum vCPU / memory ceilings that a later
98
+ // live `modify` can grow up to. `cpus`/`memory` above already raise these
99
+ // to their own value if lower, so a bare `cpus`/`memory` still works;
100
+ // setting these explicitly reserves headroom for a live resize.
101
+ if let Some(v) = conv::opt_u8(opts, "max_cpus")? {
102
+ b = b.max_cpus(v);
103
+ }
95
104
  if let Some(v) = conv::opt_u32(opts, "memory")? {
96
105
  b = b.memory(v);
97
106
  }
107
+ if let Some(v) = conv::opt_u32(opts, "max_memory")? {
108
+ b = b.max_memory(v);
109
+ }
98
110
  if let Some(v) = conv::opt_string(opts, "workdir")? {
99
111
  b = b.workdir(v);
100
112
  }
@@ -155,6 +167,10 @@ impl Sandbox {
155
167
  let host_perms = conv::opt_string(m, "host_permissions")?
156
168
  .map(|s| host_permissions_from_str(&s))
157
169
  .transpose()?;
170
+ // v0.6.7: mount-root symlink protection is on by default; this is
171
+ // the per-mount opt-out. Valid for bind/named-directory mounts —
172
+ // the core rejects it elsewhere at build().
173
+ let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
158
174
  // bind/named/disk require a source; tmpfs must not have one.
159
175
  match kind.as_str() {
160
176
  "bind" | "named" | "disk" if source.is_some() => {}
@@ -207,6 +223,9 @@ impl Sandbox {
207
223
  if let Some(hp) = host_perms {
208
224
  mb = mb.host_permissions(hp);
209
225
  }
226
+ if let Some(follow) = follow_root_symlinks {
227
+ mb = mb.follow_root_symlinks(follow);
228
+ }
210
229
  mb
211
230
  });
212
231
  }
@@ -219,18 +238,29 @@ impl Sandbox {
219
238
  if let Some(net) = conv::opt_string(opts, "network")? {
220
239
  match net.as_str() {
221
240
  "none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
222
- // Default policy is public-only, so no builder call is needed.
223
- "public" | "public_only" | "default" => {}
224
241
  "all" | "allow_all" => b = b.network(|n| n.policy(NetworkPolicy::allow_all())),
225
- "non_local" | "nonlocal" => b = b.network(|n| n.policy(NetworkPolicy::non_local())),
226
242
  other => {
227
243
  return Err(error::base_error(format!(
228
- "unknown network mode {other:?} (expected one of \
229
- public_only/none/allow_all/non_local)"
244
+ "unknown network mode {other:?} (expected none/allow_all; compose \
245
+ public/private/host access via network profiles)"
230
246
  )))
231
247
  }
232
248
  }
233
249
  }
250
+ // Composable network profiles (v0.6.7): expanded to the canonical rule
251
+ // set plus a gateway-DNS allow by the core's
252
+ // `NetworkPolicy::from_profiles`. The Ruby layer routes
253
+ // `network: [:public, :host]` (and single-profile sugar) here. An empty
254
+ // profile set never reaches this key — the Ruby layer sends it as an
255
+ // explicit empty custom policy, which has identical semantics.
256
+ let profile_names = conv::opt_string_vec(opts, "network_profiles")?;
257
+ if !profile_names.is_empty() {
258
+ let profiles = profile_names
259
+ .iter()
260
+ .map(|s| network_profile_from_str(s))
261
+ .collect::<Result<Vec<_>, Error>>()?;
262
+ b = b.network(move |n| n.policy(NetworkPolicy::from_profiles(profiles)));
263
+ }
234
264
  // Custom network policy: an ordered allow/deny rule list with per-direction
235
265
  // defaults and bulk domain denials. The Ruby layer routes bare presets to
236
266
  // the `network` key above and full policies here; mirrors the Python
@@ -250,8 +280,14 @@ impl Sandbox {
250
280
  if let Some(policy) = conv::opt_string(opts, "pull_policy")? {
251
281
  b = b.pull_policy(pull_policy_from_str(&policy)?);
252
282
  }
253
- if let Some(mib) = conv::opt_u32(opts, "oci_upper_size")? {
254
- b = b.oci_upper_size(mib);
283
+ // Writable root-disk spec for OCI sandboxes (v0.6.7): Integer = managed
284
+ // ext4 upper size in MiB (the pre-0.6.7 `oci_upper_size` meaning), Hash =
285
+ // {kind: managed|tmpfs|disk, size_mib, path, format, fstype}. The Ruby
286
+ // layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
287
+ // before it reaches the wire.
288
+ if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
289
+ let spec = parse_root_disk(v)?;
290
+ b = b.root_disk_with(move |d| spec.apply(d));
255
291
  }
256
292
  // Registry connection settings, for private / non-default registries:
257
293
  // Basic auth (username + password/token), plain-HTTP `insecure`, and
@@ -584,6 +620,34 @@ impl Sandbox {
584
620
  Ok(metrics_to_hash(&m))
585
621
  }
586
622
 
623
+ //----------------------------------------------------------------------
624
+ // Health & modification (v0.6.6 / #1099)
625
+ //----------------------------------------------------------------------
626
+
627
+ /// Health-check the guest agent without refreshing the idle timer. Returns
628
+ /// `{ name, latency_secs }` (round-trip latency as f64 seconds; the Ruby
629
+ /// layer exposes both seconds and milliseconds).
630
+ fn ping(&self) -> Result<RHash, Error> {
631
+ let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
632
+ Ok(ping_result_to_hash(&result))
633
+ }
634
+
635
+ /// Explicitly refresh the idle-activity timer. Returns
636
+ /// `{ name, activity_seq }`.
637
+ fn touch(&self) -> Result<RHash, Error> {
638
+ let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
639
+ Ok(touch_result_to_hash(&result))
640
+ }
641
+
642
+ /// Plan or apply a live sandbox modification. `opts` is the string-keyed
643
+ /// modify Hash normalized by the Ruby layer; returns the resulting
644
+ /// `SandboxModificationPlan` as a JSON string, which the Ruby layer parses
645
+ /// into a `ModificationPlan`. Mirrors the Node native binding (native returns
646
+ /// JSON, the wrapper shapes it).
647
+ fn modify(&self, opts: RHash) -> Result<String, Error> {
648
+ run_modify(self.inner.modify(), opts)
649
+ }
650
+
587
651
  /// Read captured logs as an Array of Hashes. `opts`: tail, since_ms,
588
652
  /// until_ms, sources (Array of "stdout"/"stderr"/"output"/"system"/"all").
589
653
  fn logs(&self, opts: RHash) -> Result<RArray, Error> {
@@ -1219,12 +1283,14 @@ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
1219
1283
  /// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
1220
1284
  /// presets travel via the separate `network` key handled in `create`).
1221
1285
  ///
1222
- /// Composition (mirrors the Go SDK's `NetworkConfig`): bulk domain-deny rules
1223
- /// come first (so they outrank later allow rules), then a preset's rules (if a
1224
- /// preset base is given), then the caller's explicit `rules`. Per-direction
1225
- /// defaults come from the explicit `default_egress`/`default_ingress` when set,
1226
- /// else the preset's defaults, else the asymmetric default (deny egress / allow
1227
- /// ingress).
1286
+ /// Composition order (first-match-wins per direction): bulk domain-deny rules
1287
+ /// come first (so they outrank everything), then the caller's explicit
1288
+ /// `rules`, then the profile/preset base's expansion LAST the same order the
1289
+ /// upstream CLI composes `--net` with explicit rules, so a narrower caller
1290
+ /// override (e.g. `Rule.deny_dns`) beats the base's allows instead of dying
1291
+ /// behind them. Per-direction defaults come from the explicit
1292
+ /// `default_egress`/`default_ingress` when set, else the base's defaults, else
1293
+ /// the asymmetric default (deny egress / allow ingress).
1228
1294
  fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1229
1295
  let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
1230
1296
  return Ok(None);
@@ -1245,21 +1311,40 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1245
1311
  rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
1246
1312
  }
1247
1313
 
1248
- // Optional preset base (its rules and defaults seed the policy).
1249
- let (preset_egress, preset_ingress) = match conv::opt_string(np, "preset")? {
1250
- Some(p) => {
1251
- let mut base = network_preset(&p)?;
1314
+ // Optional base composable profiles (v0.6.7) or a terminal preset,
1315
+ // mutually exclusive. Its rules and defaults seed the policy.
1316
+ let profile_names = conv::opt_string_vec(np, "profiles")?;
1317
+ let preset = conv::opt_string(np, "preset")?;
1318
+ if !profile_names.is_empty() && preset.is_some() {
1319
+ return Err(error::base_error(
1320
+ "network profiles: and preset: are mutually exclusive",
1321
+ ));
1322
+ }
1323
+ let base = if profile_names.is_empty() {
1324
+ preset.map(|p| network_preset(&p)).transpose()?
1325
+ } else {
1326
+ let profiles = profile_names
1327
+ .iter()
1328
+ .map(|s| network_profile_from_str(s))
1329
+ .collect::<Result<Vec<_>, Error>>()?;
1330
+ Some(NetworkPolicy::from_profiles(profiles))
1331
+ };
1332
+ // Caller's explicit rules go BEFORE the base expansion: under
1333
+ // first-match-wins, appending them after would make any override of a
1334
+ // base-allowed destination silently dead (a more permissive policy than
1335
+ // requested). Mirrors the upstream CLI's `--net` + rules composition.
1336
+ for rd in conv::opt_hash_vec(np, "rules")? {
1337
+ rules.push(parse_rule(rd)?);
1338
+ }
1339
+
1340
+ let (preset_egress, preset_ingress) = match base {
1341
+ Some(mut base) => {
1252
1342
  rules.append(&mut base.rules);
1253
1343
  (Some(base.default_egress), Some(base.default_ingress))
1254
1344
  }
1255
1345
  None => (None, None),
1256
1346
  };
1257
1347
 
1258
- // Caller's explicit rules come after preset rules.
1259
- for rd in conv::opt_hash_vec(np, "rules")? {
1260
- rules.push(parse_rule(rd)?);
1261
- }
1262
-
1263
1348
  let default_egress = match conv::opt_string(np, "default_egress")? {
1264
1349
  Some(s) => action_from_str(&s)?,
1265
1350
  None => preset_egress.unwrap_or(Action::Deny),
@@ -1279,18 +1364,105 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1279
1364
  fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
1280
1365
  Ok(match p {
1281
1366
  "none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
1282
- "public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
1283
1367
  "all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
1284
- "non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
1285
1368
  other => {
1286
1369
  return Err(error::base_error(format!(
1287
- "unknown network preset {other:?} (expected one of \
1288
- public_only/none/allow_all/non_local)"
1370
+ "unknown network preset {other:?} (expected none/allow_all; the removed \
1371
+ public_only/non_local presets are replaced by composable profiles)"
1289
1372
  )))
1290
1373
  }
1291
1374
  })
1292
1375
  }
1293
1376
 
1377
+ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
1378
+ match s {
1379
+ "public" => Ok(NetworkProfile::Public),
1380
+ "private" => Ok(NetworkProfile::Private),
1381
+ "host" => Ok(NetworkProfile::Host),
1382
+ other => Err(error::base_error(format!(
1383
+ "unknown network profile {other:?} (expected public/private/host)"
1384
+ ))),
1385
+ }
1386
+ }
1387
+
1388
+ /// Parsed `root_disk` create option. Integer shorthand = managed upper of N
1389
+ /// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
1390
+ /// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
1391
+ /// lives in the Ruby layer and the core builder — this stays a dumb carrier.
1392
+ struct RootDiskSpec {
1393
+ kind: RootDiskKindSpec,
1394
+ size_mib: Option<u32>,
1395
+ format: Option<DiskImageFormat>,
1396
+ fstype: Option<String>,
1397
+ }
1398
+
1399
+ enum RootDiskKindSpec {
1400
+ Managed,
1401
+ Tmpfs,
1402
+ Disk(String),
1403
+ }
1404
+
1405
+ impl RootDiskSpec {
1406
+ fn apply(self, mut d: RootDiskBuilder) -> RootDiskBuilder {
1407
+ match self.kind {
1408
+ RootDiskKindSpec::Managed => {}
1409
+ RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
1410
+ RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
1411
+ }
1412
+ if let Some(mib) = self.size_mib {
1413
+ d = d.size(mib);
1414
+ }
1415
+ if let Some(f) = self.format {
1416
+ d = d.format(f);
1417
+ }
1418
+ if let Some(ft) = self.fstype {
1419
+ d = d.fstype(ft);
1420
+ }
1421
+ d
1422
+ }
1423
+ }
1424
+
1425
+ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1426
+ if let Ok(mib) = u32::try_convert(v) {
1427
+ return Ok(RootDiskSpec {
1428
+ kind: RootDiskKindSpec::Managed,
1429
+ size_mib: Some(mib),
1430
+ format: None,
1431
+ fstype: None,
1432
+ });
1433
+ }
1434
+ let Ok(h) = RHash::try_convert(v) else {
1435
+ return Err(error::base_error(
1436
+ "root_disk: expects an Integer (managed size in MiB) or a Hash \
1437
+ (use Microsandbox::RootDisk.managed/tmpfs/disk)",
1438
+ ));
1439
+ };
1440
+ let kind = match conv::opt_string(h, "kind")?.as_deref() {
1441
+ None | Some("managed") => RootDiskKindSpec::Managed,
1442
+ Some("tmpfs") => RootDiskKindSpec::Tmpfs,
1443
+ Some("disk" | "disk-image" | "disk_image") => {
1444
+ let Some(path) = conv::opt_string(h, "path")? else {
1445
+ return Err(error::base_error("root_disk disk kind requires path:"));
1446
+ };
1447
+ RootDiskKindSpec::Disk(path)
1448
+ }
1449
+ Some(other) => {
1450
+ return Err(error::base_error(format!(
1451
+ "unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
1452
+ )))
1453
+ }
1454
+ };
1455
+ let format = conv::opt_string(h, "format")?
1456
+ .map(|f| disk_format_from_str(&f))
1457
+ .transpose()?;
1458
+ Ok(RootDiskSpec {
1459
+ kind,
1460
+ size_mib: conv::opt_u32(h, "size_mib")?,
1461
+ format,
1462
+ fstype: conv::opt_string(h, "fstype")?,
1463
+ })
1464
+ }
1465
+
1294
1466
  fn action_from_str(s: &str) -> Result<Action, Error> {
1295
1467
  match s {
1296
1468
  "allow" => Ok(Action::Allow),
@@ -1736,6 +1908,133 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
1736
1908
  hash
1737
1909
  }
1738
1910
 
1911
+ //--------------------------------------------------------------------------------------------------
1912
+ // Health & live modification (v0.6.6 / #1099)
1913
+ //--------------------------------------------------------------------------------------------------
1914
+
1915
+ /// A `SandboxPingResult` as `{ name, latency_secs }`. Latency is a `Duration`;
1916
+ /// we hand it to Ruby as f64 seconds and let the Ruby layer derive milliseconds
1917
+ /// (the Python/Node SDKs expose only `latency_ms`).
1918
+ fn ping_result_to_hash(result: &microsandbox::sandbox::SandboxPingResult) -> RHash {
1919
+ let hash = ruby().hash_new();
1920
+ let _ = hash.aset("name", result.name.clone());
1921
+ let _ = hash.aset("latency_secs", result.latency.as_secs_f64());
1922
+ hash
1923
+ }
1924
+
1925
+ /// A `SandboxTouchResult` as `{ name, activity_seq }`.
1926
+ fn touch_result_to_hash(result: &microsandbox::sandbox::SandboxTouchResult) -> RHash {
1927
+ let hash = ruby().hash_new();
1928
+ let _ = hash.aset("name", result.name.clone());
1929
+ let _ = hash.aset("activity_seq", result.activity_seq);
1930
+ hash
1931
+ }
1932
+
1933
+ /// Drive a modify builder to a plan (dry-run or apply) and return it serialized
1934
+ /// as a JSON string. Shared by `Sandbox::modify` and `SbHandle::modify`. The
1935
+ /// canonical `SandboxModificationPlan` serde shape (snake_case keys, literal-space
1936
+ /// enum strings) is parsed verbatim by the Ruby layer — matching the Python SDK's
1937
+ /// raw-dict contract rather than re-casing it.
1938
+ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String, Error> {
1939
+ let patch = build_modify_patch(opts)?;
1940
+ let policy = conv::opt_string(opts, "policy")?.unwrap_or_else(|| "no_restart".to_string());
1941
+ let dry_run = conv::opt_bool(opts, "dry_run")?;
1942
+ let builder = apply_modify_policy(builder.with_patch(patch), &policy)?;
1943
+ let plan = block_on(async move {
1944
+ if dry_run {
1945
+ builder.dry_run().await
1946
+ } else {
1947
+ builder.apply().await
1948
+ }
1949
+ })
1950
+ .map_err(error::to_ruby)?;
1951
+ serde_json::to_string(&plan)
1952
+ .map_err(|e| error::base_error(format!("failed to serialize modification plan: {e}")))
1953
+ }
1954
+
1955
+ /// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
1956
+ /// label pairs are sorted so repeated calls with the same arguments produce the
1957
+ /// same patch (and plan) ordering, mirroring the Python binding. `oci_upper_size`
1958
+ /// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
1959
+ /// too), so it stays unset via `..Default::default()`.
1960
+ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1961
+ let mut env_pairs = conv::opt_string_map(opts, "env")?;
1962
+ env_pairs.sort();
1963
+ let mut label_pairs = conv::opt_string_map(opts, "labels")?;
1964
+ label_pairs.sort();
1965
+
1966
+ Ok(SandboxModificationPatch {
1967
+ cpus: conv::opt_u8(opts, "cpus")?,
1968
+ max_cpus: conv::opt_u8(opts, "max_cpus")?,
1969
+ memory_mib: conv::opt_u32(opts, "memory")?,
1970
+ max_memory_mib: conv::opt_u32(opts, "max_memory")?,
1971
+ env: env_pairs
1972
+ .into_iter()
1973
+ .map(|(k, v)| EnvVar::new(k, v))
1974
+ .collect(),
1975
+ env_remove: conv::opt_string_vec(opts, "remove_env")?,
1976
+ labels: label_pairs,
1977
+ labels_remove: conv::opt_string_vec(opts, "remove_labels")?,
1978
+ workdir: conv::opt_string(opts, "workdir")?,
1979
+ secrets: parse_modify_secrets(opts)?,
1980
+ secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
1981
+ ..Default::default()
1982
+ })
1983
+ }
1984
+
1985
+ /// Parse the `secrets` modify option (an Array of string-keyed Hashes normalized
1986
+ /// by the Ruby layer) into canonical `SecretModificationPatch`es. Each Hash
1987
+ /// carries `name` plus at most one of `env`/`store` (source) or `value` (raw
1988
+ /// material) — the Ruby layer enforces that mutual exclusion — plus optional
1989
+ /// `placeholder` and `allowed_hosts`. The raw `value` moves straight into the
1990
+ /// `Zeroizing` field; it is never echoed in an error.
1991
+ fn parse_modify_secrets(opts: RHash) -> Result<Vec<SecretModificationPatch>, Error> {
1992
+ let mut out = Vec::new();
1993
+ for h in conv::opt_hash_vec(opts, "secrets")? {
1994
+ let name = conv::opt_string(h, "name")?
1995
+ .ok_or_else(|| error::base_error("secret modification spec requires :name"))?;
1996
+ let env = conv::opt_string(h, "env")?;
1997
+ let store = conv::opt_string(h, "store")?;
1998
+ let value = conv::opt_string(h, "value")?;
1999
+ let source = match (env, store) {
2000
+ (Some(var), None) => Some(SecretSource::Env { var }),
2001
+ (None, Some(reference)) => Some(SecretSource::Store { reference }),
2002
+ (None, None) => None,
2003
+ // The Ruby layer rejects >1 source; guard defensively without
2004
+ // echoing any value.
2005
+ (Some(_), Some(_)) => {
2006
+ return Err(error::base_error(format!(
2007
+ "secret {name:?}: \"env\" and \"store\" are mutually exclusive"
2008
+ )))
2009
+ }
2010
+ };
2011
+ out.push(SecretModificationPatch {
2012
+ name,
2013
+ source,
2014
+ value: value.unwrap_or_default().into(),
2015
+ placeholder: conv::opt_string(h, "placeholder")?,
2016
+ allowed_hosts: conv::opt_string_vec(h, "allowed_hosts")?,
2017
+ });
2018
+ }
2019
+ Ok(out)
2020
+ }
2021
+
2022
+ /// Apply the parsed `policy` string to a modify builder. `no_restart` (default)
2023
+ /// leaves the builder untouched; `next_start`/`restart` select those policies.
2024
+ fn apply_modify_policy(
2025
+ builder: SandboxModificationBuilder,
2026
+ policy: &str,
2027
+ ) -> Result<SandboxModificationBuilder, Error> {
2028
+ match policy {
2029
+ "no_restart" => Ok(builder),
2030
+ "next_start" => Ok(builder.next_start()),
2031
+ "restart" => Ok(builder.restart()),
2032
+ other => Err(error::base_error(format!(
2033
+ "unknown policy {other:?}; expected \"no_restart\", \"next_start\", or \"restart\""
2034
+ ))),
2035
+ }
2036
+ }
2037
+
1739
2038
  //--------------------------------------------------------------------------------------------------
1740
2039
  // Pull progress (streaming image-pull during create_with_progress)
1741
2040
  //--------------------------------------------------------------------------------------------------
@@ -1977,15 +2276,32 @@ impl SbHandle {
1977
2276
  /// Snapshot this (stopped) sandbox under a bare name (resolved under the
1978
2277
  /// snapshots directory). Returns the same SnapshotInfo Hash as
1979
2278
  /// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
2279
+ /// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
2280
+ /// with `dest_dir:` for explicit placement.)
1980
2281
  fn snapshot(&self, name: String) -> Result<RHash, Error> {
1981
2282
  let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
1982
2283
  Ok(crate::snapshot::snapshot_to_hash(&snap))
1983
2284
  }
1984
2285
 
1985
- /// Snapshot this (stopped) sandbox to an explicit filesystem path.
1986
- fn snapshot_to(&self, path: String) -> Result<RHash, Error> {
1987
- let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
1988
- Ok(crate::snapshot::snapshot_to_hash(&snap))
2286
+ /// Health-check the guest agent without refreshing the idle timer. A stopped
2287
+ /// sandbox raises `SandboxNotRunningError` (the handle does not start it
2288
+ /// implicitly). Returns `{ name, latency_secs }`.
2289
+ fn ping(&self) -> Result<RHash, Error> {
2290
+ let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
2291
+ Ok(ping_result_to_hash(&result))
2292
+ }
2293
+
2294
+ /// Explicitly refresh the idle-activity timer. A stopped sandbox raises
2295
+ /// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
2296
+ fn touch(&self) -> Result<RHash, Error> {
2297
+ let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
2298
+ Ok(touch_result_to_hash(&result))
2299
+ }
2300
+
2301
+ /// Plan or apply a live sandbox modification (see `Sandbox::modify`). Returns
2302
+ /// the `SandboxModificationPlan` as a JSON string.
2303
+ fn modify(&self, opts: RHash) -> Result<String, Error> {
2304
+ run_modify(self.inner.modify(), opts)
1989
2305
  }
1990
2306
  }
1991
2307
 
@@ -2100,6 +2416,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2100
2416
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
2101
2417
  class.define_method("detach", method!(Sandbox::detach, 0))?;
2102
2418
  class.define_method("metrics", method!(Sandbox::metrics, 0))?;
2419
+ class.define_method("ping", method!(Sandbox::ping, 0))?;
2420
+ class.define_method("touch", method!(Sandbox::touch, 0))?;
2421
+ class.define_method("modify", method!(Sandbox::modify, 1))?;
2103
2422
  class.define_method("metrics_stream", method!(Sandbox::metrics_stream, 1))?;
2104
2423
  class.define_method("logs", method!(Sandbox::logs, 1))?;
2105
2424
  class.define_method("log_stream", method!(Sandbox::log_stream, 1))?;
@@ -2147,7 +2466,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2147
2466
  )?;
2148
2467
  handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
2149
2468
  handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
2150
- handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
2469
+ handle.define_method("ping", method!(SbHandle::ping, 0))?;
2470
+ handle.define_method("touch", method!(SbHandle::touch, 0))?;
2471
+ handle.define_method("modify", method!(SbHandle::modify, 1))?;
2151
2472
 
2152
2473
  let session = native.define_class("PullSession", ruby.class_object())?;
2153
2474
  session.define_method("recv", method!(PullSession::recv, 0))?;