microsandbox-rb 0.16.0 → 0.17.1

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/SECURITY.md ADDED
@@ -0,0 +1,65 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report security problems **privately**, not in a public issue:
6
+
7
+ - GitHub: <https://github.com/ya-luotao/microsandbox-rb/security/advisories/new>
8
+ ("Report a vulnerability" on the repository's Security tab).
9
+
10
+ Include the gem version (`Microsandbox::VERSION`), the embedded runtime
11
+ (`Microsandbox.runtime_version`), the host OS, and the smallest reproduction you
12
+ have. You should get a first reply within a few days.
13
+
14
+ If the problem is in the microsandbox runtime itself (the `msb` binary, the
15
+ guest agent, the network engine) rather than in this Ruby binding, report it
16
+ upstream at <https://github.com/superradcompany/microsandbox/security> — and
17
+ feel free to tell us too, so the fix can be tracked for this gem.
18
+
19
+ ## Supported versions
20
+
21
+ Only the latest released version receives fixes. The gem embeds a specific
22
+ upstream runtime; the README's Versioning table maps every gem version to its
23
+ runtime tag.
24
+
25
+ ## Upstream advisories and this gem
26
+
27
+ Upstream advisories name the upstream packages (the Rust crate, the CLI, and
28
+ the official SDKs). **They do not list `microsandbox-rb`**, so `bundler-audit`
29
+ and Dependabot will not flag this gem for them. The `rubygems:microsandbox`
30
+ entry in some of them refers to the upstream project's own Ruby gem, not to
31
+ `microsandbox-rb`. This table is the mapping.
32
+
33
+ | Advisory | Severity | Affected `microsandbox-rb` | Status |
34
+ |---|---|---|---|
35
+ | [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. |
36
+ | [GHSA-cg9x-2vfm-3j58](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-cg9x-2vfm-3j58) — NAT64 addresses can bypass destination-group network policy | High | all versions (runtime `< v0.7.5`) | **Not fixed in this gem yet**; needs the runtime fix. Only exploitable where the host network routes a NAT64 prefix. Mitigate by denying the prefix explicitly (see below) or blocking it at the host. |
37
+ | [GHSA-3mpp-52gc-935r](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-3mpp-52gc-935r) — hostname allowlist bypass via mismatched HTTP `Host` / `:authority` | High | `<= 0.16.0` | Runtime fix included from `0.17.0`. If you rely on a hostname allowlist for HTTPS without TLS interception, also pass `strict: true` (off by default in this gem). |
38
+ | [GHSA-gj24-29j2-3x78](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-gj24-29j2-3x78) — egress allowlist bypass on malformed HTTP/1 request lines | Medium | `<= 0.12.0` | Runtime fix included from `0.13.0`. |
39
+ | [GHSA-4vq3-cjpp-v7fg](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-4vq3-cjpp-v7fg) — guest-controlled symlinks in `msb copy` | High | `<= 0.10.0` (the bundled `msb` CLI) | Runtime fix included from `0.11.0`. |
40
+ | [GHSA-m8f5-rh7h-vgg3](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-m8f5-rh7h-vgg3) — secret values in world-readable process arguments | Medium | `<= 0.7.0` | Runtime fix included from `0.8.0`. |
41
+
42
+ ### Mitigating GHSA-cg9x-2vfm-3j58 before the runtime fix
43
+
44
+ On a host whose network provides NAT64 (typically IPv6-only networks), deny the
45
+ well-known prefix explicitly. Rules you pass are evaluated before the rules a
46
+ profile expands to, so this keeps the default public-internet profile and puts
47
+ the deny ahead of it:
48
+
49
+ ```ruby
50
+ Microsandbox::Sandbox.create("worker", image: "python",
51
+ network: {
52
+ profiles: [:public],
53
+ rules: [Microsandbox::Rule.deny(destination: "64:ff9b::/96")]
54
+ })
55
+ ```
56
+
57
+ If your network uses a different NAT64 prefix, deny that one as well, or block
58
+ the prefix at the host or network boundary. Hosts without NAT64 routing are not
59
+ expected to be exploitable through this vector.
60
+
61
+ The deny rule is this gem's suggestion, relying on the policy engine matching
62
+ rules in order; it has not been exercised on a NAT64 network. The upstream
63
+ advisory's own workarounds are to disable sandbox networking for untrusted
64
+ workloads (`network: :none`) or to block NAT64 prefixes at the host or network
65
+ boundary.
@@ -0,0 +1,154 @@
1
+ # Sandbox configuration
2
+
3
+ All create-time options are keyword arguments to `Sandbox.create`,
4
+ `Sandbox.connect_or_create`, and `Sandbox.create_with_progress`.
5
+
6
+ ## Resources, environment, ports, network
7
+
8
+ ```ruby
9
+ Microsandbox::Sandbox.create(
10
+ "configured",
11
+ image: "public.ecr.aws/docker/library/python:3-slim",
12
+ cpus: 2,
13
+ memory: 1024, # MiB
14
+ env: { "API_BASE" => "https://example.com" },
15
+ workdir: "/app",
16
+ labels: { "team" => "research" },
17
+ ports: { 8080 => 80 }, # host => guest (TCP)
18
+ network: [:public], # composable profiles; :none for airgapped
19
+ replace: true # replace an existing sandbox of the same name
20
+ ) do |sb|
21
+ # ...
22
+ end
23
+ ```
24
+
25
+ To leave headroom for a live CPU/memory resize later, reserve a ceiling with
26
+ `max_cpus:`/`max_memory:` (see [observability.md](observability.md#live-modification-and-health)).
27
+
28
+ The full option list — `init`/`ephemeral`, disk-image `fstype`, `log_level`,
29
+ `security`, `rlimits`, `pull_policy`, `secrets`, `patches`
30
+ (`Microsandbox::Patch`), the structured `root_disk:` (`Microsandbox::RootDisk`),
31
+ `from_snapshot:`, `cmd:`, `detached:`, `idle_timeout`/`max_duration`, and more —
32
+ is documented in [surface.md](surface.md) and typed in
33
+ [`sig/microsandbox.rbs`](../sig/microsandbox.rbs).
34
+
35
+ ## Network policy
36
+
37
+ `network:` takes composable profiles (`:public`, `:private`, `:host`, `:none`)
38
+ or a custom `Microsandbox::NetworkPolicy` with per-rule CIDR/IP/domain/suffix/
39
+ group allow-deny rules, per-direction defaults, DNS and TLS-interception
40
+ settings, IPv4/IPv6 pools, `max_connections`, and `trust_host_cas`.
41
+
42
+ ```ruby
43
+ Microsandbox::Sandbox.create("worker", image: "python",
44
+ network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
45
+ rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
46
+ destination: Microsandbox::Destination.domain("api.example.com") }]))
47
+ ```
48
+
49
+ ### Strict hostname policy
50
+
51
+ `strict: true` (runtime `v0.6.18`) makes a hostname-rule allow fail closed
52
+ unless the runtime can actually see the request authority (plain-HTTP `Host`,
53
+ or SNI/`:authority` under TLS interception); a bypassed or non-intercepted
54
+ HTTPS flow allowed only by a hostname rule is then denied before the upstream
55
+ dial. Default `false`; create-only. Independently of `strict:`, any policy with
56
+ domain rules checks plain-HTTP `Host` headers against it.
57
+
58
+ ```ruby
59
+ Microsandbox::Sandbox.create("worker", image: "python",
60
+ network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
61
+ rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
62
+ destination: Microsandbox::Destination.domain("api.example.com") }]),
63
+ strict: true)
64
+ ```
65
+
66
+ ## Outbound proxy
67
+
68
+ Route the sandbox's egress through a SOCKS4 (TCP) or SOCKS5 (TCP + non-DNS UDP)
69
+ proxy with `proxy:` (runtime `v0.6.17`). The proxy is dialed by the runtime's
70
+ host-side network stack, so its address is resolved from the host (`127.0.0.1`
71
+ is the host's loopback), and the egress policy (`network:`) still governs which
72
+ destinations may be reached. A SOCKS5 password comes from a host environment
73
+ variable via `SecretSource.env` — only the variable's *name* is handed to the
74
+ runtime. Local backend only.
75
+
76
+ ```ruby
77
+ Microsandbox::Sandbox.create("worker", image: "python",
78
+ proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
79
+
80
+ Microsandbox::Sandbox.create("worker", image: "python",
81
+ proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
82
+ .credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
83
+
84
+ Microsandbox::Sandbox.create("worker", image: "python",
85
+ proxy: Microsandbox::OutboundProxy.socks4("127.0.0.1:1080", user_id: "ci"))
86
+
87
+ # The equivalent plain Hash works too:
88
+ Microsandbox::Sandbox.create("worker", image: "python",
89
+ proxy: { protocol: :socks5, address: "10.0.0.5:1080",
90
+ credentials: { username: "sandbox", password: { env: "PROXY_PASSWORD" } } })
91
+ ```
92
+
93
+ ## Secrets
94
+
95
+ `secrets:` gives the guest a placeholder and substitutes the real value only in
96
+ requests to the hosts you allow, with multi-host / wildcard allow-lists,
97
+ injection toggles, and a per-secret or sandbox-level violation policy. At
98
+ create time it is an **Array of specs**: `env:` is the variable the guest sees
99
+ (holding the placeholder), `value:` is the real secret, and `host:` / `hosts:` /
100
+ `host_patterns:` is the allow-list.
101
+
102
+ ```ruby
103
+ Microsandbox::Sandbox.create("worker", image: "python",
104
+ secrets: [{ env: "API_KEY", value: ENV.fetch("HOST_API_KEY"), host: "api.example.com" }])
105
+ # inside the guest: $API_KEY is "$MSB_API_KEY"; the proxy swaps in the real
106
+ # value for requests to api.example.com only
107
+ ```
108
+
109
+ An inline `value:` is part of the sandbox's saved configuration: it is stored
110
+ in cleartext in the local catalog under the runtime home (`~/.microsandbox` by
111
+ default) until the sandbox is removed, and `SandboxHandle#config` returns it.
112
+ Stopping a sandbox does not remove it. Use `ephemeral: true` for short-lived
113
+ work, remove sandboxes you no longer need, and do not log `#config`.
114
+
115
+ Rotating an existing secret on a running sandbox is a live `modify`, which
116
+ takes a **different shape** — a Hash keyed by name, where `env:` names a *host*
117
+ environment variable to read the new value from. See
118
+ [observability.md](observability.md#live-modification-and-health).
119
+
120
+ ## Volumes and mounts
121
+
122
+ Persistent storage that outlives individual sandboxes:
123
+
124
+ ```ruby
125
+ Microsandbox::Volume.create("cache", kind: "disk", size_mib: 512)
126
+ Microsandbox::Volume.list # => [Microsandbox::VolumeInfo, ...]
127
+
128
+ Microsandbox::Sandbox.create("with-vol", image: "public.ecr.aws/docker/library/alpine:latest",
129
+ volumes: { "/data" => { named: "cache" } }) do |sb|
130
+ sb.fs.write("/data/state.txt", "persisted")
131
+ end
132
+
133
+ Microsandbox::Volume.remove("cache")
134
+ ```
135
+
136
+ `volumes:` accepts a host path String (bind mount) or `{ bind: "/host" }` /
137
+ `{ named: "volume-name" }` per guest path (tmpfs and disk mounts, mount
138
+ policies, and a per-bind-mount `quota_mib:` are supported too). A bind or
139
+ named mount may pin the fallback guest owner for host files with `uid:`/`gid:`
140
+ (both required together, runtime `v0.6.15`). Named volumes can also be read and
141
+ written from the host without booting a sandbox via `Volume.fs` /
142
+ `VolumeInfo#fs`.
143
+
144
+ A relative host path (`volumes: { "/data" => "./data" }`, `image: "./rootfs"`,
145
+ a `root_disk:` image, a `Patch.copy_file` source, a `tls:` certificate, a
146
+ path-form `from_snapshot:`) is resolved **once, when the sandbox is created**,
147
+ against the creating process's working directory, and stored as an absolute
148
+ path. Starting the sandbox later from another directory mounts the same host
149
+ directory. Resolution does not follow symlinks. Sandboxes created by gem
150
+ versions before `0.17.1` kept the relative path and resolved it again on every
151
+ start ([GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm));
152
+ recreate those. On a cloud backend, host paths are sent as written.
153
+
154
+ Boot from a snapshot with `Sandbox.create(name, from_snapshot: "snap-name-or-path")`.
data/docs/errors.md ADDED
@@ -0,0 +1,74 @@
1
+ # Error handling
2
+
3
+ All errors descend from `Microsandbox::Error` (itself a `StandardError`) and
4
+ carry a stable, machine-readable `#code`. The native layer raises the matching
5
+ subclass for each core error variant; unmapped variants surface as the base
6
+ `Error`. The hierarchy is flat, mirroring the Python SDK.
7
+
8
+ ```ruby
9
+ begin
10
+ Microsandbox::Sandbox.create("dup", image: "public.ecr.aws/docker/library/alpine:latest")
11
+ Microsandbox::Sandbox.create("dup", image: "public.ecr.aws/docker/library/alpine:latest") # name clash
12
+ rescue Microsandbox::SandboxAlreadyExistsError => e
13
+ warn "#{e.code}: #{e.message}" # => "sandbox-already-exists: ..."
14
+ rescue Microsandbox::Error => e
15
+ warn "microsandbox failed: #{e.message}"
16
+ end
17
+ ```
18
+
19
+ `Microsandbox::SomeError.code` returns the code for a class without an instance.
20
+ Ruby-side keyword validation (a malformed `secrets:` spec, mutually exclusive
21
+ options) raises a plain `ArgumentError` before the native layer is reached.
22
+
23
+ ## Classes and codes
24
+
25
+ | Class | `#code` | Raised when |
26
+ |-------|---------|-------------|
27
+ | `Error` | `microsandbox-error` | Base class; unmapped core errors |
28
+ | `InvalidConfigError` | `invalid-config` | Bad create options, invalid backend/cloud configuration |
29
+ | `SandboxNotFoundError` | `sandbox-not-found` | No sandbox with that name |
30
+ | `SandboxNotRunningError` | `sandbox-not-running` | Operation needs a running guest (`ping`/`touch`/`exec` on a stopped sandbox) |
31
+ | `SandboxAlreadyExistsError` | `sandbox-already-exists` | Name clash on `create` without `replace:` |
32
+ | `SandboxStillRunningError` | `sandbox-still-running` | `remove` on a running sandbox |
33
+ | `SandboxReplacedError` | `sandbox-replaced` | The identity a handle was bound to no longer owns the name (see [lifecycle.md](lifecycle.md#convergent-lifecycle)) |
34
+ | `ExecTimeoutError` | `exec-timeout` | `exec(timeout:)` / `shell(timeout:)` deadline hit |
35
+ | `ExecFailedError` | `exec-failed` | Spawn-time failure (e.g. command not found) — a non-zero exit is **not** an error |
36
+ | `NoDefaultCommandError` | `no-default-command` | `exec_default` / `attach_default` on an image with no runnable `ENTRYPOINT`+`CMD` |
37
+ | `FilesystemError` | `filesystem-error` | Guest filesystem I/O failure |
38
+ | `PathNotFoundError` | `path-not-found` | Reserved for parity; the native layer currently reports missing guest paths as `FilesystemError` |
39
+ | `VolumeNotFoundError` | `volume-not-found` | No named volume with that name |
40
+ | `VolumeAlreadyExistsError` | `volume-already-exists` | `Volume.create` name clash |
41
+ | `ImageNotFoundError` | `image-not-found` | Image absent from the cache / registry |
42
+ | `ImageInUseError` | `image-in-use` | `Image.remove` on an image a sandbox still references |
43
+ | `ImagePullFailedError` | `image-pull-failed` | Reserved for parity; not raised by the current native mapping |
44
+ | `SnapshotNotFoundError` | `snapshot-not-found` | No snapshot with that name |
45
+ | `SnapshotAlreadyExistsError` | `snapshot-already-exists` | Snapshot name clash |
46
+ | `SnapshotSandboxRunningError` | `snapshot-sandbox-running` | Snapshot requested from a running source sandbox |
47
+ | `SnapshotImageMissingError` | `snapshot-image-missing` | Restore needs an image that is not cached |
48
+ | `SnapshotIntegrityError` | `snapshot-integrity` | Snapshot payload failed verification |
49
+ | `SnapshotMigrationError` | `snapshot-migration` | Automatic snapshot-descriptor migration was blocked and needs repair |
50
+ | `NetworkPolicyError` | `network-policy-error` | Invalid network policy or `NetworkBuilder` validation failure (an invalid `proxy:` is `InvalidConfigError`) |
51
+ | `SecretViolationError` | `secret-violation` | Reserved for parity; not raised by the current native mapping |
52
+ | `TlsError` | `tls-error` | Reserved for parity; not raised by the current native mapping |
53
+ | `IoError` | `io-error` | Host-side I/O failure |
54
+ | `MetricsDisabledError` | `metrics-disabled` | Metrics turned off for this sandbox |
55
+ | `MetricsUnavailableError` | `metrics-unavailable` | Reserved for parity; not raised at the pinned runtime. The transient "no live metrics slot" window right after boot raises the base `Microsandbox::Error` — match on the message |
56
+ | `UnsupportedOperationError` | `unsupported-operation` | The runtime version does not support the operation |
57
+ | `CloudHttpError` | `cloud-http` | Cloud backend HTTP failure |
58
+ | `UnsupportedError` | `unsupported` | The selected backend does not implement the operation; carries `#operation` (e.g. `"sandbox.kill"`) and `#hint` |
59
+
60
+ The snapshot classes go beyond the Python SDK (which collapses them into its
61
+ base error) and match the Go SDK's per-variant coverage, so callers can rescue
62
+ a missing / duplicate / running-source / missing-image / corrupt snapshot
63
+ specifically.
64
+
65
+ The authoritative list is `lib/microsandbox/errors.rb`; the core-variant to
66
+ class mapping lives in `ext/microsandbox/src/error.rs`, and every class is
67
+ declared in [`sig/microsandbox.rbs`](../sig/microsandbox.rbs). Classes marked
68
+ "reserved" exist so callers can rescue them today without a code change once
69
+ the native layer maps a core variant to them.
70
+
71
+ ## Secrets are redacted
72
+
73
+ The native layer redacts secret values from error messages (since `0.8.2`),
74
+ and a rejected proxy credential is reported by kind only, never by value.
data/docs/execution.md ADDED
@@ -0,0 +1,101 @@
1
+ # Executing commands
2
+
3
+ ## `exec` and `shell`
4
+
5
+ ```ruby
6
+ Microsandbox::Sandbox.create("exec-demo", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
7
+ # Direct command (no shell)
8
+ out = sb.exec("ls", ["-la", "/etc"], cwd: "/", timeout: 30)
9
+ out.exit_code # => 0
10
+ out.success? # => true
11
+ out.stdout # => "..." (UTF-8)
12
+ out.stderr_bytes # => raw ASCII-8BIT bytes
13
+
14
+ # Shell script (pipes, redirects, &&)
15
+ sb.shell("cat /etc/os-release | grep VERSION").stdout
16
+
17
+ # Environment, stdin, working directory
18
+ sb.exec("cat", [], stdin: "piped data")
19
+ sb.exec("sh", ["-c", "echo $GREETING"], env: { "GREETING" => "hi" })
20
+ end
21
+ ```
22
+
23
+ A non-zero exit is **not** an error — inspect `exit_code`/`success?`. Spawn-time
24
+ failures (e.g. command not found) and timeouts raise typed errors
25
+ ([errors.md](errors.md)). `exec`/`shell` also take per-call `rlimits`.
26
+
27
+ ## Default workload
28
+
29
+ `create` is strictly boot-only (runtime `v0.6.9`): it never runs the image's
30
+ `ENTRYPOINT`/`CMD`. Execute the image's own command explicitly:
31
+
32
+ ```ruby
33
+ Microsandbox::Sandbox.create("worker", image: "example/worker:latest",
34
+ cmd: ["worker.py", "--once"]) do |sb| # cmd: overrides the durable image CMD
35
+ out = sb.exec_default(timeout: 300) # buffered; exec-style options
36
+ handle = sb.exec_default_stream # or streaming (returns an ExecHandle)
37
+ sb.attach_default # or interactive (host TTY)
38
+ end
39
+ ```
40
+
41
+ An image whose entrypoint and CMD resolve to no executable command raises
42
+ `Microsandbox::NoDefaultCommandError`.
43
+
44
+ ## Streaming output
45
+
46
+ For long-running commands, stream events as they arrive instead of waiting:
47
+
48
+ ```ruby
49
+ Microsandbox::Sandbox.create("stream", image: "public.ecr.aws/docker/library/python:3-slim") do |sb|
50
+ handle = sb.exec_stream("python", ["-u", "-c", "import time\nfor i in range(3): print(i); time.sleep(1)"])
51
+ handle.each do |event| # ExecHandle is Enumerable
52
+ print event.text if event.stdout?
53
+ end
54
+ # or: out = handle.collect → ExecOutput (drain to the end)
55
+ # interactive stdin — create the stream with stdin: :pipe to get a writable sink:
56
+ # h = sb.exec_stream("cat", [], stdin: :pipe)
57
+ # sink = h.stdin; sink.write("data\n"); sink.close # close sends EOF
58
+ # control: handle.signal(15), handle.kill, handle.resize(rows, cols)
59
+ end
60
+ ```
61
+
62
+ `shell_stream` is the shell-script counterpart. For a host-TTY-coupled
63
+ interactive session (raw mode + SIGWINCH), use `attach`/`attach_shell`.
64
+
65
+ ### Streams are single-pass
66
+
67
+ `ExecHandle`, `LogStream`, `MetricsStream`, `FsReadStream`, `PullSession`, and
68
+ `AgentStream` are `Enumerable`, but `each` drains a one-shot native channel —
69
+ they are forward-only, not rewindable, and meant for a single consumer. Iterate
70
+ (or `collect`/`read`) exactly once: a second `each`, or a combinator after a
71
+ partial drain (`count` then `each`, `to_a` twice), silently yields nothing.
72
+ Don't share one handle across threads.
73
+
74
+ ## Threads, the GVL, and timeouts
75
+
76
+ The GVL is released during sandbox calls, so *other* Ruby threads keep
77
+ running. The *calling* thread blocks uninterruptibly until the call returns:
78
+ `Timeout::timeout`, `Thread#kill`, and Ctrl-C cannot interrupt a blocked native
79
+ call. Bound long-running work with a real deadline where one exists:
80
+
81
+ - `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds.
82
+ - `AgentClient.connect_sandbox` / `connect_path` take a `timeout:` that bounds
83
+ only the connect handshake.
84
+ - The streaming paths and `AgentClient#request`/`#stream` have no timeout knob
85
+ and can block indefinitely if the guest wedges.
86
+
87
+ See [DESIGN.md](../DESIGN.md#sync-not-async) for the sync-over-async bridge
88
+ behind this.
89
+
90
+ ## SSH and SFTP
91
+
92
+ `Sandbox#ssh` opens an in-process SSH client to the guest (`SshClient`, with an
93
+ `SftpClient` for file transfer) or prepares an `SshServer`. `open_client` /
94
+ `prepare_server` accept `inactivity_timeout:` in seconds (`0` disables, `nil`
95
+ inherits the 600s global default).
96
+
97
+ ## Raw agent client
98
+
99
+ `Microsandbox::AgentClient` gives byte-level access to the guest `agentd`
100
+ protocol (`AgentStream`/`AgentFrame`) for protocol-level work the higher-level
101
+ API doesn't cover.
@@ -0,0 +1,45 @@
1
+ # Guest filesystem
2
+
3
+ `Sandbox#fs` is a `Microsandbox::FS` bound to the running guest.
4
+
5
+ ```ruby
6
+ Microsandbox::Sandbox.create("fs-demo", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
7
+ sb.fs.write("/tmp/data.txt", "hello")
8
+ sb.fs.read_text("/tmp/data.txt") # => "hello" (UTF-8)
9
+ sb.fs.read("/tmp/data.txt") # => raw bytes (ASCII-8BIT)
10
+ sb.fs.exists?("/tmp/data.txt") # => true
11
+
12
+ sb.fs.mkdir("/tmp/sub")
13
+ sb.fs.copy("/tmp/data.txt", "/tmp/sub/copy.txt")
14
+ sb.fs.rename("/tmp/sub/copy.txt", "/tmp/sub/renamed.txt")
15
+ sb.fs.list("/tmp/sub") # => [Microsandbox::FsEntry, ...]
16
+ sb.fs.stat("/tmp/data.txt") # => Microsandbox::FsMetadata
17
+
18
+ # Host <-> guest copies
19
+ sb.fs.copy_from_host("./local.txt", "/tmp/local.txt")
20
+ sb.fs.copy_to_host("/tmp/out.txt", "./out.txt")
21
+ end
22
+ ```
23
+
24
+ ## Streaming reads and writes
25
+
26
+ Large files stream instead of buffering: `fs.read_stream(path)` returns a
27
+ single-pass `FsReadStream` (see
28
+ [execution.md](execution.md#streams-are-single-pass)), and
29
+ `fs.write_stream(path)` returns an `FsWriteSink` to write into and close.
30
+
31
+ ## Errors
32
+
33
+ Guest filesystem failures, including missing paths, raise
34
+ `Microsandbox::FilesystemError` (`#code` `filesystem-error`); the message
35
+ carries the guest-side detail. See [errors.md](errors.md).
36
+
37
+ ## Host-side volume access
38
+
39
+ Named volumes can be read and written from the host without a running sandbox
40
+ through `Microsandbox::Volume.fs(name)` / `VolumeInfo#fs` (a `VolumeFs`). See
41
+ [configuration.md](configuration.md#volumes-and-mounts).
42
+
43
+ Paths are relative to the volume root; a leading `/` is the volume root, not the
44
+ host root. A path containing a `..` segment is refused with `FilesystemError` in
45
+ every operation.
data/docs/images.md ADDED
@@ -0,0 +1,65 @@
1
+ # Images, registries, and snapshots
2
+
3
+ ## The local image cache
4
+
5
+ Images are pulled automatically on `create` (governed by `pull_policy:`). The
6
+ cache itself is managed through `Microsandbox::Image`:
7
+
8
+ ```ruby
9
+ Microsandbox::Image.list # => [Microsandbox::ImageInfo, ...]
10
+ Microsandbox::Image.get("public.ecr.aws/docker/library/alpine:latest") # => Microsandbox::ImageInfo
11
+ Microsandbox::Image.inspect("public.ecr.aws/docker/library/alpine:latest").layers # => [{...}, ...]
12
+ Microsandbox::Image.remove("public.ecr.aws/docker/library/alpine:latest", force: true)
13
+ report = Microsandbox::Image.prune
14
+ report.bytes_reclaimed
15
+ ```
16
+
17
+ `Image.save`/`Image.load` move images through OCI archives, and
18
+ `Sandbox.create_with_progress` returns a `PullSession` that streams pull
19
+ progress before yielding the booted sandbox.
20
+
21
+ ## Why the examples use `public.ecr.aws/docker/library/...`
22
+
23
+ Anonymous **Docker Hub** pulls are rate-limited and often fail with
24
+ `registry error: Not authorized`. The examples pull from AWS's public mirror of
25
+ the Docker Library instead. Plain short names like `image: "python"` work too if
26
+ you aren't rate-limited; for authenticated Docker Hub, pass `registry_auth:`.
27
+
28
+ ## Private & authenticated registries
29
+
30
+ For a private registry — or to lift Docker Hub's anonymous rate limit — pass
31
+ `registry_auth:` with a username and a password or token:
32
+
33
+ ```ruby
34
+ Microsandbox::Sandbox.create(
35
+ "private",
36
+ image: "registry.example.com/team/app:latest",
37
+ registry_auth: { username: "ci-bot", password: ENV.fetch("REGISTRY_TOKEN") }
38
+ ) do |sb|
39
+ # ...
40
+ end
41
+ ```
42
+
43
+ For self-hosted registries you can also reach the registry over plain HTTP and
44
+ trust a private CA:
45
+
46
+ ```ruby
47
+ Microsandbox::Sandbox.create(
48
+ "internal",
49
+ image: "registry.internal:5000/app:latest",
50
+ registry_insecure: true, # plain HTTP instead of HTTPS
51
+ registry_ca_certs: File.read("/etc/pki/internal-ca.pem") # String or Array of PEMs
52
+ )
53
+ ```
54
+
55
+ Without `registry_auth:`, the core's default credential resolution still applies
56
+ (OS keyring, global config, and `~/.docker/config.json`), so an existing
57
+ `docker login` is honored automatically.
58
+
59
+ ## Snapshots
60
+
61
+ `Microsandbox::Snapshot` covers create / open / get / list / list_dir / reindex /
62
+ remove / verify / save / load, and `Sandbox.create(name, from_snapshot: ...)` boots
63
+ from one. Snapshot failures raise the typed `Snapshot*Error` classes listed in
64
+ [errors.md](errors.md). Payload integrity is opt-in (`record_integrity:`), and
65
+ `verify` can report `:not_recorded` for snapshots taken without it.
data/docs/lifecycle.md ADDED
@@ -0,0 +1,108 @@
1
+ # Sandbox lifecycle
2
+
3
+ `Sandbox.create` boots a microVM and returns a live `Microsandbox::Sandbox`.
4
+ `Sandbox.get`/`Sandbox.list` return a controllable `Microsandbox::SandboxHandle`
5
+ for sandboxes that already exist. The live object owns the coarse controls
6
+ (`stop`/`kill`/`drain`/`wait`); the handle owns the fine-grained ones
7
+ (`stop_with_timeout`, `request_*`, `wait_until_stopped`, `config`, `snapshot`).
8
+
9
+ ## Create, stop, inspect
10
+
11
+ Stopping is not removing. The block form **stops** the sandbox when the block
12
+ returns; the stopped sandbox keeps its name, its saved configuration and its
13
+ disk state in the catalog until you remove it, and creating another sandbox
14
+ with the same name raises `SandboxAlreadyExistsError`. Three ways to finish:
15
+
16
+ - `ephemeral: true` at create — removed as soon as it stops; right for one-off
17
+ work and for code that runs repeatedly under a fixed name.
18
+ - `sb.destroy` (or `Sandbox.remove(name)` once stopped) — remove explicitly.
19
+ - Leave it stopped and bring it back later with `Sandbox.start(name)`.
20
+
21
+ Block forms used in a loop without one of the first two accumulate stopped
22
+ sandboxes (and their disk usage) indefinitely.
23
+
24
+ ```ruby
25
+ # Block form — recommended; stops the sandbox automatically (even on error)
26
+ Microsandbox::Sandbox.create("box", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
27
+ # ...
28
+ end
29
+
30
+ # Manual form — you are responsible for stopping it
31
+ sb = Microsandbox::Sandbox.create("box", image: "public.ecr.aws/docker/library/alpine:latest")
32
+ begin
33
+ # ...
34
+ ensure
35
+ sb.stop # graceful (SIGTERM→SIGKILL escalation, 10s default)
36
+ # sb.stop_and_wait # graceful, then wait → ExitStatus(#exit_code, #success?)
37
+ # sb.kill # force (SIGKILL); sb.drain for a graceful drain
38
+ end
39
+
40
+ # Inspect / manage existing sandboxes. `list` returns a cursor-paginated,
41
+ # Enumerable SandboxPage of handles.
42
+ Microsandbox::Sandbox.list # => Microsandbox::SandboxPage (first page)
43
+ Microsandbox::Sandbox.list.map(&:name) # enumerate the page's handles
44
+ # next page: Sandbox.list_with(cursor: page.next_cursor, limit: 50)
45
+ h = Microsandbox::Sandbox.get("box") # => Microsandbox::SandboxHandle
46
+ h.status # :running, :stopped, :created, ...
47
+ h.stop_with_timeout(5) # custom escalation timeout
48
+ h.request_stop # fire-and-return; pair with #wait_until_stopped
49
+ h.request_kill
50
+ h.request_drain
51
+ h.wait_until_stopped # => Microsandbox::SandboxStopResult
52
+ Microsandbox::Sandbox.start("box") # restart a stopped sandbox
53
+ Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
54
+ ```
55
+
56
+ `Sandbox.create` is strictly boot-only: it never runs the image's
57
+ `ENTRYPOINT`/`CMD`. See [execution.md](execution.md#default-workload) for
58
+ `exec_default`.
59
+
60
+ ## Convergent lifecycle
61
+
62
+ Idempotent operations that take a name to the state you want, whatever state
63
+ it is in now (runtime `v0.6.16`):
64
+
65
+ ```ruby
66
+ # Create it, or connect to (and start) the one that is already there. The
67
+ # keyword options are `create`'s and apply only when a create actually happens.
68
+ sb = Microsandbox::Sandbox.connect_or_create("box", image: "public.ecr.aws/docker/library/alpine:latest")
69
+
70
+ sb.id # opaque identity of the *persisted* sandbox — unlike
71
+ # the reusable name, it changes on remove+recreate
72
+ sb.wait_for_status(:running) # => SandboxHandle (no built-in timeout)
73
+ sb = sb.restart # stop + start; => a new live Sandbox
74
+ sb.destroy # stop + remove, in one step
75
+
76
+ h = Microsandbox::Sandbox.get("box")
77
+ h.connect_or_start # connect if running, start if not => Sandbox
78
+ h.restart(force: true, timeout: 5)
79
+ h.destroy
80
+ ```
81
+
82
+ Everything that acts on an *existing* receiver — `wait_for_status`, `restart`,
83
+ `destroy`, `connect_or_start` — compares the identity it was bound to against
84
+ the name's current owner first, raising `Microsandbox::SandboxReplacedError`
85
+ rather than touching a sandbox someone else recreated under the same name.
86
+ (`connect_or_create` is the entry point, so it has no prior identity to check:
87
+ it simply converges on whichever sandbox now owns the name.)
88
+
89
+ `connect_or_create`'s block form stops the sandbox only when the call owns its
90
+ lifecycle — i.e. when it created it attached. A sandbox it merely connected to,
91
+ or started with `detached: true`, is left running. And the stop it does issue is
92
+ scoped to that sandbox's `id`, so a name removed and recreated while the block
93
+ ran cannot be taken down by the teardown either. `Sandbox#owns_lifecycle?`
94
+ reports which case you are in; `Sandbox#detach` hands the VM off so it
95
+ outlives the block.
96
+
97
+ ## The live `Sandbox` / `SandboxHandle` split
98
+
99
+ Upstream `v0.5.8` split the lifecycle into the live `Sandbox` and a
100
+ controllable `SandboxHandle`, and the gem mirrors it. The live
101
+ `Sandbox#stop`/`#kill` do not take a `timeout:`; `#request_stop`/
102
+ `#request_kill`/`#request_drain`/`#wait_until_stopped` and a custom stop timeout
103
+ live on the `SandboxHandle` from `Sandbox.get`. `Sandbox.get`/`.list` return a
104
+ `SandboxHandle` (formerly a read-only `SandboxInfo`, kept as a deprecated
105
+ alias).
106
+
107
+ `SandboxHandle` also carries `#ping`/`#touch`/`#modify` — see
108
+ [observability.md](observability.md).