kobako 0.2.1 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +205 -59
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +15 -7
  7. data/ext/kobako/src/wasm/dispatch.rs +88 -36
  8. data/ext/kobako/src/wasm/host_state.rs +298 -55
  9. data/ext/kobako/src/wasm/instance.rs +477 -160
  10. data/ext/kobako/src/wasm.rs +20 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -4
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +24 -17
  16. data/lib/kobako/codec/utils.rb +105 -12
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/handle.rb +62 -0
  20. data/lib/kobako/handle_table.rb +119 -0
  21. data/lib/kobako/invocation.rb +143 -0
  22. data/lib/kobako/outcome/panic.rb +2 -2
  23. data/lib/kobako/outcome.rb +61 -24
  24. data/lib/kobako/rpc/dispatcher.rb +30 -28
  25. data/lib/kobako/rpc/envelope.rb +10 -10
  26. data/lib/kobako/rpc/fault.rb +4 -3
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +23 -33
  29. data/lib/kobako/rpc/wire_error.rb +23 -0
  30. data/lib/kobako/sandbox.rb +211 -136
  31. data/lib/kobako/sandbox_options.rb +73 -0
  32. data/lib/kobako/snippet/binary.rb +30 -0
  33. data/lib/kobako/snippet/source.rb +28 -0
  34. data/lib/kobako/snippet/table.rb +174 -0
  35. data/lib/kobako/snippet.rb +20 -0
  36. data/lib/kobako/usage.rb +41 -0
  37. data/lib/kobako/version.rb +1 -1
  38. data/lib/kobako.rb +1 -0
  39. data/sig/kobako/codec/factory.rbs +1 -1
  40. data/sig/kobako/codec/utils.rbs +10 -0
  41. data/sig/kobako/errors.rbs +3 -0
  42. data/sig/kobako/handle.rbs +19 -0
  43. data/sig/kobako/handle_table.rbs +23 -0
  44. data/sig/kobako/invocation.rbs +25 -0
  45. data/sig/kobako/outcome.rbs +1 -1
  46. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  47. data/sig/kobako/rpc/envelope.rbs +3 -3
  48. data/sig/kobako/rpc/server.rbs +1 -7
  49. data/sig/kobako/rpc/wire_error.rbs +6 -0
  50. data/sig/kobako/sandbox.rbs +22 -17
  51. data/sig/kobako/sandbox_options.rbs +32 -0
  52. data/sig/kobako/snippet/binary.rbs +12 -0
  53. data/sig/kobako/snippet/source.rbs +13 -0
  54. data/sig/kobako/snippet/table.rbs +36 -0
  55. data/sig/kobako/snippet.rbs +4 -0
  56. data/sig/kobako/usage.rbs +11 -0
  57. data/sig/kobako/wasm.rbs +5 -1
  58. metadata +21 -5
  59. data/lib/kobako/rpc/handle.rb +0 -38
  60. data/lib/kobako/rpc/handle_table.rb +0 -107
  61. data/sig/kobako/rpc/handle.rbs +0 -19
  62. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "handle"
4
+
5
+ module Kobako
6
+ # Host-side mapping from opaque integer Handle IDs to Ruby objects.
7
+ # The table is owned by +Kobako::Sandbox+ ({docs/behavior.md B-19}[link:../../docs/behavior.md])
8
+ # and injected into the per-Sandbox +Kobako::RPC::Server+ so guest→host
9
+ # RPC dispatch resolves Handle targets and arguments against the same
10
+ # table that host→guest wire encoding allocates into
11
+ # ({docs/behavior.md B-14, B-34}[link:../../docs/behavior.md]).
12
+ #
13
+ # Lifecycle invariants ({docs/behavior.md}[link:../../docs/behavior.md]):
14
+ #
15
+ # - {docs/behavior.md B-15}[link:../../docs/behavior.md] — Handle IDs are
16
+ # allocated by a monotonically increasing counter scoped to a single
17
+ # invocation. The first ID issued in an invocation is 1; ID 0 is reserved
18
+ # as the invalid sentinel and is never returned by +#alloc+.
19
+ #
20
+ # - {docs/behavior.md B-19}[link:../../docs/behavior.md] — At every
21
+ # invocation boundary (via +#reset!+), every Handle issued under the
22
+ # old state becomes invalid. Reset applies uniformly regardless of
23
+ # allocation source (B-14 Service return or B-34 host-injected
24
+ # argument).
25
+ #
26
+ # - {docs/behavior.md B-21}[link:../../docs/behavior.md] — The cap is
27
+ # +0x7fff_ffff+ (2³¹ − 1). Allocation beyond the cap raises immediately
28
+ # — no silent truncation, no wrap, no ID reuse.
29
+ class HandleTable
30
+ # Build a fresh, empty HandleTable. +next_id+ is an internal seam that
31
+ # sets the starting value of the monotonic counter (defaults to 1 per
32
+ # B-15); tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
33
+ # the cap-exhaustion path without 2³¹ allocations.
34
+ def initialize(next_id: 1)
35
+ @entries = {} # : Hash[Integer, untyped]
36
+ @next_id = next_id
37
+ end
38
+
39
+ # Bind +object+ in the table and return a +Kobako::Handle+ token
40
+ # for it. +object+ is any host-side Ruby object to bind. Returns a
41
+ # freshly-allocated +Kobako::Handle+ whose +#id+ falls in
42
+ # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
43
+ # +Kobako::HandleTableExhausted+ if the next ID would exceed the
44
+ # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
45
+ # and the allocator share the same invariant
46
+ # ({docs/behavior.md B-21}[link:../../docs/behavior.md]).
47
+ #
48
+ # Returning a Handle (rather than a bare Integer id) keeps the
49
+ # allocator's output a domain entity; +Kobako::Handle.from_wire+
50
+ # is reserved for the codec's wire-decode path, where the id is
51
+ # the only thing the bytes carry.
52
+ def alloc(object)
53
+ id = @next_id
54
+ cap = Kobako::Handle::MAX_ID
55
+ if id > cap
56
+ raise HandleTableExhausted,
57
+ "Handle id space exhausted: allocation would assign id #{id}, exceeding the cap (#{cap})"
58
+ end
59
+
60
+ @entries[id] = object
61
+ @next_id = id + 1
62
+ Kobako::Handle.from_wire(id)
63
+ end
64
+
65
+ # Resolve a Handle ID to its bound object. +id+ is a Handle ID previously
66
+ # returned by +#alloc+. Returns the bound object. Raises
67
+ # +Kobako::HandleTableError+ if +id+ is not currently bound.
68
+ def fetch(id)
69
+ require_bound!(id)
70
+ @entries[id]
71
+ end
72
+
73
+ # Remove and return the binding for +id+. +id+ is the Handle ID to
74
+ # release. Returns the previously-bound object. Raises
75
+ # +Kobako::HandleTableError+ if +id+ is not currently bound.
76
+ def release(id)
77
+ require_bound!(id)
78
+ @entries.delete(id)
79
+ end
80
+
81
+ # Clear all entries AND reset the counter to 1. Called at the per-invocation
82
+ # boundary by +Kobako::Sandbox+ — see
83
+ # {docs/behavior.md B-19}[link:../../docs/behavior.md]. Returns +self+.
84
+ def reset!
85
+ @entries.clear
86
+ @next_id = 1
87
+ self
88
+ end
89
+
90
+ # Mark the entry at +id+ as disconnected (ABA protection). +id+ is the
91
+ # Handle ID to poison; silently ignored if +id+ is not currently bound.
92
+ # Returns +self+ for chainability, matching the convention of +#reset!+.
93
+ def mark_disconnected(id)
94
+ @entries[id] = :disconnected if @entries.key?(id)
95
+ self
96
+ end
97
+
98
+ # Returns the number of currently-bound entries.
99
+ def size
100
+ @entries.size
101
+ end
102
+
103
+ # Returns +true+ when +id+ is currently bound, +false+ otherwise.
104
+ def include?(id)
105
+ @entries.key?(id)
106
+ end
107
+
108
+ private
109
+
110
+ # Single source of truth for the "unknown Handle id" raise shared by
111
+ # {#fetch} and {#release}. Returns +nil+ on success; raises
112
+ # +Kobako::HandleTableError+ when +id+ is not currently bound.
113
+ def require_bound!(id)
114
+ return if @entries.key?(id)
115
+
116
+ raise HandleTableError, "unknown Handle id: #{id.inspect}"
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "handle"
4
+ require_relative "codec"
5
+
6
+ module Kobako
7
+ # Host-side value object for a single +Sandbox#run+ invocation
8
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md];
9
+ # {docs/behavior.md B-31}[link:../../docs/behavior.md]).
10
+ #
11
+ # An Invocation captures the host-layer concept of "a single +#run+
12
+ # call": the entrypoint constant name plus its positional and keyword
13
+ # arguments. Host pre-flight (E-24 / E-25 / E-29 / E-30) is enforced at
14
+ # construction so the Value Object is the single source of truth —
15
+ # anything that passes +Invocation.new+ is safe to encode and ship to
16
+ # the guest.
17
+ #
18
+ # Invocation sits at top level, not under +Kobako::RPC+: RPC in SPEC
19
+ # is the guest→host capability channel (Server / Client / Request /
20
+ # Response); Invocation is the opposite direction (host→guest
21
+ # entrypoint dispatch). +#encode+ takes the Sandbox's HandleTable
22
+ # and routes any non-wire-representable +args+ / +kwargs+ leaf
23
+ # through it as a +Kobako::Handle+
24
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]) — the
25
+ # symmetric counterpart of the guest→host wrap path in
26
+ # +Kobako::RPC::Dispatcher#wrap_as_handle+ (B-14). A
27
+ # +Kobako::Handle+ that arrives **already constructed** in the
28
+ # caller's +args+ / +kwargs+ is rejected at construction (E-29):
29
+ # legitimate Handles only enter Host App code through error fields,
30
+ # so a Handle reaching the call site is by definition smuggled in.
31
+ # The +#encode+ output is the "Invocation envelope" that ships
32
+ # through the +__kobako_run+ command buffer.
33
+ #
34
+ # Built on the +class X < Data.define(...)+ subclass form (the
35
+ # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
36
+ class Invocation < Data.define(:entrypoint, :args, :kwargs)
37
+ # Ruby constant-name pattern enforced on the +entrypoint+ Symbol
38
+ # ({docs/behavior.md E-25}[link:../../docs/behavior.md]). Parallel to
39
+ # +Kobako::Snippet::Table::NAME_PATTERN+; the two constants name the
40
+ # same regex but cover distinct surfaces (snippet identity vs.
41
+ # entrypoint resolution) so a future divergence stays local.
42
+ NAME_PATTERN = /\A[A-Z]\w*\z/
43
+
44
+ # steep:ignore:start
45
+ def initialize(entrypoint:, args: [], kwargs: {})
46
+ super(
47
+ entrypoint: normalize_entrypoint(entrypoint),
48
+ args: validate_args!(args),
49
+ kwargs: validate_kwargs!(kwargs)
50
+ )
51
+ end
52
+ # steep:ignore:end
53
+
54
+ # Encode this Invocation to the msgpack bytes the guest's
55
+ # +__kobako_run+ entry point consumes as its command-buffer payload
56
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]).
57
+ # Walks +args+ / +kwargs+ through {Codec::Utils.deep_wrap} so any
58
+ # non-wire-representable leaf is allocated into +handle_table+ and
59
+ # replaced with a +Kobako::Handle+
60
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]); the
61
+ # +handle_table+ argument is the Sandbox's table, sharing the same
62
+ # allocator the guest→host return path (B-14) uses.
63
+ #
64
+ # Layout: msgpack map with string keys +"entrypoint"+ (Symbol via
65
+ # ext 0x00), +"args"+ (Array), +"kwargs"+ (Map with Symbol keys);
66
+ # any wrapped leaf rides as ext 0x01 in its original position
67
+ # (docs/wire-codec.md § ext 0x01 position rules).
68
+ def encode(handle_table)
69
+ Codec::Encoder.encode(
70
+ "entrypoint" => entrypoint,
71
+ "args" => Codec::Utils.deep_wrap(args, handle_table),
72
+ "kwargs" => Codec::Utils.deep_wrap(kwargs, handle_table)
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ # steep:ignore:start
79
+ # E-24: target must be a Symbol or String (TypeError, not
80
+ # ArgumentError — the wrong-type case is a Host App programming
81
+ # error before the invocation reaches the guest). E-25: after
82
+ # +.to_s+ the value must match NAME_PATTERN (ArgumentError),
83
+ # rejecting +::+-segmented names and any non-constant form.
84
+ def normalize_entrypoint(target)
85
+ unless target.is_a?(Symbol) || target.is_a?(String)
86
+ raise TypeError, "Invocation entrypoint must be a Symbol or String, got #{target.class}"
87
+ end
88
+
89
+ target_str = target.to_s
90
+ unless NAME_PATTERN.match?(target_str)
91
+ raise ArgumentError,
92
+ "Invocation entrypoint must match #{NAME_PATTERN.inspect} (got #{target.inspect})"
93
+ end
94
+
95
+ target_str.to_sym
96
+ end
97
+
98
+ # E-29: +args+ must not contain a +Kobako::Handle+. The Handle
99
+ # allocator lives inside the Host Gem; legitimate paths surface
100
+ # Handle objects only through raised error fields, so a Handle
101
+ # reaching +args+ is a forged or smuggled token. Non-wire-
102
+ # representable arguments that are not Handles are handled by
103
+ # auto-wrap inside +#encode+ (B-34) — the reject path is reserved
104
+ # for Handle objects specifically.
105
+ def validate_args!(args)
106
+ raise ArgumentError, "Invocation args must be Array" unless args.is_a?(Array)
107
+ raise ArgumentError, forged_handle_message("args") if args.any?(Kobako::Handle)
108
+
109
+ args
110
+ end
111
+
112
+ # E-30 covers the non-Symbol kwargs-key case; E-29 also rejects a
113
+ # +Kobako::Handle+ arriving as a kwargs value (same forged-token
114
+ # principle as the +args+ branch). Both checks live here so the
115
+ # Host App sees the host-side error message before any encode /
116
+ # decode boundary.
117
+ def validate_kwargs!(kwargs)
118
+ raise ArgumentError, "Invocation kwargs must be Hash" unless kwargs.is_a?(Hash)
119
+
120
+ bad_keys = kwargs.each_key.grep_v(Symbol)
121
+ unless bad_keys.empty?
122
+ raise ArgumentError,
123
+ "Invocation kwargs keys must be Symbols (got #{bad_keys.inspect})"
124
+ end
125
+ raise ArgumentError, forged_handle_message("kwargs values") if kwargs.each_value.any?(Kobako::Handle)
126
+
127
+ kwargs
128
+ end
129
+
130
+ # Single source of truth for the E-29 reject message so the args
131
+ # and kwargs branches stay phrased identically. Message stays in
132
+ # caller vocabulary: it names the affected slot and the reason
133
+ # without leaking SPEC anchor identifiers (B-xx / E-xx live in
134
+ # source comments, not user-visible errors) or self-referential
135
+ # architecture terms — the error is raised BY kobako, so saying
136
+ # "allocated by the Host Gem" reads as third-person about self.
137
+ def forged_handle_message(slot)
138
+ "Invocation #{slot} must not contain a Kobako::Handle — " \
139
+ "Kobako::Handle instances are internal wire tokens, not caller-constructible"
140
+ end
141
+ # steep:ignore:end
142
+ end
143
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Kobako
4
4
  module Outcome
5
- # SPEC.md Outcome Envelope → Panic envelope ({SPEC.md Outcome
6
- # Envelope}[link:../../../SPEC.md]). Wire-shaped failure record
5
+ # Wire-contract Outcome Envelope → Panic envelope ({docs/wire-contract.md
6
+ # Outcome Envelope}[link:../../../docs/wire-contract.md]). Wire-shaped failure record
7
7
  # carried in the OUTCOME_BUFFER when the guest run terminates with
8
8
  # an uncaught top-level exception.
9
9
  #
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outcome/panic"
4
+ require_relative "rpc/wire_error"
4
5
 
5
6
  module Kobako
6
7
  # Host-facing boundary for the OUTCOME_BUFFER produced by
7
- # +__kobako_run+. Takes raw outcome bytes — a one-byte tag followed by
8
+ # +__kobako_eval+. Takes raw outcome bytes — a one-byte tag followed by
8
9
  # the msgpack-encoded body — and maps them to either the unwrapped
9
10
  # mruby return value or a raised three-layer
10
- # ({SPEC.md "Error Scenarios"}[link:../../SPEC.md]) exception.
11
+ # ({docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]) exception.
11
12
  #
12
13
  # Self-contained: this module owns the wire framing (tag bytes,
13
14
  # body decoding), and the +Panic+ wire record lives at
@@ -24,7 +25,7 @@ module Kobako
24
25
  module Outcome
25
26
  # First byte of the OUTCOME_BUFFER for the success branch — body is
26
27
  # the bare msgpack encoding of the returned value
27
- # ({SPEC.md Outcome Envelope}[link:../../SPEC.md]).
28
+ # ({docs/wire-contract.md Outcome Envelope}[link:../../docs/wire-contract.md]).
28
29
  TYPE_VALUE = 0x01
29
30
  # First byte of the OUTCOME_BUFFER for the failure branch — body is
30
31
  # the msgpack Panic map.
@@ -45,12 +46,20 @@ module Kobako
45
46
  end
46
47
 
47
48
  # TrapError for unknown or absent tag
48
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]: len=0 and unknown-tag
49
- # both walk the trap path).
49
+ # ({docs/wire-codec.md ABI Signatures}[link:../../docs/wire-codec.md]:
50
+ # zero-length output and unrecognised first byte both walk the trap
51
+ # path). The user-facing message stays in caller vocabulary — the
52
+ # raw tag byte (or absence) belongs in +details+ for operators, not
53
+ # in the message a caller sees.
50
54
  def build_trap_error(tag)
51
- return TrapError.new("guest exited without writing an outcome (len=0)") if tag.nil?
52
-
53
- TrapError.new(format("unknown outcome tag 0x%<tag>02x", tag: tag))
55
+ if tag.nil?
56
+ TrapError.new("Sandbox exited without producing a result")
57
+ else
58
+ TrapError.new(
59
+ "Sandbox produced an unrecognised result; the runtime is corrupted, " \
60
+ "discard this Sandbox before another invocation"
61
+ )
62
+ end
54
63
  end
55
64
 
56
65
  def split_tag(bytes)
@@ -63,11 +72,18 @@ module Kobako
63
72
  end
64
73
 
65
74
  # Decode failure on the success tag is a SandboxError (E-09): the
66
- # framing was fine, but the carried value is unrepresentable.
75
+ # framing was fine, but the carried value is unrepresentable. The
76
+ # specific codec fault is stashed in +details[:wire_error]+ rather
77
+ # than spliced into the message — callers cannot act on the inner
78
+ # "Symbol payload must be …" wording, but operators triaging a
79
+ # corrupted Sandbox runtime still need it.
67
80
  def decode_value(body)
68
81
  Kobako::Codec::Decoder.decode(body)
69
82
  rescue Kobako::Codec::Error => e
70
- raise build_wire_violation_error("result envelope decode failed: #{e.message}")
83
+ raise build_wire_violation_error(
84
+ "Sandbox produced an invalid result value",
85
+ wire_error: e.message
86
+ )
71
87
  end
72
88
 
73
89
  # Decode failure on the panic tag is a SandboxError (E-08). Either
@@ -77,16 +93,21 @@ module Kobako
77
93
  def decode_panic(body)
78
94
  raise build_panic_error(parse_panic(body))
79
95
  rescue Kobako::Codec::Error => e
80
- raise build_wire_violation_error("panic envelope decode failed: #{e.message}")
96
+ raise build_wire_violation_error(
97
+ "Sandbox produced an invalid panic record",
98
+ wire_error: e.message
99
+ )
81
100
  end
82
101
 
83
102
  # Build a +Panic+ value object from the msgpack-decoded body. Raises
84
103
  # +Kobako::Codec::InvalidType+ when the body is not a map or
85
104
  # when required keys are missing — both routed by +decode_panic+ to
86
- # +build_wire_violation_error+.
105
+ # +build_wire_violation_error+. The +InvalidType+ message itself is
106
+ # never user-facing; it lands in +details[:wire_error]+ via the
107
+ # rescue chain above.
87
108
  def parse_panic(body)
88
109
  map = Kobako::Codec::Decoder.decode(body)
89
- raise Kobako::Codec::InvalidType, "Panic envelope must be a map, got #{map.class}" unless map.is_a?(Hash)
110
+ raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
90
111
 
91
112
  Kobako::Codec::Utils.wire_boundary do
92
113
  Panic.new(
@@ -100,7 +121,7 @@ module Kobako
100
121
  # Ruby exception. +origin == "service"+ → ServiceError (with the
101
122
  # +::Disconnected+ subclass selected when the panic carries the
102
123
  # disconnected sentinel —
103
- # {SPEC "Error Classes"}[link:../../SPEC.md]); everything else
124
+ # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]); everything else
104
125
  # → SandboxError.
105
126
  def build_panic_error(panic)
106
127
  panic_target_class(panic).new(
@@ -112,21 +133,37 @@ module Kobako
112
133
  )
113
134
  end
114
135
 
115
- # {SPEC "Error Classes"}[link:../../SPEC.md]: when
116
- # +origin="service"+ and the panic +class+ field names
117
- # +ServiceError::Disconnected+, surface that subclass so callers can
118
- # rescue the disconnected path specifically (E-14).
136
+ # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]: map
137
+ # the panic +class+ field to the matching Ruby exception subclass so
138
+ # callers can rescue specific failure paths. +origin="service"+ plus
139
+ # +class="Kobako::ServiceError::Disconnected"+ selects the
140
+ # +Disconnected+ subclass (E-14); +origin="sandbox"+ plus
141
+ # +class="Kobako::BytecodeError"+ selects the +BytecodeError+
142
+ # subclass (E-37 / E-38). Everything else falls back to the base
143
+ # class for the origin.
119
144
  def panic_target_class(panic)
120
- return SandboxError unless panic.origin == Panic::ORIGIN_SERVICE
121
-
122
- panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
145
+ case panic.origin
146
+ when Panic::ORIGIN_SERVICE
147
+ panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
148
+ else
149
+ panic.klass == "Kobako::BytecodeError" ? BytecodeError : SandboxError
150
+ end
123
151
  end
124
152
 
125
- def build_wire_violation_error(message)
126
- SandboxError.new(
153
+ # Lift the wire-violation fallback to the real
154
+ # +Kobako::RPC::WireError+ class so callers can +rescue+ it
155
+ # specifically instead of pattern-matching on +error.klass+. The
156
+ # +klass+ field is still populated so existing operator-side
157
+ # tooling that greps on the string continues to work.
158
+ # +wire_error+ carries the inner codec / framing message for
159
+ # operator diagnosis without polluting the user-facing
160
+ # +#message+.
161
+ def build_wire_violation_error(message, wire_error: nil)
162
+ Kobako::RPC::WireError.new(
127
163
  message,
128
164
  origin: Panic::ORIGIN_SANDBOX,
129
- klass: "Kobako::RPC::WireError"
165
+ klass: "Kobako::RPC::WireError",
166
+ details: wire_error.nil? ? nil : { wire_error: wire_error }
130
167
  )
131
168
  end
132
169
  end
@@ -10,7 +10,7 @@ module Kobako
10
10
  # The module is stateless — all mutable state is threaded through the
11
11
  # +server+ argument so Dispatcher has no instance variables and no side
12
12
  # effects beyond mutating the HandleTable via +alloc+ when a non-wire-
13
- # representable return value must be wrapped ({SPEC.md B-14}[link:../../../SPEC.md]).
13
+ # representable return value must be wrapped ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
14
14
  #
15
15
  # Entry point:
16
16
  #
@@ -22,34 +22,35 @@ module Kobako
22
22
  # Internal sentinel raised when target resolution fails. Mapped to
23
23
  # Response.error with type="undefined". Contained at the wire boundary —
24
24
  # not part of the public Kobako error taxonomy
25
- # ({SPEC.md E-xx}[link:../../../SPEC.md]).
25
+ # ({docs/behavior.md E-xx}[link:../../../docs/behavior.md]).
26
26
  class UndefinedTargetError < StandardError; end
27
27
 
28
28
  # Internal sentinel raised when a Handle target resolves to the
29
29
  # +:disconnected+ sentinel in the HandleTable (ABA protection,
30
- # {SPEC.md E-14}[link:../../../SPEC.md]). Mapped to Response.error with
30
+ # {docs/behavior.md E-14}[link:../../../docs/behavior.md]). Mapped to Response.error with
31
31
  # type="disconnected". Contained at the wire boundary.
32
32
  class DisconnectedTargetError < StandardError; end
33
33
 
34
34
  # Dispatch a single RPC request and return the encoded response bytes.
35
35
  # Called by +Kobako::RPC::Server#dispatch+ which is invoked from the
36
36
  # Rust ext inside +__kobako_dispatch+. +request_bytes+ is the
37
- # msgpack-encoded Request envelope. +server+ is the live Server for
38
- # this run, used to resolve path-based targets via +#lookup+ and to
39
- # access the +#handle_table+ for Handle-based targets and return-value
40
- # wrapping. Always returns a binary Stringnever raises. Any failure
41
- # during decode, lookup, or method invocation is reified as a
42
- # Response.error envelope so the guest sees the failure as a normal RPC
43
- # error rather than a wasm trap
44
- # ({SPEC.md B-12}[link:../../../SPEC.md]).
45
- def dispatch(request_bytes, server)
37
+ # msgpack-encoded Request envelope. +server+ resolves path-based
38
+ # Member targets via +#lookup+. +handle_table+ is the Sandbox's
39
+ # HandleTable, injected separately so Dispatcher does not depend
40
+ # on Server publishing a Handle accessorHandle is a
41
+ # Sandbox-level domain entity (B-19) and the dispatcher is its
42
+ # only consumer here. Always returns a binary String never
43
+ # raises. Any failure during decode, lookup, or method invocation
44
+ # is reified as a Response.error envelope so the guest sees the
45
+ # failure as a normal RPC error rather than a wasm trap
46
+ # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
47
+ def dispatch(request_bytes, server, handle_table)
46
48
  request = Kobako::RPC.decode_request(request_bytes)
47
- handle_table = server.handle_table
48
49
  target = resolve_target(request.target, server, handle_table)
49
50
  args = request.args.map { |v| resolve_arg(v, handle_table) }
50
51
  kwargs = request.kwargs.transform_values { |v| resolve_arg(v, handle_table) }
51
52
  value = invoke(target, request.method_name, args, kwargs)
52
- encode_ok(value, server)
53
+ encode_ok(value, handle_table)
53
54
  rescue StandardError => e
54
55
  encode_caught_error(e)
55
56
  end
@@ -57,15 +58,16 @@ module Kobako
57
58
  # Map an error caught at the dispatch boundary to a +Response.error+
58
59
  # envelope. +error+ is the +StandardError+ caught by {#dispatch}'s
59
60
  # rescue. Returns a msgpack-encoded Response envelope (binary). Four
60
- # error buckets ({SPEC.md B-12}[link:../../../SPEC.md]):
61
- # +Kobako::Codec::Error+ → type="runtime" (wire decode failed);
61
+ # error buckets ({docs/behavior.md B-12}[link:../../../docs/behavior.md]):
62
+ # +Kobako::Codec::Error+ → type="runtime" (malformed RPC request);
62
63
  # +DisconnectedTargetError+ → type="disconnected" (E-14);
63
64
  # +UndefinedTargetError+ → type="undefined" (E-13); +ArgumentError+ →
64
65
  # type="argument" (B-12 arity mismatch); everything else →
65
66
  # type="runtime".
66
67
  def encode_caught_error(error)
67
68
  case error
68
- when Kobako::Codec::Error then encode_error("runtime", "wire decode failed: #{error.message}")
69
+ when Kobako::Codec::Error then encode_error("runtime",
70
+ "Sandbox received a malformed RPC request: #{error.message}")
69
71
  when DisconnectedTargetError then encode_error("disconnected", error.message)
70
72
  when UndefinedTargetError then encode_error("undefined", error.message)
71
73
  when ArgumentError then encode_error("argument", error.message)
@@ -86,7 +88,7 @@ module Kobako
86
88
  end
87
89
  end
88
90
 
89
- # {SPEC.md B-16}[link:../../../SPEC.md] — An RPC::Handle arriving as a positional or keyword
91
+ # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An Kobako::Handle arriving as a positional or keyword
90
92
  # argument identifies a host-side object previously allocated by a prior
91
93
  # RPC's Handle wrap (B-14). Resolve it back to the Ruby object before
92
94
  # the dispatch reaches +public_send+. A Handle whose entry is the
@@ -94,7 +96,7 @@ module Kobako
94
96
  # the dispatcher emits a Response.error with type="disconnected".
95
97
  def resolve_arg(value, handle_table)
96
98
  case value
97
- when Kobako::RPC::Handle
99
+ when Kobako::Handle
98
100
  require_live_object!(value.id, handle_table)
99
101
  else
100
102
  value
@@ -112,7 +114,7 @@ module Kobako
112
114
  case target
113
115
  when String
114
116
  resolve_path(target, server)
115
- when Kobako::RPC::Handle
117
+ when Kobako::Handle
116
118
  resolve_handle(target, handle_table)
117
119
  end
118
120
  end
@@ -139,24 +141,24 @@ module Kobako
139
141
  end
140
142
 
141
143
  # Encode +value+ as a +Response.ok+ envelope. When the value is not
142
- # wire-representable per {SPEC.md B-13}[link:../../../SPEC.md]'s type
144
+ # wire-representable per {docs/behavior.md B-13}[link:../../../docs/behavior.md]'s type
143
145
  # mapping, the +UnsupportedType+ rescue routes it through the
144
146
  # HandleTable via {#wrap_as_handle} and re-encodes with the Capability
145
- # Handle in place ({SPEC.md B-14}[link:../../../SPEC.md]). The happy
147
+ # Handle in place ({docs/behavior.md B-14}[link:../../../docs/behavior.md]). The happy
146
148
  # path encodes exactly once.
147
- def encode_ok(value, server)
149
+ def encode_ok(value, handle_table)
148
150
  response = Kobako::RPC::Response.ok(value)
149
151
  Kobako::RPC.encode_response(response)
150
152
  rescue Kobako::Codec::UnsupportedType
151
- encode_ok(wrap_as_handle(value, server), server)
153
+ encode_ok(wrap_as_handle(value, handle_table), handle_table)
152
154
  end
153
155
 
154
- # Allocate +value+ in the Server's HandleTable and return a +Handle+
155
- # that the wire codec can carry ({SPEC.md B-14}[link:../../../SPEC.md]).
156
+ # Allocate +value+ in the Sandbox's HandleTable and return a +Handle+
157
+ # that the wire codec can carry ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
156
158
  # Used as the fallback path of {#encode_ok} when +value+ has no wire
157
159
  # representation.
158
- def wrap_as_handle(value, server)
159
- Kobako::RPC::Handle.new(server.handle_table.alloc(value))
160
+ def wrap_as_handle(value, handle_table)
161
+ handle_table.alloc(value)
160
162
  end
161
163
 
162
164
  def encode_error(type, message)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "handle"
3
+ require_relative "../handle"
4
4
  require_relative "fault"
5
5
  require_relative "../codec"
6
6
 
@@ -8,7 +8,7 @@ module Kobako
8
8
  # See lib/kobako/rpc.rb for the umbrella module doc; this file owns the
9
9
  # Request / Response value objects and their encode/decode helpers.
10
10
  module RPC
11
- # ---------------- Response status bytes (SPEC.md Response Shape) ---
11
+ # ---------------- Response status bytes (docs/wire-contract.md § Response Shape) ---
12
12
 
13
13
  # Response variant marker for the success branch.
14
14
  STATUS_OK = 0
@@ -16,7 +16,7 @@ module Kobako
16
16
  STATUS_ERROR = 1
17
17
 
18
18
  # Value object for a single guest-initiated RPC Request
19
- # ({SPEC.md Wire Codec → Request}[link:../../../SPEC.md]).
19
+ # ({docs/wire-codec.md Envelope Encoding → Request}[link:../../../docs/wire-codec.md]).
20
20
  #
21
21
  # 4-element msgpack array: +[target, method, args, kwargs]+. +target+
22
22
  # is either a +String+ (+"Namespace::Member"+) or a {Handle}. SPEC pins
@@ -25,8 +25,8 @@ module Kobako
25
25
  Request = Data.define(:target, :method_name, :args, :kwargs) do
26
26
  # steep:ignore:start
27
27
  def initialize(target:, method:, args: [], kwargs: {})
28
- unless target.is_a?(String) || target.is_a?(Kobako::RPC::Handle)
29
- raise ArgumentError, "Request target must be String or Kobako::RPC::Handle, got #{target.class}"
28
+ unless target.is_a?(String) || target.is_a?(Kobako::Handle)
29
+ raise ArgumentError, "Request target must be String or Kobako::Handle, got #{target.class}"
30
30
  end
31
31
  raise ArgumentError, "Request method must be String" unless method.is_a?(String)
32
32
  raise ArgumentError, "Request args must be Array" unless args.is_a?(Array)
@@ -56,7 +56,7 @@ module Kobako
56
56
  def self.decode_request(bytes)
57
57
  arr = Codec::Decoder.decode(bytes)
58
58
  unless arr.is_a?(Array) && arr.length == 4
59
- raise Codec::InvalidType, "Request must be a 4-element array, got #{arr.inspect}"
59
+ raise Codec::InvalidType, "Request envelope is malformed (expected a 4-element array)"
60
60
  end
61
61
 
62
62
  target, method_name, args, kwargs = arr
@@ -66,7 +66,7 @@ module Kobako
66
66
  end
67
67
 
68
68
  # Value object for a single host-side RPC Response
69
- # ({SPEC.md Wire Codec → Response}[link:../../../SPEC.md]).
69
+ # ({docs/wire-codec.md Envelope Encoding → Response}[link:../../../docs/wire-codec.md]).
70
70
  #
71
71
  # 2-element msgpack array: +[status, value-or-fault]+. +status+ is 0
72
72
  # (success) or 1 (fault). For success the second element is the return
@@ -87,10 +87,10 @@ module Kobako
87
87
 
88
88
  def initialize(status:, payload:)
89
89
  unless [STATUS_OK, STATUS_ERROR].include?(status)
90
- raise ArgumentError, "Response status must be 0 or 1, got #{status.inspect}"
90
+ raise ArgumentError, "Response status must be 0 (ok) or 1 (error), got #{status.inspect}"
91
91
  end
92
92
  if status == STATUS_ERROR && !payload.is_a?(Kobako::RPC::Fault)
93
- raise ArgumentError, "Response status=1 payload must be Kobako::RPC::Fault"
93
+ raise ArgumentError, "Response with error status must carry a Kobako::RPC::Fault payload"
94
94
  end
95
95
 
96
96
  super
@@ -108,7 +108,7 @@ module Kobako
108
108
  def self.decode_response(bytes)
109
109
  arr = Codec::Decoder.decode(bytes)
110
110
  unless arr.is_a?(Array) && arr.length == 2
111
- raise Codec::InvalidType, "Response must be a 2-element array, got #{arr.inspect}"
111
+ raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
112
112
  end
113
113
 
114
114
  status, payload = arr
@@ -4,8 +4,9 @@ module Kobako
4
4
  module RPC
5
5
  # Wire-level value object for an ext-0x02 Exception envelope.
6
6
  #
7
- # SPEC pins the payload (Wire Codec → Ext Types → ext 0x02) to a
8
- # msgpack map with exactly three keys:
7
+ # SPEC pins the payload
8
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
9
+ # → ext 0x02) to a msgpack map with exactly three keys:
9
10
  # * "type" — one of "runtime", "argument", "disconnected", "undefined"
10
11
  # * "message" — human-readable string
11
12
  # * "details" — any wire-legal value, or nil when absent
@@ -22,7 +23,7 @@ module Kobako
22
23
  # Reach it through +self.class::VALID_TYPES+ — Data.define's block
23
24
  # scope resolves bare constants against the enclosing +Wire+ module,
24
25
  # so a bare +VALID_TYPES+ would raise +NameError+. Same pattern as
25
- # +RPC::Handle+.
26
+ # +Kobako::Handle+.
26
27
  # steep:ignore:start
27
28
  def initialize(type:, message:, details: nil)
28
29
  valid_types = self.class::VALID_TYPES