kobako 0.13.0 → 0.15.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +81 -0
  4. data/Cargo.lock +3 -3
  5. data/README.md +107 -32
  6. data/ROADMAP.md +25 -0
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  9. data/crates/kobako-runtime/Cargo.toml +1 -1
  10. data/crates/kobako-runtime/README.md +1 -1
  11. data/crates/kobako-runtime/src/error.rs +8 -0
  12. data/crates/kobako-runtime/src/snapshot.rs +7 -3
  13. data/crates/kobako-wasmtime/CHANGELOG.md +14 -0
  14. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  15. data/crates/kobako-wasmtime/README.md +1 -1
  16. data/crates/kobako-wasmtime/src/capture.rs +17 -13
  17. data/crates/kobako-wasmtime/src/driver.rs +7 -8
  18. data/crates/kobako-wasmtime/src/frames.rs +3 -10
  19. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -1
  20. data/crates/kobako-wasmtime/src/instance_pre.rs +7 -6
  21. data/crates/kobako-wasmtime/src/invocation.rs +4 -5
  22. data/crates/kobako-wasmtime/src/trap.rs +65 -71
  23. data/data/kobako.wasm +0 -0
  24. data/ext/kobako/Cargo.toml +1 -1
  25. data/ext/kobako/src/runtime/errors.rs +9 -39
  26. data/ext/kobako/src/runtime.rs +5 -20
  27. data/lib/kobako/catalog/handles.rb +3 -3
  28. data/lib/kobako/catalog/services.rb +123 -0
  29. data/lib/kobako/catalog/snippets.rb +2 -2
  30. data/lib/kobako/catalog.rb +2 -2
  31. data/lib/kobako/codec/decoder.rb +10 -10
  32. data/lib/kobako/codec/encoder.rb +5 -5
  33. data/lib/kobako/codec/error.rb +3 -3
  34. data/lib/kobako/codec/ext_types.rb +169 -0
  35. data/lib/kobako/codec/handle_walk.rb +17 -21
  36. data/lib/kobako/codec/state.rb +98 -0
  37. data/lib/kobako/codec/utils.rb +9 -9
  38. data/lib/kobako/codec.rb +23 -6
  39. data/lib/kobako/errors.rb +14 -39
  40. data/lib/kobako/fault.rb +1 -1
  41. data/lib/kobako/handle.rb +6 -3
  42. data/lib/kobako/outcome.rb +14 -7
  43. data/lib/kobako/pool.rb +1 -1
  44. data/lib/kobako/sandbox.rb +30 -25
  45. data/lib/kobako/transport/dispatcher.rb +41 -31
  46. data/lib/kobako/transport/request.rb +16 -11
  47. data/lib/kobako/transport/response.rb +11 -4
  48. data/lib/kobako/transport/run.rb +1 -1
  49. data/lib/kobako/transport/yield.rb +7 -4
  50. data/lib/kobako/transport/yielder.rb +20 -12
  51. data/lib/kobako/version.rb +1 -1
  52. data/release-please-config.json +37 -7
  53. data/sig/kobako/catalog/services.rbs +25 -0
  54. data/sig/kobako/codec/ext_types.rbs +31 -0
  55. data/sig/kobako/codec/handle_walk.rbs +2 -2
  56. data/sig/kobako/codec/state.rbs +20 -0
  57. data/sig/kobako/codec.rbs +14 -0
  58. data/sig/kobako/handle.rbs +0 -2
  59. data/sig/kobako/sandbox.rbs +1 -1
  60. data/sig/kobako/transport/dispatcher.rbs +8 -8
  61. data/sig/kobako/transport/run.rbs +1 -1
  62. data/sig/kobako/transport/yielder.rbs +3 -3
  63. data/sig/kobako/transport.rbs +8 -0
  64. metadata +8 -7
  65. data/lib/kobako/catalog/namespaces.rb +0 -115
  66. data/lib/kobako/codec/factory.rb +0 -187
  67. data/lib/kobako/namespace.rb +0 -78
  68. data/sig/kobako/catalog/namespaces.rbs +0 -17
  69. data/sig/kobako/codec/factory.rbs +0 -34
  70. data/sig/kobako/namespace.rbs +0 -21
@@ -10,18 +10,18 @@ module Kobako
10
10
  # - UTF-8 assertion at the codec boundary
11
11
  # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
12
12
  # § str/bin Encoding Rules and § Ext Types → ext 0x00). Used by
13
- # {Decoder} when walking +str+ family payloads and by {Factory}
13
+ # Decoder when walking +str+ family payloads and by ExtTypes
14
14
  # when validating the +ext 0x00+ Symbol payload.
15
15
  # - +ArgumentError+ translation at the codec boundary
16
- # ({with_boundary}) so the public taxonomy stays
17
- # {Kobako::Codec::Error}.
16
+ # (#with_boundary) so the public taxonomy stays
17
+ # Kobako::Codec::Error.
18
18
  #
19
19
  # Both helpers are pure — they only inspect inputs, never mutate them.
20
- # The host↔guest Handle substitution walk lives in {HandleWalk}.
20
+ # The host↔guest Handle substitution walk lives in HandleWalk.
21
21
  module Utils
22
22
  module_function
23
23
 
24
- # Raise {InvalidEncoding} unless +string+'s bytes are valid under
24
+ # Raise InvalidEncoding unless +string+'s bytes are valid under
25
25
  # its current encoding tag. +label+ is the caller-supplied prefix
26
26
  # for the error message (e.g. +"str payload"+, +"Symbol payload"+).
27
27
  def assert_utf8!(string, label)
@@ -32,13 +32,13 @@ module Kobako
32
32
 
33
33
  # Run +block+ at the codec boundary: a value object raises
34
34
  # +ArgumentError+ when an invariant is violated at construction, and
35
- # this helper surfaces that as {InvalidType} so the public taxonomy
36
- # stays {Kobako::Codec::Error} and never leaks +ArgumentError+ from
35
+ # this helper surfaces that as InvalidType so the public taxonomy
36
+ # stays Kobako::Codec::Error and never leaks +ArgumentError+ from
37
37
  # the Ruby standard library.
38
38
  #
39
39
  # Reach for this only where a value object is constructed outside a
40
- # {Decoder.decode} block, whose rescue already performs the same
41
- # mapping (worked example: {Factory#unpack_handle} building
40
+ # Decoder.decode block, whose rescue already performs the same
41
+ # mapping (worked example: ExtTypes#unpack_handle building
42
42
  # +Handle.restore+ from a raw fixext payload). Do not use it for
43
43
  # general-purpose validation outside the codec boundary —
44
44
  # host-layer +ArgumentError+ values should propagate unchanged.
data/lib/kobako/codec.rb CHANGED
@@ -3,7 +3,8 @@
3
3
  require_relative "codec/error"
4
4
  require_relative "codec/utils"
5
5
  require_relative "codec/handle_walk"
6
- require_relative "codec/factory"
6
+ require_relative "codec/state"
7
+ require_relative "codec/ext_types"
7
8
  require_relative "codec/encoder"
8
9
  require_relative "codec/decoder"
9
10
 
@@ -18,14 +19,30 @@ module Kobako
18
19
  # the kobako root so the codec can register them without depending
19
20
  # upward on Transport.
20
21
  #
21
- # Backed by the official +msgpack+ gem via {Factory}; {Encoder} and
22
- # {Decoder} are thin wrappers that register the three kobako-specific
23
- # ext types (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
24
- # envelope) on a single +MessagePack::Factory+ instance. The Rust side
22
+ # Backed by the official +msgpack+ gem: ExtTypes registers the three
23
+ # kobako-specific ext types (0x00 Symbol, 0x01 Capability Handle,
24
+ # 0x02 Exception envelope) on one process-wide +MessagePack::Factory+,
25
+ # and Encoder / Decoder are thin wrappers over it. The Rust side
25
26
  # mirrors this layer as the +codec+ module in the +kobako-codec+ crate;
26
- # the ext-code constants live as module-private values on {Factory}
27
+ # the ext-code constants live as module-private values on ExtTypes
27
28
  # alongside +codec::EXT_SYMBOL+ / +codec::EXT_HANDLE+ /
28
29
  # +codec::EXT_ERRENV+ on that side.
29
30
  module Codec
31
+ # Bracket a decode and return the block's result together with whether
32
+ # the decoded tree carried an ext 0x01 Capability Handle — the signal a
33
+ # dispatch path uses to skip an all-identity Handle-resolution walk.
34
+ # The tracking state is codec-internal; this is its only readout.
35
+ def self.track_handles(&block)
36
+ State.current.track_handles(&block)
37
+ end
38
+
39
+ # Bracket a codec operation in a payload position: an ext 0x02 Fault
40
+ # envelope is only legal in the Response fault field, so the envelope
41
+ # layers open this bracket around every other encode / decode and the
42
+ # ext-type conversions refuse the envelope while it is open — a wire
43
+ # violation on decode, no wire representation on encode.
44
+ def self.forbid_faults(&block)
45
+ State.current.forbid_faults(&block)
46
+ end
30
47
  end
31
48
  end
data/lib/kobako/errors.rb CHANGED
@@ -4,38 +4,13 @@
4
4
  module Kobako
5
5
  # Error taxonomy.
6
6
  #
7
- # Every `Kobako::Sandbox` invocation (`#eval` or `#run`) either returns a value or raises
8
- # exactly one of three invocation-outcome classes. Attribution is decided after the
9
- # guest binary returns control to the host: first the Wasm-trap layer, then
10
- # the outcome-envelope tag.
11
- #
12
- # Three invocation-outcome branches:
13
- #
14
- # * {TrapError} — Wasm engine layer (trap, OOM, unreachable, or a
15
- # wire-violation fallback signalling a corrupted
16
- # guest runtime).
17
- # * {SandboxError} — sandbox / wire layer (mruby script error,
18
- # wire-decode failure on an otherwise valid tag,
19
- # Catalog::Handles exhaustion, output buffer overrun).
20
- # * {ServiceError} — service / capability layer (a Service capability
21
- # call that failed and was not rescued inside the
22
- # script).
23
- #
24
- # Two further branches sit outside the invocation taxonomy:
25
- #
26
- # * {SetupError} — construction layer. Raised by `Kobako::Sandbox.new`
27
- # when the wasm runtime cannot be built from the
28
- # configured +wasm_path+ before any invocation runs.
29
- # Not an invocation outcome, so it never passes
30
- # through the two-step attribution decision.
31
- # * {PoolTimeoutError} — pool checkout layer. Raised by `Kobako::Pool#with`
32
- # when the checkout wait exceeds +checkout_timeout+.
33
- #
34
- # Named subclasses:
35
- #
36
- # * {ModuleNotBuiltError} < {SetupError} — Guest Binary artifact absent
37
- # at +wasm_path+.
38
- # * {HandleExhaustedError} < {SandboxError} — Handle id cap hit.
7
+ # Every +Kobako::Sandbox+ invocation (+#eval+ or +#run+) either returns a
8
+ # value or raises exactly one of TrapError, SandboxError, or ServiceError.
9
+ # Attribution is decided after the guest binary returns control to the
10
+ # host: first the Wasm-trap layer, then the outcome-envelope tag.
11
+ # SetupError and PoolTimeoutError sit outside the invocation taxonomy and
12
+ # never pass through that attribution decision. Each class below
13
+ # documents its own layer.
39
14
 
40
15
  # Base for all kobako-raised errors so callers that want to ignore the
41
16
  # taxonomy can rescue a single class.
@@ -48,8 +23,8 @@ module Kobako
48
23
  #
49
24
  # Two named subclasses cover the configured per-invocation caps:
50
25
  #
51
- # * {TimeoutError} — wall-clock +timeout+ exceeded.
52
- # * {MemoryLimitError} — guest +memory.grow+ would exceed
26
+ # * TimeoutError — wall-clock +timeout+ exceeded.
27
+ # * MemoryLimitError — guest +memory.grow+ would exceed
53
28
  # +memory_limit+.
54
29
  #
55
30
  # Host Apps that only care about "guest is unrecoverable, discard the
@@ -128,14 +103,14 @@ module Kobako
128
103
  class HandleExhaustedError < SandboxError; end
129
104
 
130
105
  # BytecodeError is the SandboxError subclass raised when a
131
- # `#preload(binary:)` snippet fails structural validation during the
132
- # first invocation's snippet replay against a fresh `mrb_state` (RITE
106
+ # +#preload(binary:)+ snippet fails structural validation during the
107
+ # first invocation's snippet replay against a fresh +mrb_state+ (RITE
133
108
  # version mismatch or corrupt body). Bytecode that loads cleanly and
134
- # then raises at top level surfaces as plain `SandboxError` with the
109
+ # then raises at top level surfaces as plain +SandboxError+ with the
135
110
  # natural mruby class preserved. Inherits from SandboxError so a single
136
- # `rescue Kobako::SandboxError` covers both source and bytecode
111
+ # +rescue Kobako::SandboxError+ covers both source and bytecode
137
112
  # snippet failures while callers wanting bytecode-specific handling
138
- # can `rescue Kobako::BytecodeError` directly.
113
+ # can +rescue Kobako::BytecodeError+ directly.
139
114
  class BytecodeError < SandboxError; end
140
115
 
141
116
  # Pool checkout layer. Raised by +Kobako::Pool#with+ when the checkout
data/lib/kobako/fault.rb CHANGED
@@ -5,7 +5,7 @@ module Kobako
5
5
  #
6
6
  # Top-level shared wire primitive: like +Kobako::Handle+ (ext 0x01),
7
7
  # +Fault+ is a MessagePack ext-type leaf registered by
8
- # +Kobako::Codec::Factory+ and rides nested inside other envelopes (a
8
+ # +Kobako::Codec::ExtTypes+ and rides nested inside other envelopes (a
9
9
  # +Kobako::Transport::Response+ error payload, or another Fault's
10
10
  # +details+). It lives at the kobako root rather than under +Transport+
11
11
  # because the Codec layer must register it, and Codec must not depend
data/lib/kobako/handle.rb CHANGED
@@ -14,9 +14,11 @@ module Kobako
14
14
  # The constructor is internal to the Host Gem. +Kobako::Handle.new+ is
15
15
  # privatised so Host App code cannot fabricate a Handle from a bare
16
16
  # integer; legitimate Handle instances enter Host App code only as
17
- # fields on raised error objects. The Host Gem itself constructs
18
- # Handles through {.restore}, which exists at exactly two call
19
- # sites: +Kobako::Codec::Factory#unpack_handle+ (wire decode) and
17
+ # fields on raised error objects. +#with+ Data's copy-with-changes
18
+ # constructor is removed for the same reason: a legitimate Handle
19
+ # must not derive a sibling with a caller-chosen id. The Host Gem itself constructs
20
+ # Handles through +.restore+, which exists at exactly two call
21
+ # sites: +Kobako::Codec::ExtTypes#unpack_handle+ (wire decode) and
20
22
  # +Kobako::Codec::HandleWalk.deep_wrap+ / +Kobako::Transport::Dispatcher#wrap_as_handle+
21
23
  # (allocator paths). Both live inside +lib/kobako/+ and are not part
22
24
  # of any public surface.
@@ -41,6 +43,7 @@ module Kobako
41
43
  end
42
44
 
43
45
  private_class_method :new
46
+ undef_method :with
44
47
 
45
48
  # Host Gem–internal factory. Allocates the Data instance through
46
49
  # +Class#allocate+ and dispatches +#initialize+ explicitly so the
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "codec"
3
4
  require_relative "outcome/panic"
4
5
  require_relative "transport/error"
5
6
 
@@ -69,7 +70,9 @@ module Kobako
69
70
  # "Symbol payload must be …" wording, but operators triaging a
70
71
  # corrupted Sandbox runtime still need it.
71
72
  def decode_value(body)
72
- Kobako::Codec::Decoder.decode(body)
73
+ # The Result envelope is a payload position: an ext 0x02 Fault in it
74
+ # is a wire violation routed into the invalid-result rescue below.
75
+ Kobako::Codec.forbid_faults { Kobako::Codec::Decoder.decode(body) }
73
76
  rescue Kobako::Codec::Error => e
74
77
  raise build_transport_error(
75
78
  "Sandbox produced an invalid result value",
@@ -98,13 +101,17 @@ module Kobako
98
101
  # through the decoder boundary; the message itself is never user-
99
102
  # facing — it lands in +details+ via the rescue chain above.
100
103
  def build_panic_record(body)
101
- Kobako::Codec::Decoder.decode(body) do |map|
102
- raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
104
+ # The Panic envelope is a payload position: an ext 0x02 Fault in its
105
+ # +details+ is a wire violation routed into the invalid-panic rescue.
106
+ Kobako::Codec.forbid_faults do
107
+ Kobako::Codec::Decoder.decode(body) do |map|
108
+ raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
103
109
 
104
- Panic.new(
105
- origin: map["origin"], klass: map["class"], message: map["message"],
106
- backtrace: map["backtrace"] || [], details: map["details"]
107
- )
110
+ Panic.new(
111
+ origin: map["origin"], klass: map["class"], message: map["message"],
112
+ backtrace: map["backtrace"] || [], details: map["details"]
113
+ )
114
+ end
108
115
  end
109
116
  end
110
117
 
data/lib/kobako/pool.rb CHANGED
@@ -24,7 +24,7 @@ module Kobako
24
24
  # seconds (+nil+ waits indefinitely); every other keyword is
25
25
  # forwarded verbatim to +Kobako::Sandbox.new+. The optional block
26
26
  # runs exactly once per constructed Sandbox — it is the setup window
27
- # for +#define+ / +#preload+ before that Sandbox's first checkout.
27
+ # for +#bind+ / +#preload+ before that Sandbox's first checkout.
28
28
  # No Sandbox is constructed here. Raises +ArgumentError+ for an
29
29
  # invalid +slots+ / +checkout_timeout+.
30
30
  def initialize(slots:, checkout_timeout: DEFAULT_CHECKOUT_TIMEOUT_SECONDS, **sandbox_options, &setup)
@@ -17,7 +17,7 @@ module Kobako
17
17
  #
18
18
  # The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
19
19
  # +Kobako::Catalog::Handles+, the per-instance
20
- # +Kobako::Catalog::Namespaces+ (which receives the +Catalog::Handles+ by
20
+ # +Kobako::Catalog::Services+ (which receives the +Catalog::Handles+ by
21
21
  # injection so guest→host dispatch and host→guest auto-wrap share one
22
22
  # allocator), and the dispatch +Proc+ / +yield_to_guest+ lambda installed
23
23
  # on the Runtime via +Runtime#on_dispatch=+. The underlying wasmtime Engine
@@ -100,27 +100,33 @@ module Kobako
100
100
  @wasm_path = wasm_path || Kobako::Runtime.default_path
101
101
  @options = SandboxOptions.new(**)
102
102
  @handler = Catalog::Handles.new
103
- @services = Kobako::Catalog::Namespaces.new(handler: @handler)
103
+ @services = Kobako::Catalog::Services.new(handler: @handler)
104
104
  @snippets = Catalog::Snippets.new
105
105
  @runtime = build_runtime!
106
106
  install_dispatch_proc!
107
107
  reset_invocation_state!
108
108
  end
109
109
 
110
- # Declare or retrieve the Namespace named +name+ on this Sandbox. +name+
111
- # must be a Symbol or String in constant form. Returns the
112
- # +Kobako::Namespace+.
110
+ # Bind +object+ as the Service reachable at +path+ a Symbol or
111
+ # String of one or more +::+-separated constant-form segments
112
+ # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
113
+ # chaining.
113
114
  #
114
- # Raises +ArgumentError+ when called after the first invocation, or
115
- # when +name+ does not match the constant-name pattern.
116
- def define(name)
117
- @services.define(name)
115
+ # Raises +ArgumentError+ when a segment is malformed, when +path+
116
+ # collides with an existing binding (a name is a bound Service or a
117
+ # grouping prefix, never both), or when called after the first
118
+ # invocation has sealed Service registration.
119
+ def bind(path, object)
120
+ @services.bind(path, object)
121
+ self
118
122
  end
119
123
 
120
124
  # Register a snippet on this Sandbox in one of two forms:
121
125
  #
122
126
  # * +preload(code: source, name: Name)+ — +source+ is mruby source
123
- # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. The +name+
127
+ # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. Compile
128
+ # failures surface as +Kobako::SandboxError+ on the first
129
+ # invocation's replay. The +name+
124
130
  # becomes the snippet's +(snippet:Name)+ backtrace filename and
125
131
  # is the dedupe key that rejects a duplicate +code:+ snippet.
126
132
  # * +preload(binary: bytes)+ — +bytes+ is precompiled RITE
@@ -173,15 +179,15 @@ module Kobako
173
179
  #
174
180
  # Source delivery uses the WASI stdin three-frame protocol
175
181
  # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
176
- # Frame 1 carries the msgpack-encoded preamble (Namespace / Member
177
- # registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
182
+ # Frame 1 carries the msgpack-encoded preamble (Service registry
183
+ # snapshot), Frame 2 carries the user source UTF-8 bytes, and
178
184
  # Frame 3 carries the snippet table registered via +#preload+.
179
185
  # Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
180
186
  # mandatory-presence — an empty snippet table sends an empty msgpack
181
187
  # array, never an absent frame.
182
188
  #
183
189
  # The first invocation seals the Service registry and snippet table;
184
- # subsequent +#define+ / +#preload+ calls raise +ArgumentError+.
190
+ # subsequent +#bind+ / +#preload+ calls raise +ArgumentError+.
185
191
  #
186
192
  # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
187
193
  # +Kobako::SandboxError+ when the guest ran to completion but failed
@@ -216,7 +222,7 @@ module Kobako
216
222
  # runtime that cannot honor the request never runs guest code.
217
223
  def build_runtime!
218
224
  runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
219
- @options.stdout_limit, @options.stderr_limit, profile)
225
+ @options.stdout_limit, @options.stderr_limit, @options.profile)
220
226
  @options.enforce_floor!(runtime.profile)
221
227
  runtime
222
228
  end
@@ -257,10 +263,8 @@ module Kobako
257
263
  # the readout here also covers the trap path, where +Runtime#eval+ /
258
264
  # +#run+ raise instead of returning outcome bytes.
259
265
  #
260
- # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
261
- # whose order matches the +Kobako::Usage+ field order; the
262
- # destructure-then-kwargs handoff below is the explicit
263
- # positional→keyword conversion point.
266
+ # The ext-side contract is positional: +Runtime#usage+ yields
267
+ # +[wall_time, memory_peak]+ in +Kobako::Usage+ field order.
264
268
  def read_usage!
265
269
  wall_time, memory_peak = @runtime.usage
266
270
  @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
@@ -283,16 +287,14 @@ module Kobako
283
287
 
284
288
  # Read the per-last-invocation output captures from the ext and wrap
285
289
  # them as +Kobako::Capture+ value objects. Runs in the +invoke!+
286
- # +ensure+ block next to {#read_usage!} for the same reason: the ext
290
+ # +ensure+ block next to #read_usage! for the same reason: the ext
287
291
  # stashes the captures on every outcome, so the readout also covers
288
292
  # the trap path, where +Runtime#eval+ / +#run+ raise instead of
289
293
  # returning outcome bytes — +#stdout+ / +#stderr+ keep the guest's
290
294
  # partial output readable after a rescue.
291
295
  #
292
- # The ext returns a positional 4-tuple
293
- # +[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]+;
294
- # the destructure-then-kwargs handoff below is the explicit
295
- # positional→keyword conversion point.
296
+ # The ext-side contract is positional: +Runtime#captures+ yields
297
+ # +[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]+.
296
298
  def read_captures!
297
299
  stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated = @runtime.captures
298
300
  @stdout_capture = Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
@@ -306,7 +308,7 @@ module Kobako
306
308
  # The yielded block must return the invocation's raw outcome bytes —
307
309
  # i.e. the value of +Runtime#eval+ / +#run+ — which the success path
308
310
  # feeds to +Outcome.decode+. Captures and usage are populated by the
309
- # +ensure+ readouts ({#read_usage!} / {#read_captures!}) on every
311
+ # +ensure+ readouts (#read_usage! / #read_captures!) on every
310
312
  # outcome, so +#stdout+ / +#stderr+ / +#usage+ stay readable after a
311
313
  # rescued trap.
312
314
  # The rescue chain is the single trap-translation boundary —
@@ -320,7 +322,10 @@ module Kobako
320
322
  # token; restore it to the host object the guest referenced before
321
323
  # handing the value to the Host App. @handler still holds this
322
324
  # invocation's table — reset only happens at the next #begin_invocation!.
323
- Codec::HandleWalk.deep_restore(Outcome.decode(return_bytes), @handler)
325
+ # A Handle-free result resolves to itself, so the restoration walk is
326
+ # skipped when the decode carried none.
327
+ value, carried_handle = Codec.track_handles { Outcome.decode(return_bytes) }
328
+ carried_handle ? Codec::HandleWalk.deep_restore(value, @handler) : value
324
329
  rescue Kobako::TrapError => e
325
330
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
326
331
  ensure
@@ -13,7 +13,7 @@ module Kobako
13
13
  module Transport
14
14
  # Pure-function dispatcher for guest-initiated transport calls.
15
15
  # Decodes a msgpack-encoded Request envelope, resolves the target
16
- # object through the Catalog::Namespaces (path lookup) or
16
+ # object through the Catalog::Services (path lookup) or
17
17
  # Catalog::Handles (Handle lookup), invokes the method, and returns
18
18
  # a msgpack-encoded Response envelope.
19
19
  #
@@ -24,10 +24,10 @@ module Kobako
24
24
  #
25
25
  # Entry point:
26
26
  #
27
- # Kobako::Transport::Dispatcher.dispatch(request_bytes, namespaces, handler, yield_to_guest)
27
+ # Kobako::Transport::Dispatcher.dispatch(request_bytes, services, handler, yield_to_guest)
28
28
  # # => msgpack-encoded Response bytes (never raises)
29
29
  module Dispatcher
30
- # Throw tag for the {Yielder}'s break unwind back to the
30
+ # Throw tag for the Yielder's break unwind back to the
31
31
  # dispatcher's +catch+ frame. +private_constant+ is a
32
32
  # convention boundary — not a defence.
33
33
  BREAK_THROW = :__kobako_break__
@@ -54,12 +54,12 @@ module Kobako
54
54
  # Callable gadget types whose own public methods are reflection surface
55
55
  # (+Proc#binding+ reaches +Binding#eval+, +Method#receiver+ / +#unbind+
56
56
  # hand back the underlying object) rather than Service behaviour. Only
57
- # {CALLABLE_ALLOW} is reachable on a target of these types; a bound
57
+ # CALLABLE_ALLOW is reachable on a target of these types; a bound
58
58
  # lambda stays invocable, its reflective surface does not.
59
59
  GADGET_OWNERS = [Proc, Method, UnboundMethod, Binding].freeze
60
60
  private_constant :GADGET_OWNERS
61
61
 
62
- # The sole methods reachable on a {GADGET_OWNERS} target: invoking it
62
+ # The sole methods reachable on a GADGET_OWNERS target: invoking it
63
63
  # (+call+ / +[]+ / +yield+) and the harmless +arity+ / +lambda?+
64
64
  # describers that aid guest-side debugging.
65
65
  CALLABLE_ALLOW = %i[call [] yield arity lambda?].freeze
@@ -67,7 +67,7 @@ module Kobako
67
67
 
68
68
  # Dispatch a single transport request and return the encoded
69
69
  # Response bytes. Invoked from the +Runtime#on_dispatch+ Proc that
70
- # +Kobako::Sandbox#initialize+ installs on the ext side; +namespaces+,
70
+ # +Kobako::Sandbox#initialize+ installs on the ext side; +services+,
71
71
  # +handler+, and +yield_to_guest+ are captured in that Proc's
72
72
  # closure so the Dispatcher stays stateless and the registry doesn't
73
73
  # need to publish accessors for the Sandbox-owned +Catalog::Handles+
@@ -77,31 +77,38 @@ module Kobako
77
77
  # returns a binary String — every failure path is reified as a
78
78
  # Response.error envelope so the guest sees a transport error rather
79
79
  # than a wasm trap.
80
- def dispatch(request_bytes, namespaces, handler, yield_to_guest)
81
- request = Kobako::Transport::Request.decode(request_bytes)
82
- target = resolve_target(request.target, namespaces, handler)
83
- args, kwargs = resolve_call_args(request, handler)
80
+ #
81
+ # The decode runs inside +Codec.track_handles+ so #resolve_call_args
82
+ # can skip the argument walk when no Capability Handle crossed the
83
+ # wire.
84
+ def dispatch(request_bytes, services, handler, yield_to_guest)
85
+ request, carried_handle = Kobako::Codec.track_handles { Kobako::Transport::Request.decode(request_bytes) }
86
+ target = resolve_target(request.target, services, handler)
87
+ args, kwargs = resolve_call_args(request, handler, carried_handle)
84
88
  yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if request.block_given
85
- value = catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }
86
- encode_ok(value, handler)
89
+ encode_ok(catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }, handler)
87
90
  rescue StandardError => e
88
91
  encode_caught_error(e)
89
92
  ensure
90
93
  yielder&.invalidate!
91
94
  end
92
95
 
93
- # Resolve positional and keyword arguments off +request+ in one
94
- # step. Both pass through {#resolve_arg} so Capability Handles
95
- # round-trip back to the host-side Ruby object before the call
96
- # reaches +public_send+.
97
- def resolve_call_args(request, handler)
96
+ # Resolve positional and keyword arguments off +request+ in one step.
97
+ # +carried_handle+ reports whether the decode carried any Capability
98
+ # Handle; when it did not, every argument resolves to itself, so the
99
+ # decoded values pass straight through and the walk is skipped entirely.
100
+ # Otherwise both go through #resolve_arg so Handles round-trip back to
101
+ # the host-side Ruby object before the call reaches +public_send+.
102
+ def resolve_call_args(request, handler, carried_handle)
103
+ return [request.args, request.kwargs] unless carried_handle
104
+
98
105
  [request.args.map { |v| resolve_arg(v, handler) },
99
106
  request.kwargs.transform_values { |v| resolve_arg(v, handler) }]
100
107
  end
101
108
 
102
109
  # Map an error caught at the dispatch boundary to a +Response.error+
103
110
  # envelope (binary msgpack). +error+ is the +StandardError+ caught by
104
- # {#dispatch}'s rescue; the +type+ field tells the guest which kind
111
+ # #dispatch's rescue; the +type+ field tells the guest which kind
105
112
  # of failure it was so it can raise the matching proxy-side error.
106
113
  def encode_caught_error(error)
107
114
  case error
@@ -119,8 +126,8 @@ module Kobako
119
126
  # reject calls to no-kwarg methods when the wire carries the
120
127
  # uniform empty-map shape.
121
128
  #
122
- # +yielder+ is the host-side {Yielder} materialised when the guest
123
- # call site supplied a block; its {Yielder#to_proc}
129
+ # +yielder+ is the host-side Yielder materialised when the guest
130
+ # call site supplied a block; its Yielder#to_proc
124
131
  # rides the +&block+ slot. +&nil+ is a no-op block argument in Ruby,
125
132
  # so the same call site handles both cases without an explicit
126
133
  # conditional.
@@ -137,8 +144,8 @@ module Kobako
137
144
  end
138
145
 
139
146
  # Guard the +public_send+ below against ambient reflection methods.
140
- # A public method whose owner is a {META_OWNERS} or {GADGET_OWNERS} module is
141
- # rejected, except {CALLABLE_ALLOW} on a gadget target (a bound lambda
147
+ # A public method whose owner is a META_OWNERS or GADGET_OWNERS module is
148
+ # rejected, except CALLABLE_ALLOW on a gadget target (a bound lambda
142
149
  # stays invocable). A name with no concrete public method is allowed
143
150
  # only when the target opts into it via +respond_to?+ (dynamic
144
151
  # +method_missing+ Services), since the dangerous methods are all
@@ -159,7 +166,7 @@ module Kobako
159
166
  # Consult the target's opt-in narrowing predicate. A bound object
160
167
  # may define a private +respond_to_guest?(name)+ to restrict which of its
161
168
  # methods the guest reaches; a falsy answer rejects the dispatch.
162
- # The predicate composes beneath {#reject_meta_method!} — it only narrows,
169
+ # The predicate composes beneath #reject_meta_method! — it only narrows,
163
170
  # never re-opening the reflection surface the floor rejects — and is
164
171
  # consulted with the private surface included so the guest's +public_send+
165
172
  # dispatch can never reach +respond_to_guest?+ itself.
@@ -187,17 +194,17 @@ module Kobako
187
194
  # Target type is already validated by +Transport::Request.decode+
188
195
  # before this method is reached, so no else-branch is needed here —
189
196
  # the wire layer is the system boundary that enforces the invariant.
190
- def resolve_target(target, namespaces, handler)
197
+ def resolve_target(target, services, handler)
191
198
  case target
192
199
  when String
193
- resolve_path(target, namespaces)
200
+ resolve_path(target, services)
194
201
  when Kobako::Handle
195
202
  require_live_object!(target.id, handler)
196
203
  end
197
204
  end
198
205
 
199
- def resolve_path(path, namespaces)
200
- namespaces.lookup(path)
206
+ def resolve_path(path, services)
207
+ services.lookup(path)
201
208
  rescue KeyError => e
202
209
  raise UndefinedTargetError, e.message
203
210
  end
@@ -213,7 +220,7 @@ module Kobako
213
220
  # Encode +value+ as a +Response.ok+ envelope. When the value is not
214
221
  # wire-representable per the codec's type mapping, the
215
222
  # +UnsupportedType+ rescue routes it through the
216
- # Catalog::Handles via {#wrap_as_handle} and re-encodes with the Capability
223
+ # Catalog::Handles via #wrap_as_handle and re-encodes with the Capability
217
224
  # Handle in place. The happy path encodes exactly once.
218
225
  def encode_ok(value, handler)
219
226
  response = Kobako::Transport::Response.ok(value)
@@ -224,15 +231,18 @@ module Kobako
224
231
 
225
232
  # Allocate +value+ in the Sandbox's Catalog::Handles and return a +Handle+
226
233
  # that the wire codec can carry. Used as the fallback path of
227
- # {#encode_ok} when +value+ has no wire representation.
234
+ # #encode_ok when +value+ has no wire representation.
228
235
  def wrap_as_handle(value, handler)
229
236
  handler.alloc(value)
230
237
  end
231
238
 
239
+ # +message+ folds to UTF-8 first: Ruby core builds some exception
240
+ # messages as ASCII-8BIT (the arity ArgumentError, for one), and
241
+ # the codec would ride those as bin — a shape the guest refuses.
232
242
  def encode_error(type, message)
243
+ message = message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
233
244
  fault = Kobako::Fault.new(type: type, message: message)
234
- response = Kobako::Transport::Response.error(fault)
235
- response.encode
245
+ Kobako::Transport::Response.error(fault).encode
236
246
  end
237
247
  end
238
248
  end
@@ -12,8 +12,8 @@ module Kobako
12
12
  #
13
13
  # 5-element msgpack array:
14
14
  # +[target, method_name, args, kwargs, block_given]+. +target+ is
15
- # either a +String+ (+"<Namespace>::<Member>"+, e.g. +"MyService::KV"+)
16
- # or a {Handle}. SPEC pins +kwargs+ map keys to ext 0x00 Symbol;
15
+ # either a +String+ (+"MyService::KV"+, e.g. +"MyService::KV"+)
16
+ # or a Handle. SPEC pins +kwargs+ map keys to ext 0x00 Symbol;
17
17
  # enforced at construction so the Value Object is the single source of
18
18
  # truth. +block_given+ is a Boolean signalling whether the guest call
19
19
  # site supplied a block; the block body itself never crosses the
@@ -43,17 +43,22 @@ module Kobako
43
43
  Codec::Encoder.encode([target, method_name, args, kwargs, block_given])
44
44
  end
45
45
 
46
- # Decode +bytes+ into a {Request}. Raises +Codec::InvalidType+ when the
47
- # envelope is not the expected 5-element msgpack array, or when the
48
- # Value Object's construction invariants reject the decoded fields.
46
+ # Decode +bytes+ into a Request. Raises +Codec::InvalidType+ when the
47
+ # envelope is not the expected 5-element msgpack array, when any
48
+ # position carries an ext 0x02 Fault envelope (a Request is a payload
49
+ # position; the Response fault field is the envelope's only home), or
50
+ # when the Value Object's construction invariants reject the decoded
51
+ # fields.
49
52
  def self.decode(bytes)
50
- Codec::Decoder.decode(bytes) do |arr|
51
- unless arr.is_a?(Array) && arr.length == 5
52
- raise Codec::InvalidType, "Request envelope is malformed (expected a 5-element array)"
53
- end
53
+ Codec.forbid_faults do
54
+ Codec::Decoder.decode(bytes) do |arr|
55
+ unless arr.is_a?(Array) && arr.length == 5
56
+ raise Codec::InvalidType, "Request envelope is malformed (expected a 5-element array)"
57
+ end
54
58
 
55
- target, method_name, args, kwargs, block_given = arr
56
- new(target: target, method_name: method_name, args: args, kwargs: kwargs, block_given: block_given)
59
+ target, method_name, args, kwargs, block_given = arr
60
+ new(target: target, method_name: method_name, args: args, kwargs: kwargs, block_given: block_given)
61
+ end
57
62
  end
58
63
  end
59
64
 
@@ -20,7 +20,7 @@ module Kobako
20
20
  #
21
21
  # 2-element msgpack array: +[status, value-or-fault]+. +status+ is 0
22
22
  # (success) or 1 (fault). For success the second element is the return
23
- # value; for fault it is a {Fault} (ext 0x02 envelope).
23
+ # value; for fault it is a Fault (ext 0x02 envelope).
24
24
  #
25
25
  # Built on the +class X < Data.define(...)+ subclass form so the
26
26
  # class body is fully Steep-visible; see +lib/kobako/outcome/panic.rb+
@@ -53,12 +53,19 @@ module Kobako
53
53
  def error? = status == STATUS_ERROR
54
54
 
55
55
  # Encode this Response to msgpack bytes as the 2-element
56
- # +[status, payload]+ array.
56
+ # +[status, payload]+ array. The ok variant's value is a payload
57
+ # position, so a +Kobako::Fault+ inside it has no wire
58
+ # representation and surfaces as +UnsupportedType+ — the
59
+ # Dispatcher's auto-wrap rescue turns it into a Capability Handle.
60
+ # The error variant carries the Fault envelope in its one legal
61
+ # position and encodes it plainly.
57
62
  def encode
58
- Codec::Encoder.encode([status, payload])
63
+ return Codec::Encoder.encode([status, payload]) if error?
64
+
65
+ Codec.forbid_faults { Codec::Encoder.encode([status, payload]) }
59
66
  end
60
67
 
61
- # Decode +bytes+ into a {Response}. Raises +Codec::InvalidType+ when the
68
+ # Decode +bytes+ into a Response. Raises +Codec::InvalidType+ when the
62
69
  # envelope is not the expected 2-element msgpack array, or when the
63
70
  # Value Object's construction invariants reject the decoded fields.
64
71
  def self.decode(bytes)