kobako 0.6.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65375590ffd16758a33fee3ef16cfd3d1e99e439c378e9652da453a601b53f8a
4
- data.tar.gz: 62c134b7035649fcde436816d95618287768f7a8a09d59f330cb5e785eae8024
3
+ metadata.gz: ac6c01cd421449a5b87d4a21777b257a204018747d468481ff309382d08f8a89
4
+ data.tar.gz: 2c172f0ffb8d0cb600bc8837102e584ca4492f1f59ce8d77989169d078976bb0
5
5
  SHA512:
6
- metadata.gz: cc4ad901e03f7de534c41423e3e7795a410d7201caf7a4d730b93829aceefc8ff7518320c2874a80e34978a9e38d6a430b5e03edbcd2e6868cb987c1d8ed3c91
7
- data.tar.gz: 0b594e9b033b3a2d27b9f330b119ddf3f246b50f462cfb8914173d4074064ce17c3f12fb20851331f1e7c50390c39f64722820564618148de37fa06a4f603beb
6
+ metadata.gz: 88fb3c50f8e631dbe6139ef9f8ab5e5ad50f4ee08a2aec0b8cdbba5e70c9670fb78c24e80a59916daa23ce6e9663f73ba82ad26c78e69588f59e91c72a4b1cf9
7
+ data.tar.gz: 1eacade67c1ddc6de4fa8a75e5975272157ea2e08c55dcb5c790eb3528941b3b96091f3695b60389d5c93d75599b04ff99a1af530530971f2765a94b101c3f57
@@ -1 +1 @@
1
- {".":"0.6.1"}
1
+ {".":"0.7.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/elct9620/kobako/compare/v0.6.2...v0.7.0) (2026-06-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **examples/async-io:** demo single-thread I/O overlap across Sandboxes ([858a0f7](https://github.com/elct9620/kobako/commit/858a0f70bb0730e5e3ad3f49a5caadf948f6ed7d))
9
+ * **guest:** reject construction of Handle proxies (B-39) ([bda5e2b](https://github.com/elct9620/kobako/commit/bda5e2b5e48fe25adeefac19c47f1b93585091bf))
10
+ * **guest:** reject construction of Member proxies (B-38) ([885c281](https://github.com/elct9620/kobako/commit/885c2812da8627e4ec7c185d9e04e4056c94a7fd))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **ext:** surface the buried root cause on non-cap traps ([dbd1ce5](https://github.com/elct9620/kobako/commit/dbd1ce51f25ab8c83d39daee64278192d763d5ef))
16
+ * **guest:** bound the encoder walk so cycles and deep nesting fail cleanly ([90880ff](https://github.com/elct9620/kobako/commit/90880ffe6f0ee911b3c7c076ef6c86b9e08c62e1))
17
+ * **guest:** stop an embedded NUL in a returned value from hard-trapping ([14fbb97](https://github.com/elct9620/kobako/commit/14fbb97ecb47b4263585602754e92237bb951d46))
18
+ * **guest:** stop named-capture regexes from hard-trapping the sandbox ([a279ea1](https://github.com/elct9620/kobako/commit/a279ea1e2f196580b396342851e4e75ff9ea5cfa))
19
+
20
+ ## [0.6.2](https://github.com/elct9620/kobako/compare/v0.6.1...v0.6.2) (2026-05-31)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **guest:** enable mruby-sprintf for printf and String#% ([1179227](https://github.com/elct9620/kobako/commit/1179227b85b861bccc82d2a258481769a13ed4d5))
26
+
3
27
  ## [0.6.1](https://github.com/elct9620/kobako/compare/v0.6.0...v0.6.1) (2026-05-28)
4
28
 
5
29
 
data/Cargo.lock CHANGED
@@ -864,7 +864,7 @@ dependencies = [
864
864
 
865
865
  [[package]]
866
866
  name = "kobako"
867
- version = "0.6.1"
867
+ version = "0.7.0"
868
868
  dependencies = [
869
869
  "magnus",
870
870
  "wasmtime",
data/README.md CHANGED
@@ -53,9 +53,21 @@ result # => 3
53
53
 
54
54
  The script executes inside the Wasm guest. It cannot read your filesystem, open sockets, or touch your `ENV`.
55
55
 
56
+ ## Glossary
57
+
58
+ | Term | Meaning |
59
+ |------|---------|
60
+ | Sandbox | The runtime unit (`Kobako::Sandbox`) that runs guest code and returns a result or raises a typed error. |
61
+ | Service | A host Ruby object injected under `<Namespace>::<Member>` — the guest's only path to host resources. |
62
+ | Namespace / Member | A guest-visible Ruby module, and a named binding (a module constant) within it. |
63
+ | Invocation | One `#eval` or `#run`; capability state resets between invocations. |
64
+ | Snippet | Named mruby code (source or bytecode) replayed into a fresh state before every invocation. |
65
+ | Handle | An opaque token the guest holds for a host object the wire cannot transmit directly. |
66
+ | Block | A guest mruby block passed to a Service; each `yield` is a synchronous round-trip into the guest. |
67
+
56
68
  ## Usage
57
69
 
58
- ### Injecting Services
70
+ ### Services
59
71
 
60
72
  Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior.md`](docs/behavior.md) B-07..B-12.
61
73
 
@@ -77,42 +89,9 @@ sandbox.eval(<<~RUBY)
77
89
  RUBY
78
90
  ```
79
91
 
80
- Names must match `/\A[A-Z]\w*\z/`. Symbol kwargs travel transparently to the host method's keyword arguments. The registry seals at the first invocation; later `#define` raises `ArgumentError`.
81
-
82
- ### Yielding to guest blocks
83
-
84
- A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
85
-
86
- ```ruby
87
- sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
88
-
89
- sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
90
- # => [2, 4, 6]
91
- ```
92
+ Names must match `/\A[A-Z]\w*\z/`. Symbol kwargs travel transparently to the host method's keyword arguments. The registry seals at the first invocation (see [Invocation Lifecycle](#invocation-lifecycle)); later `#define` raises `ArgumentError`.
92
93
 
93
- ### Per-invocation caps
94
-
95
- Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call — populated on every outcome including traps — for actual consumption ([`docs/behavior.md`](docs/behavior.md) B-35).
96
-
97
- ```ruby
98
- sandbox = Kobako::Sandbox.new(
99
- timeout: 5.0, # seconds, default 60.0
100
- memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
101
- stdout_limit: 64 * 1024, # bytes, default 1 MiB
102
- stderr_limit: 64 * 1024
103
- )
104
- ```
105
-
106
- | Cap | Raises | Default |
107
- |----------------|----------------------------|---------|
108
- | `timeout` | `Kobako::TimeoutError` | 60.0 s |
109
- | `memory_limit` | `Kobako::MemoryLimitError` | 1 MiB |
110
- | `stdout_limit` | output clipped (no raise) | 1 MiB |
111
- | `stderr_limit` | output clipped (no raise) | 1 MiB |
112
-
113
- `memory_limit` covers the per-invocation `memory.grow` delta from the entry baseline, so a Sandbox reused across invocations does not silently accumulate against a global budget.
114
-
115
- ### Capturing stdout / stderr
94
+ ### Output Capture
116
95
 
117
96
  Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior.md`](docs/behavior.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
118
97
 
@@ -128,7 +107,7 @@ sandbox.stdout # => "hello\n"
128
107
  sandbox.stderr # => "be careful\n"
129
108
  ```
130
109
 
131
- ### Error handling
110
+ ### Error Handling
132
111
 
133
112
  Every invocation either returns a value or raises exactly one of three classes, so you can route faults without inspecting messages. The full taxonomy lives in [`lib/kobako/errors.rb`](lib/kobako/errors.rb).
134
113
 
@@ -153,25 +132,29 @@ end
153
132
 
154
133
  `SandboxError` and `ServiceError` carry structured `origin` / `klass` / `backtrace_lines` / `details` fields when the guest produced a panic envelope.
155
134
 
156
- ### Capability Handles
135
+ ### Resource Limits
157
136
 
158
- A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior.md`](docs/behavior.md) B-13..B-21, B-34, B-37).
137
+ Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call populated on every outcome including traps for actual consumption ([`docs/behavior.md`](docs/behavior.md) B-35).
159
138
 
160
139
  ```ruby
161
- class Greeter
162
- def initialize(name) = @name = name
163
- def greet = "hi, #{@name}"
164
- end
165
-
166
- sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
167
-
168
- sandbox.eval('Factory::Make.call("Bob").greet') # => "hi, Bob" (Handle round-trip inside guest)
169
- sandbox.eval('Factory::Make.call("Bob")') # => #<Greeter @name="Bob"> (B-37 restoration)
140
+ sandbox = Kobako::Sandbox.new(
141
+ timeout: 5.0, # seconds, default 60.0
142
+ memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
143
+ stdout_limit: 64 * 1024, # bytes, default 1 MiB
144
+ stderr_limit: 64 * 1024
145
+ )
170
146
  ```
171
147
 
172
- A `break` value from a guest block is the one exception: it unwinds back to the guest Member call rather than to host code, so a Handle in it stays a Handle — restoring would just re-wrap the same object into a new id on the return trip.
148
+ | Cap | Raises | Default |
149
+ |----------------|----------------------------|---------|
150
+ | `timeout` | `Kobako::TimeoutError` | 60.0 s |
151
+ | `memory_limit` | `Kobako::MemoryLimitError` | 1 MiB |
152
+ | `stdout_limit` | output clipped (no raise) | 1 MiB |
153
+ | `stderr_limit` | output clipped (no raise) | 1 MiB |
173
154
 
174
- ### Setup-once, run-many
155
+ `memory_limit` covers the per-invocation `memory.grow` delta from the entry baseline, so a Sandbox reused across invocations does not silently accumulate against a global budget.
156
+
157
+ ### Invocation Lifecycle
175
158
 
176
159
  One Sandbox serves many invocations. Service bindings and preloaded snippets persist across calls; capability state (Handles, stdout, stderr, memory delta) resets between them.
177
160
 
@@ -216,7 +199,54 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
216
199
 
217
200
  For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), construct a fresh `Kobako::Sandbox` per scope — wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
218
201
 
219
- ### Preloaded snippets and entrypoint dispatch
202
+ ### Service Blocks
203
+
204
+ A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
205
+
206
+ ```ruby
207
+ sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
208
+
209
+ sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
210
+ # => [2, 4, 6]
211
+ ```
212
+
213
+ ### Handle Management
214
+
215
+ A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior.md`](docs/behavior.md) B-13..B-21, B-34, B-37).
216
+
217
+ ```ruby
218
+ class Greeter
219
+ def initialize(name) = @name = name
220
+ def greet = "hi, #{@name}"
221
+ end
222
+
223
+ sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
224
+
225
+ sandbox.eval('Factory::Make.call("Bob").greet') # => "hi, Bob" (Handle round-trip inside guest)
226
+ sandbox.eval('Factory::Make.call("Bob")') # => #<Greeter @name="Bob"> (B-37 restoration)
227
+ ```
228
+
229
+ A `break` value from a guest block is the one exception: it unwinds back to the guest Member call rather than to host code, so a Handle in it stays a Handle — restoring would just re-wrap the same object into a new id on the return trip.
230
+
231
+ Each dispatch that hands back a non-wire-representable object allocates a *new* Handle — kobako never deduplicates by object identity (B-15, B-17). This is most visible with fluent / builder APIs. An `ActiveRecord::Relation` chain `spawn`s a fresh relation at each step, so every hop is an independent dispatch that binds its own Handle:
232
+
233
+ ```
234
+ guest chain host (Catalog::Handles, one invocation)
235
+ ─────────── ─────────────────────────────────────────
236
+ User.where(active: true) ─call──▶ Relation #1 (fresh clone) bound ▶ Handle 1
237
+ ◀─Handle 1
238
+ .order(:created_at) ─call──▶ Relation #2 (fresh clone) bound ▶ Handle 2
239
+ ◀─Handle 2
240
+ .limit(10) ─call──▶ Relation #3 (fresh clone) bound ▶ Handle 3
241
+ ◀─Handle 3
242
+
243
+ 3 hops ─▶ 3 dispatches ─▶ 3 distinct relations ─▶ 3 Handles
244
+ all stay live until the invocation ends, then reset together
245
+ ```
246
+
247
+ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and reset between invocations, so even deep chains stay far inside the range. Two consequences are worth keeping in mind: the same host object handed back twice yields two *different* Handles — the guest cannot tell they alias — and every intermediate Handle stays live until the invocation ends, since there is no per-Handle release (B-19).
248
+
249
+ ### Snippets & Entrypoints
220
250
 
221
251
  `Sandbox#preload` registers named mruby snippets that replay against the fresh `mrb_state` before every invocation; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior.md`](docs/behavior.md) B-31..B-33).
222
252
 
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -76,8 +76,9 @@ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
76
76
  /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
77
77
  /// outer wrapper at `format!("{}", err)` would otherwise surface only
78
78
  /// the `"error while executing at wasm backtrace: ..."` framing, which
79
- /// is operator noise on a cap trap. For `TrapClass::Other` the
80
- /// wasmtime wrapper IS the diagnostic (real script trap) so it stays.
79
+ /// is operator noise on a cap trap. For `TrapClass::Other` the framing
80
+ /// is kept but the chain's root cause is appended (see
81
+ /// `other_trap_message`) so the real trap reason survives.
81
82
  pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
82
83
  match classify_trap(&err) {
83
84
  TrapClass::Timeout => {
@@ -94,7 +95,24 @@ pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
94
95
  .unwrap_or_else(|| format!("{}", err));
95
96
  memory_limit_err(ruby, msg)
96
97
  }
97
- TrapClass::Other => trap_err(ruby, format!("{}", err)),
98
+ TrapClass::Other => trap_err(ruby, other_trap_message(&err)),
99
+ }
100
+ }
101
+
102
+ /// Compose the message for a non-cap trap. wasmtime's `Display` surfaces only
103
+ /// the `"error while executing at wasm backtrace: ..."` framing; the actual
104
+ /// trap reason (e.g. `"wasm trap: indirect call type mismatch"`) is the
105
+ /// chain's root cause and would otherwise be dropped, making real guest
106
+ /// faults undiagnosable. Append the root cause unless the framing already
107
+ /// carries it. Pure so it can be exercised from `cargo test` without the
108
+ /// magnus surface.
109
+ fn other_trap_message(err: &wasmtime::Error) -> String {
110
+ let display = format!("{}", err);
111
+ let root = err.root_cause().to_string();
112
+ if display.contains(&root) {
113
+ display
114
+ } else {
115
+ format!("{display}\n\n{root}")
98
116
  }
99
117
  }
100
118
 
@@ -111,7 +129,7 @@ pub(super) fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError
111
129
 
112
130
  #[cfg(test)]
113
131
  mod tests {
114
- use super::{classify_trap, TrapClass};
132
+ use super::{classify_trap, other_trap_message, TrapClass};
115
133
  use crate::runtime::invocation::{MemoryLimitTrap, TimeoutTrap};
116
134
 
117
135
  #[test]
@@ -131,4 +149,30 @@ mod tests {
131
149
  let err = wasmtime::Error::msg("some other wasmtime fault");
132
150
  assert_eq!(classify_trap(&err), TrapClass::Other);
133
151
  }
152
+
153
+ // A guest hard trap reaches the host as a wasmtime error whose Display is
154
+ // only the backtrace framing, with the trap reason buried as the chain's
155
+ // root cause. The named-capture regex bug surfaced as exactly this shape.
156
+ #[test]
157
+ fn other_trap_message_surfaces_buried_trap_reason() {
158
+ let err = wasmtime::Error::msg("wasm trap: indirect call type mismatch")
159
+ .context("error while executing at wasm backtrace:\n 0: 0x1 - <unknown>");
160
+ let msg = other_trap_message(&err);
161
+ assert!(
162
+ msg.contains("indirect call type mismatch"),
163
+ "a non-cap trap surfaced through Kobako::TrapError must carry the root trap reason, not only the backtrace framing; got: {msg}"
164
+ );
165
+ assert!(
166
+ msg.contains("error while executing"),
167
+ "a non-cap trap surfaced through Kobako::TrapError must keep the wasm backtrace framing; got: {msg}"
168
+ );
169
+ }
170
+
171
+ // A flat error (no cause chain) is its own root_cause; appending it would
172
+ // duplicate the whole message.
173
+ #[test]
174
+ fn other_trap_message_does_not_duplicate_a_flat_error() {
175
+ let err = wasmtime::Error::msg("plain fault");
176
+ assert_eq!(other_trap_message(&err), "plain fault");
177
+ }
134
178
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,10 @@
1
+ # Pin the Rust toolchain so local builds and CI stay byte-identical.
2
+ # The wasm32-wasip1 crt1-command.o references __wasi_init_tp from 1.96 onward;
3
+ # vendored wasi-sdk 33's libc.a supplies that symbol, so the two move together.
4
+ # Bump this in lockstep with WASI_SDK_VERSION in tasks/support/kobako_vendor.rb.
5
+ # This file is the single source of the channel; the CI workflows read it.
6
+ [toolchain]
7
+ channel = "1.96.0"
8
+ components = ["clippy", "rustfmt"]
9
+ targets = ["wasm32-wasip1"]
10
+ profile = "minimal"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kobako
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aotokitsuruya
@@ -103,6 +103,7 @@ files:
103
103
  - lib/kobako/usage.rb
104
104
  - lib/kobako/version.rb
105
105
  - release-please-config.json
106
+ - rust-toolchain.toml
106
107
  - sig/kobako.rbs
107
108
  - sig/kobako/capture.rbs
108
109
  - sig/kobako/catalog.rbs