kobako 0.14.0 → 0.16.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +67 -0
  4. data/Cargo.lock +3 -3
  5. data/README.md +104 -29
  6. data/ROADMAP.md +2 -3
  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/snapshot.rs +7 -3
  12. data/crates/kobako-wasmtime/CHANGELOG.md +14 -0
  13. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  14. data/crates/kobako-wasmtime/README.md +1 -1
  15. data/crates/kobako-wasmtime/src/capture.rs +17 -13
  16. data/crates/kobako-wasmtime/src/driver.rs +6 -7
  17. data/crates/kobako-wasmtime/src/frames.rs +2 -9
  18. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -1
  19. data/crates/kobako-wasmtime/src/trap.rs +30 -57
  20. data/data/kobako.wasm +0 -0
  21. data/ext/kobako/Cargo.toml +1 -1
  22. data/ext/kobako/src/runtime/errors.rs +9 -39
  23. data/ext/kobako/src/runtime.rs +5 -20
  24. data/lib/kobako/catalog/extensions.rb +114 -0
  25. data/lib/kobako/catalog/handles.rb +1 -1
  26. data/lib/kobako/catalog/services.rb +135 -0
  27. data/lib/kobako/catalog/snippets.rb +1 -1
  28. data/lib/kobako/catalog.rb +7 -5
  29. data/lib/kobako/codec/decoder.rb +3 -3
  30. data/lib/kobako/codec/encoder.rb +4 -4
  31. data/lib/kobako/codec/ext_types.rb +169 -0
  32. data/lib/kobako/codec/state.rb +98 -0
  33. data/lib/kobako/codec/utils.rb +2 -2
  34. data/lib/kobako/codec.rb +23 -6
  35. data/lib/kobako/extension.rb +47 -0
  36. data/lib/kobako/fault.rb +1 -1
  37. data/lib/kobako/handle.rb +1 -1
  38. data/lib/kobako/outcome.rb +13 -7
  39. data/lib/kobako/pool.rb +1 -1
  40. data/lib/kobako/sandbox.rb +47 -27
  41. data/lib/kobako/transport/dispatcher.rb +25 -18
  42. data/lib/kobako/transport/request.rb +14 -9
  43. data/lib/kobako/transport/response.rb +9 -2
  44. data/lib/kobako/transport/yield.rb +4 -1
  45. data/lib/kobako/transport/yielder.rb +14 -6
  46. data/lib/kobako/version.rb +1 -1
  47. data/lib/kobako.rb +1 -0
  48. data/sig/kobako/catalog/extensions.rbs +25 -0
  49. data/sig/kobako/catalog/services.rbs +27 -0
  50. data/sig/kobako/codec/ext_types.rbs +31 -0
  51. data/sig/kobako/codec/state.rbs +20 -0
  52. data/sig/kobako/codec.rbs +3 -0
  53. data/sig/kobako/extension.rbs +20 -0
  54. data/sig/kobako/sandbox.rbs +3 -1
  55. data/sig/kobako/transport/dispatcher.rbs +4 -4
  56. data/sig/kobako/transport/yielder.rbs +1 -1
  57. data/sig/kobako/transport.rbs +2 -2
  58. metadata +11 -7
  59. data/lib/kobako/catalog/namespaces.rb +0 -115
  60. data/lib/kobako/codec/factory.rb +0 -187
  61. data/lib/kobako/namespace.rb +0 -78
  62. data/sig/kobako/catalog/namespaces.rbs +0 -17
  63. data/sig/kobako/codec/factory.rbs +0 -34
  64. data/sig/kobako/namespace.rbs +0 -21
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msgpack"
4
+
5
+ require_relative "error"
6
+ require_relative "utils"
7
+ require_relative "state"
8
+ require_relative "../handle"
9
+ require_relative "../fault"
10
+
11
+ module Kobako
12
+ module Codec
13
+ # The kobako wire ext-type conversions
14
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types)
15
+ # as pure functions: per-operation decode state is threaded in as an
16
+ # argument, so the module itself holds nothing. #build_factory assembles
17
+ # the one +MessagePack::Factory+ these conversions are registered on.
18
+ module ExtTypes
19
+ # MessagePack ext type code reserved for Symbol
20
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
21
+ # → ext 0x00). Module-private — mirrors +codec::EXT_SYMBOL+ on the
22
+ # Rust side.
23
+ EXT_SYMBOL = 0x00
24
+ # MessagePack ext type code reserved for Capability Handle
25
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
26
+ # → ext 0x01). Module-private — mirrors +codec::EXT_HANDLE+ on the
27
+ # Rust side.
28
+ EXT_HANDLE = 0x01
29
+ # MessagePack ext type code reserved for Exception envelope
30
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
31
+ # → ext 0x02). Module-private — mirrors +codec::EXT_ERRENV+ on the
32
+ # Rust side.
33
+ EXT_ERRENV = 0x02
34
+ private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
35
+
36
+ module_function
37
+
38
+ # Assemble a +MessagePack::Factory+ with the three kobako ext types
39
+ # registered, frozen because registration is its only mutation and
40
+ # happens exactly once. The stateful conversions resolve their
41
+ # per-operation state at call time, so one registered factory serves
42
+ # every thread.
43
+ def build_factory
44
+ factory = MessagePack::Factory.new
45
+ register_symbol(factory)
46
+ register_handle(factory)
47
+ register_fault(factory)
48
+ factory.freeze
49
+ end
50
+
51
+ # Symbol-to-name packer for the ext-0x00 registration.
52
+ def pack_symbol(symbol)
53
+ symbol.name
54
+ end
55
+
56
+ # Validate the ext-0x00 payload as UTF-8 and intern. Raises
57
+ # InvalidEncoding on invalid bytes — SPEC forbids the
58
+ # binary-encoding fallback that msgpack-gem's default unpacker
59
+ # would otherwise apply. The re-tag step lives here because the
60
+ # msgpack ext-type unpacker hands us binary bytes; the assertion
61
+ # itself is shared with Decoder via Utils.assert_utf8!. The
62
+ # +"Symbol"+ label keeps the error message in Ruby vocabulary
63
+ # rather than wire-ext-code vocabulary.
64
+ def unpack_symbol(payload)
65
+ name = payload.b.force_encoding(Encoding::UTF_8)
66
+ Utils.assert_utf8!(name, "Symbol payload")
67
+ name.to_sym
68
+ end
69
+
70
+ # Handle-id packer for the ext-0x01 registration: the fixext-4
71
+ # big-endian id frame.
72
+ def pack_handle(handle)
73
+ [handle.id].pack("N")
74
+ end
75
+
76
+ # Peel off the fixext-4 frame, hand the bytes to the
77
+ # Host-Gem-internal +Kobako::Handle.restore+ factory, and
78
+ # translate the +ArgumentError+ raised by Handle's invariants
79
+ # into a wire-layer +InvalidType+ via Codec::Utils.with_boundary.
80
+ # The Value Object owns the id-range contract; this method only
81
+ # owns the frame shape. Records the Handle sighting on +state+ so a
82
+ # Handle-free decode can skip the downstream resolution walk.
83
+ def unpack_handle(payload, state)
84
+ state.record_handle!
85
+ bytes = payload.b
86
+ raise InvalidType, "Handle payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
87
+
88
+ id = bytes.unpack1("N") # : Integer
89
+ Codec::Utils.with_boundary { Kobako::Handle.restore(id) }
90
+ end
91
+
92
+ # Encode the inner ext-0x02 map via Encoder (not the raw factory) so
93
+ # the embedded payload flows through the same boundary as a top-level
94
+ # encode — nested kobako values (Handle, nested Fault) reach the
95
+ # registered ext-type packers. A +details+ chain nested past the
96
+ # +state+ depth cap has no wire representation and surfaces as
97
+ # +UnsupportedType+. In a payload position (+state+ inside a
98
+ # forbid_faults bracket) the envelope has no wire representation at
99
+ # all, so the refusal routes the value into the position's
100
+ # non-representable handling — the Dispatcher's auto-wrap rescue,
101
+ # or a raise at the yield site.
102
+ def pack_fault(fault, state)
103
+ if state.faults_forbidden?
104
+ raise UnsupportedType, "Kobako::Fault has no wire representation in a payload position"
105
+ end
106
+
107
+ state.within_ext_frame(UnsupportedType) do
108
+ Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
109
+ end
110
+ end
111
+
112
+ # Peel the embedded msgpack map and hand it to +Kobako::Fault.new+
113
+ # inside Decoder.decode's block form, so the value-object's
114
+ # +ArgumentError+ invariants surface as +InvalidType+ through the
115
+ # decoder boundary. Inner decode goes through Decoder (not the raw
116
+ # factory) so the embedded +str+ payloads flow through the same
117
+ # UTF-8 validation as a top-level decode. A nested ext 0x02 in
118
+ # +details+ re-enters this method, so the +state+ ext-frame guard
119
+ # bounds the chain depth to keep it from exhausting the native stack.
120
+ # In a payload position (+state+ inside a forbid_faults bracket) the
121
+ # envelope is a wire violation outright — its sole legal position is
122
+ # the Response fault field.
123
+ def unpack_fault(payload, state)
124
+ if state.faults_forbidden?
125
+ raise InvalidType, "Fault envelope (ext 0x02) is not a legal value in a payload position"
126
+ end
127
+
128
+ state.within_ext_frame(InvalidType) do
129
+ Decoder.decode(payload) do |map|
130
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
131
+
132
+ Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
133
+ end
134
+ end
135
+ end
136
+
137
+ def register_symbol(factory)
138
+ factory.register_type(
139
+ EXT_SYMBOL, Symbol,
140
+ packer: ->(symbol) { ExtTypes.pack_symbol(symbol) },
141
+ unpacker: ->(payload) { ExtTypes.unpack_symbol(payload) }
142
+ )
143
+ end
144
+
145
+ def register_handle(factory)
146
+ factory.register_type(
147
+ EXT_HANDLE, Kobako::Handle,
148
+ packer: ->(handle) { ExtTypes.pack_handle(handle) },
149
+ unpacker: ->(payload) { ExtTypes.unpack_handle(payload, State.current) }
150
+ )
151
+ end
152
+
153
+ def register_fault(factory)
154
+ factory.register_type(
155
+ EXT_ERRENV, Kobako::Fault,
156
+ packer: ->(fault) { ExtTypes.pack_fault(fault, State.current) },
157
+ unpacker: ->(payload) { ExtTypes.unpack_fault(payload, State.current) }
158
+ )
159
+ end
160
+ end
161
+
162
+ # The process-wide registered factory: ext registration is paid once at
163
+ # load, and a registered +MessagePack::Factory+ only reads its type
164
+ # registry afterwards, so every thread shares this instance for byte
165
+ # work.
166
+ FACTORY = ExtTypes.build_factory
167
+ private_constant :FACTORY
168
+ end
169
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Codec
5
+ # Codec-internal, per-thread state of the operation in flight: the
6
+ # ext-envelope nesting depth and whether a Capability Handle crossed
7
+ # the current decode. Thread scoping is what makes plain instance
8
+ # variables sound — host codec calls run synchronously on their
9
+ # owning thread, and a nested decode (an ext 0x02 Fault re-entering
10
+ # through its +details+) reuses the same thread instance, so the
11
+ # depth counter accumulates across the re-entry instead of resetting.
12
+ class State
13
+ # An ext 0x02 (Fault) envelope nests through its +details+ field, and
14
+ # each level re-enters the codec with a fresh +MessagePack+ unpacker
15
+ # whose built-in stack guard resets — so ext-envelope depth is tracked
16
+ # here instead. The cap matches the wire's overall nesting bound and
17
+ # keeps a nested chain from exhausting the native stack: an over-deep
18
+ # chain fails as a clean wire error, never a stack-level trap.
19
+ MAX_EXT_DEPTH = 128
20
+ private_constant :MAX_EXT_DEPTH
21
+
22
+ # Thread-local slot holding the calling thread's State.
23
+ STATE_KEY = :__kobako_codec_state__
24
+ private_constant :STATE_KEY
25
+
26
+ # The calling thread's State, built on first use so the mutable
27
+ # state stays isolated to the thread that runs the codec call.
28
+ def self.current
29
+ Thread.current[STATE_KEY] ||= new
30
+ end
31
+ private_class_method :new
32
+
33
+ def initialize
34
+ @ext_depth = 0
35
+ @carried_handle = false
36
+ @faults_forbidden = false
37
+ end
38
+
39
+ # Bracket a decode and return the block's result together with
40
+ # whether the decoded tree carried an ext 0x01 Capability Handle.
41
+ # ExtTypes#unpack_handle is the sole chokepoint every Handle passes
42
+ # through, so one decode pass records the whole tree and a caller
43
+ # can skip an all-identity Handle-resolution walk when none was
44
+ # present.
45
+ def track_handles
46
+ @carried_handle = false
47
+ result = yield
48
+ [result, @carried_handle]
49
+ end
50
+
51
+ # Record that an ext 0x01 Capability Handle crossed the current
52
+ # decode; #track_handles reports it to the bracketing caller.
53
+ def record_handle!
54
+ @carried_handle = true
55
+ end
56
+
57
+ # Bracket a codec operation in a payload position, where an ext 0x02
58
+ # Fault envelope has no legal wire representation: the fault field of
59
+ # an error Response is its only home. The ext-type conversions
60
+ # consult #faults_forbidden? and refuse the envelope in both
61
+ # directions while the bracket is open. Save/restore keeps a nested
62
+ # legal operation on the same thread unaffected.
63
+ def forbid_faults
64
+ previous = @faults_forbidden
65
+ @faults_forbidden = true
66
+ yield
67
+ ensure
68
+ @faults_forbidden = previous
69
+ end
70
+
71
+ # Whether the operation in flight sits inside a #forbid_faults
72
+ # bracket — i.e. in a payload position where ext 0x02 is a wire
73
+ # violation.
74
+ def faults_forbidden?
75
+ @faults_forbidden
76
+ end
77
+
78
+ # Track ext-envelope re-entry depth and refuse a chain past
79
+ # MAX_EXT_DEPTH, raising +over_limit+ so the failure lands in the
80
+ # caller's existing wire-error class. The next depth is checked before
81
+ # it is committed, so an over-deep rejection leaves the counter
82
+ # untouched, and the +ensure+ restores the entry value on the way out.
83
+ def within_ext_frame(over_limit)
84
+ depth = @ext_depth + 1
85
+ raise over_limit, "ext envelope nesting exceeds #{MAX_EXT_DEPTH} levels" if depth > MAX_EXT_DEPTH
86
+
87
+ @ext_depth = depth
88
+ begin
89
+ yield
90
+ ensure
91
+ @ext_depth = depth - 1
92
+ end
93
+ end
94
+ end
95
+
96
+ private_constant :State
97
+ end
98
+ end
@@ -10,7 +10,7 @@ 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
16
  # (#with_boundary) so the public taxonomy stays
@@ -38,7 +38,7 @@ module Kobako
38
38
  #
39
39
  # Reach for this only where a value object is constructed outside a
40
40
  # Decoder.decode block, whose rescue already performs the same
41
- # mapping (worked example: Factory#unpack_handle building
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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Kobako::Extension — a guest idiom paired with an optional host backend,
5
+ # installed on a Sandbox via +Sandbox#install+. It composes the existing
6
+ # +#preload+ (the guest +source+) and +#bind+ (the +backend+) verbs into
7
+ # one setup unit, so guest code sees a native-style constant whose pure
8
+ # methods run in-guest and whose privileged methods dispatch to the
9
+ # backend.
10
+ #
11
+ # The four readers form the contract +#install+ duck-types on:
12
+ #
13
+ # * +name+ — a Symbol matching +/\A[A-Z]\w*\z/+, the preloaded snippet's
14
+ # canonical backtrace name and the +depends_on+ match key. Independent
15
+ # of any bound path.
16
+ # * +source+ — the mruby idiom as a String; always present, since an
17
+ # Extension always carries a guest idiom. A host object with no idiom
18
+ # is bound with +#bind+ directly.
19
+ # * +backend+ — an +Extension::Backend+ or +nil+ for a pure-guest
20
+ # Extension.
21
+ # * +depends_on+ — Symbol names of Extensions that must also be
22
+ # installed, checked for presence at the first invocation.
23
+ #
24
+ # +Kobako::Extension+ is the bundled value type; any object exposing the
25
+ # four readers is equally valid, so a Host App or gem may supply its own.
26
+ class Extension < Data.define(:name, :source, :backend, :depends_on)
27
+ # Kobako::Extension::Backend — the host attachment of an Extension,
28
+ # pairing +path+ (the constant path the backend binds at, single-segment
29
+ # +"File"+ or nested +"MyApp::Store"+, spelling the guest constant the
30
+ # idiom routes to) with +provider+ (the source of the bound object).
31
+ #
32
+ # A +provider+ that is not itself callable is the bound object, resolved
33
+ # once for the Sandbox's life; a callable provider is invoked once per
34
+ # invocation to yield that invocation's object, so a fresh object backs
35
+ # the path every invocation. Callability is the sole discriminator — a
36
+ # fixed backend that is itself callable is supplied through a
37
+ # non-callable wrapper.
38
+ class Backend < Data.define(:path, :provider)
39
+ end
40
+
41
+ # +backend+ and +depends_on+ default to absent so the common
42
+ # pure-idiom and single-backend shapes stay terse.
43
+ def initialize(name:, source:, backend: nil, depends_on: [])
44
+ super
45
+ end
46
+ end
47
+ end
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
@@ -18,7 +18,7 @@ module Kobako
18
18
  # constructor — is removed for the same reason: a legitimate Handle
19
19
  # must not derive a sibling with a caller-chosen id. The Host Gem itself constructs
20
20
  # Handles through +.restore+, which exists at exactly two call
21
- # sites: +Kobako::Codec::Factory#unpack_handle+ (wire decode) and
21
+ # sites: +Kobako::Codec::ExtTypes#unpack_handle+ (wire decode) and
22
22
  # +Kobako::Codec::HandleWalk.deep_wrap+ / +Kobako::Transport::Dispatcher#wrap_as_handle+
23
23
  # (allocator paths). Both live inside +lib/kobako/+ and are not part
24
24
  # of any public surface.
@@ -70,7 +70,9 @@ module Kobako
70
70
  # "Symbol payload must be …" wording, but operators triaging a
71
71
  # corrupted Sandbox runtime still need it.
72
72
  def decode_value(body)
73
- 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) }
74
76
  rescue Kobako::Codec::Error => e
75
77
  raise build_transport_error(
76
78
  "Sandbox produced an invalid result value",
@@ -99,13 +101,17 @@ module Kobako
99
101
  # through the decoder boundary; the message itself is never user-
100
102
  # facing — it lands in +details+ via the rescue chain above.
101
103
  def build_panic_record(body)
102
- Kobako::Codec::Decoder.decode(body) do |map|
103
- 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)
104
109
 
105
- Panic.new(
106
- origin: map["origin"], klass: map["class"], message: map["message"],
107
- backtrace: map["backtrace"] || [], details: map["details"]
108
- )
110
+ Panic.new(
111
+ origin: map["origin"], klass: map["class"], message: map["message"],
112
+ backtrace: map["backtrace"] || [], details: map["details"]
113
+ )
114
+ end
109
115
  end
110
116
  end
111
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
@@ -48,29 +48,21 @@ module Kobako
48
48
  # so use +#stdout_truncated?+ to observe overflow. Populated on every
49
49
  # outcome — including a rescued +TrapError+, after which it holds the
50
50
  # bytes written before the trap fired — mirroring +#usage+.
51
- def stdout
52
- @stdout_capture.bytes
53
- end
51
+ def stdout = @stdout_capture.bytes
54
52
 
55
53
  # Returns the bytes the guest wrote to stderr during the most recent
56
54
  # invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
57
55
  # any invocation. Mirror of +#stdout+.
58
- def stderr
59
- @stderr_capture.bytes
60
- end
56
+ def stderr = @stderr_capture.bytes
61
57
 
62
58
  # Returns +true+ iff stdout capture during the most recent invocation
63
59
  # exceeded +stdout_limit+. Resets to +false+ at the start of the next
64
60
  # invocation.
65
- def stdout_truncated?
66
- @stdout_capture.truncated?
67
- end
61
+ def stdout_truncated? = @stdout_capture.truncated?
68
62
 
69
63
  # Returns +true+ iff stderr capture during the most recent invocation
70
64
  # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
71
- def stderr_truncated?
72
- @stderr_capture.truncated?
73
- end
65
+ def stderr_truncated? = @stderr_capture.truncated?
74
66
 
75
67
  # Returns the +Kobako::Usage+ value object for the most recent
76
68
  # invocation. Carries +wall_time+ (Float seconds the guest export call spent
@@ -100,21 +92,42 @@ module Kobako
100
92
  @wasm_path = wasm_path || Kobako::Runtime.default_path
101
93
  @options = SandboxOptions.new(**)
102
94
  @handler = Catalog::Handles.new
103
- @services = Kobako::Catalog::Namespaces.new(handler: @handler)
95
+ @services = Kobako::Catalog::Services.new(handler: @handler)
104
96
  @snippets = Catalog::Snippets.new
97
+ @extensions = Catalog::Extensions.new
105
98
  @runtime = build_runtime!
106
99
  install_dispatch_proc!
107
100
  reset_invocation_state!
108
101
  end
109
102
 
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+.
103
+ # Bind +object+ as the Service reachable at +path+ a Symbol or
104
+ # String of one or more +::+-separated constant-form segments
105
+ # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
106
+ # chaining.
107
+ #
108
+ # Raises +ArgumentError+ when a segment is malformed, when +path+
109
+ # collides with an existing binding (a name is a bound Service or a
110
+ # grouping prefix, never both), or when called after the first
111
+ # invocation has sealed Service registration.
112
+ def bind(path, object)
113
+ @services.bind(path, object)
114
+ self
115
+ end
116
+
117
+ # Install one or more Extensions — each a guest idiom (+source+) paired
118
+ # with an optional host +backend+, composed onto the Sandbox through
119
+ # +#preload+ and +#bind+. An Extension is any object exposing
120
+ # +name+ / +source+ / +backend+ / +depends_on+; +Kobako::Extension+ is
121
+ # the bundled value type. Returns +self+.
113
122
  #
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)
123
+ # Raises +ArgumentError+ for a malformed Extension, a call after the
124
+ # first invocation seals registration, or at that first invocation —
125
+ # an unmet +depends_on+.
126
+ def install(*extensions)
127
+ raise ArgumentError, "cannot install after first Sandbox invocation" if @services.sealed?
128
+
129
+ extensions.each { |extension| @extensions.install(extension, snippets: @snippets, services: @services) }
130
+ self
118
131
  end
119
132
 
120
133
  # Register a snippet on this Sandbox in one of two forms:
@@ -175,15 +188,15 @@ module Kobako
175
188
  #
176
189
  # Source delivery uses the WASI stdin three-frame protocol
177
190
  # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
178
- # Frame 1 carries the msgpack-encoded preamble (Namespace / Member
179
- # registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
191
+ # Frame 1 carries the msgpack-encoded preamble (Service registry
192
+ # snapshot), Frame 2 carries the user source UTF-8 bytes, and
180
193
  # Frame 3 carries the snippet table registered via +#preload+.
181
194
  # Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
182
195
  # mandatory-presence — an empty snippet table sends an empty msgpack
183
196
  # array, never an absent frame.
184
197
  #
185
198
  # The first invocation seals the Service registry and snippet table;
186
- # subsequent +#define+ / +#preload+ calls raise +ArgumentError+.
199
+ # subsequent +#bind+ / +#preload+ calls raise +ArgumentError+.
187
200
  #
188
201
  # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
189
202
  # +Kobako::SandboxError+ when the guest ran to completion but failed
@@ -237,14 +250,18 @@ module Kobako
237
250
  end
238
251
  end
239
252
 
240
- # Per-invocation prologue. Seals the Service / snippet registries on
241
- # first call (idempotent) and zeros the per-invocation capability
253
+ # Per-invocation prologue. Seals the Service / snippet / Extension
254
+ # registries on first call (idempotent asserting Extension
255
+ # dependencies then), refreshes each callable Extension backend to this
256
+ # invocation's fresh object, and zeros the per-invocation capability
242
257
  # state — capture buffers, truncation predicates, and the
243
258
  # +Catalog::Handles+ counter — before the guest runs. The
244
259
  # +Catalog::Handles+ itself is held as +@handler+ and never exposed
245
260
  # beyond this class — it is not part of the Host App's surface.
246
261
  def begin_invocation!
247
262
  @services.seal!
263
+ @extensions.seal!
264
+ @extensions.refresh_backends!(@services)
248
265
  @handler.reset!
249
266
  reset_invocation_state!
250
267
  end
@@ -318,7 +335,10 @@ module Kobako
318
335
  # token; restore it to the host object the guest referenced before
319
336
  # handing the value to the Host App. @handler still holds this
320
337
  # invocation's table — reset only happens at the next #begin_invocation!.
321
- Codec::HandleWalk.deep_restore(Outcome.decode(return_bytes), @handler)
338
+ # A Handle-free result resolves to itself, so the restoration walk is
339
+ # skipped when the decode carried none.
340
+ value, carried_handle = Codec.track_handles { Outcome.decode(return_bytes) }
341
+ carried_handle ? Codec::HandleWalk.deep_restore(value, @handler) : value
322
342
  rescue Kobako::TrapError => e
323
343
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
324
344
  ensure