kobako 0.2.1 → 0.3.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +123 -57
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +3 -3
  7. data/ext/kobako/src/wasm/dispatch.rs +87 -36
  8. data/ext/kobako/src/wasm/host_state.rs +189 -52
  9. data/ext/kobako/src/wasm/instance.rs +367 -152
  10. data/ext/kobako/src/wasm.rs +19 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -2
  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 +11 -7
  16. data/lib/kobako/codec/utils.rb +3 -2
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/invocation.rb +112 -0
  20. data/lib/kobako/outcome/panic.rb +2 -2
  21. data/lib/kobako/outcome.rb +20 -13
  22. data/lib/kobako/rpc/dispatcher.rb +9 -9
  23. data/lib/kobako/rpc/envelope.rb +3 -3
  24. data/lib/kobako/rpc/fault.rb +3 -2
  25. data/lib/kobako/rpc/handle.rb +3 -2
  26. data/lib/kobako/rpc/handle_table.rb +7 -7
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +14 -12
  29. data/lib/kobako/sandbox.rb +147 -125
  30. data/lib/kobako/sandbox_options.rb +73 -0
  31. data/lib/kobako/snippet/binary.rb +30 -0
  32. data/lib/kobako/snippet/source.rb +28 -0
  33. data/lib/kobako/snippet/table.rb +174 -0
  34. data/lib/kobako/snippet.rb +20 -0
  35. data/lib/kobako/version.rb +1 -1
  36. data/sig/kobako/errors.rbs +3 -0
  37. data/sig/kobako/invocation.rbs +23 -0
  38. data/sig/kobako/sandbox.rbs +17 -18
  39. data/sig/kobako/sandbox_options.rbs +32 -0
  40. data/sig/kobako/snippet/binary.rbs +12 -0
  41. data/sig/kobako/snippet/source.rbs +13 -0
  42. data/sig/kobako/snippet/table.rbs +36 -0
  43. data/sig/kobako/snippet.rbs +4 -0
  44. data/sig/kobako/wasm.rbs +3 -1
  45. metadata +13 -1
@@ -32,10 +32,23 @@ mod host_state;
32
32
  mod instance;
33
33
 
34
34
  use magnus::value::Lazy;
35
- use magnus::{function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
35
+ use magnus::{
36
+ function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, RString, Ruby,
37
+ };
36
38
 
37
39
  use instance::Instance;
38
40
 
41
+ /// Copy the bytes of +s+ into a fresh +Vec<u8>+. Single safe entry to
42
+ /// what would otherwise be an inline +unsafe { rstring.as_slice() }
43
+ /// .to_vec()+ duplicated at every host-↔-guest boundary. The borrow
44
+ /// does not outlive this call, so no Ruby allocation can move the
45
+ /// underlying RString between the borrow and the copy — the safety
46
+ /// invariant the inline form relied on is established once here.
47
+ pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
48
+ // SAFETY: see item doc.
49
+ unsafe { s.as_slice() }.to_vec()
50
+ }
51
+
39
52
  // ---------------------------------------------------------------------------
40
53
  // Error classes (lazy-resolved from Ruby once Kobako::Wasm is defined).
41
54
  // ---------------------------------------------------------------------------
@@ -69,14 +82,14 @@ pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
69
82
  }
70
83
 
71
84
  /// Construct a `Kobako::Wasm::TimeoutError` magnus error. Surfaces the
72
- /// SPEC.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
85
+ /// docs/behavior.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
73
86
  /// as `Kobako::TimeoutError`.
74
87
  pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
75
88
  MagnusError::new(ruby.get_inner(&WASM_TIMEOUT_ERROR), msg.into())
76
89
  }
77
90
 
78
91
  /// Construct a `Kobako::Wasm::MemoryLimitError` magnus error. Surfaces
79
- /// the SPEC.md E-20 linear-memory cap path so the Sandbox layer can
92
+ /// the docs/behavior.md E-20 linear-memory cap path so the Sandbox layer can
80
93
  /// rewrap it as `Kobako::MemoryLimitError`.
81
94
  pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
82
95
  MagnusError::new(ruby.get_inner(&WASM_MEMORY_LIMIT_ERROR), msg.into())
@@ -92,7 +105,7 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
92
105
  // Error hierarchy. ModuleNotBuiltError is the headline error for the
93
106
  // common pre-build state where `data/kobako.wasm` has not yet been
94
107
  // produced (e.g. fresh clone before `rake compile`). TimeoutError and
95
- // MemoryLimitError carry the SPEC.md B-01 per-run cap paths up to the
108
+ // MemoryLimitError carry the docs/behavior.md B-01 per-run cap paths up to the
96
109
  // Sandbox layer.
97
110
  let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
98
111
  wasm.define_error("ModuleNotBuiltError", base_err)?;
@@ -102,7 +115,8 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
102
115
  let instance = wasm.define_class("Instance", ruby.class_object())?;
103
116
  instance.define_singleton_method("from_path", function!(Instance::from_path, 5))?;
104
117
  instance.define_method("server=", method!(Instance::set_server, 1))?;
105
- instance.define_method("run", method!(Instance::run, 2))?;
118
+ instance.define_method("eval", method!(Instance::eval, 3))?;
119
+ instance.define_method("run", method!(Instance::run, 3))?;
106
120
  instance.define_method("stdout", method!(Instance::stdout, 0))?;
107
121
  instance.define_method("stderr", method!(Instance::stderr, 0))?;
108
122
  instance.define_method("outcome!", method!(Instance::outcome, 0))?;
@@ -2,15 +2,16 @@
2
2
 
3
3
  module Kobako
4
4
  # Host-side captured prefix of guest stdout / stderr produced during a
5
- # single +Kobako::Sandbox#run+, paired with the truncation flag the WASI
6
- # pipe sets when the guest wrote past the configured per-channel cap
7
- # ({SPEC.md B-04}[link:../../SPEC.md]).
5
+ # single +Kobako::Sandbox+ invocation, paired with the truncation flag
6
+ # the WASI pipe sets when the guest wrote past the configured per-channel
7
+ # cap ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
8
8
  #
9
9
  # Immutable value object: the captured bytes and the truncation flag
10
10
  # always travel together and the instance is frozen on construction.
11
11
  # Construct via +Capture.from_ext+ for ext-provided binary bytes (handles
12
- # UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-run
13
- # sentinel that +Sandbox+ uses before any +#run+ has executed.
12
+ # UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-
13
+ # invocation sentinel that +Sandbox+ uses before any invocation has
14
+ # executed.
14
15
  class Capture
15
16
  attr_reader :bytes
16
17
 
@@ -24,8 +25,8 @@ module Kobako
24
25
  end
25
26
 
26
27
  # Returns +true+ iff the underlying capture channel exceeded its
27
- # configured cap during the originating +Sandbox#run+
28
- # ({SPEC.md B-04}[link:../../SPEC.md]).
28
+ # configured cap during the originating +Sandbox+ invocation
29
+ # ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
29
30
  def truncated? = @truncated
30
31
 
31
32
  # Construct a Capture from ext-provided binary bytes. Coerces +bytes+
@@ -38,9 +39,10 @@ module Kobako
38
39
  new(bytes: copy, truncated: truncated)
39
40
  end
40
41
 
41
- # Pre-run sentinel ({SPEC.md B-05}[link:../../SPEC.md]). Empty UTF-8
42
- # bytes and +truncated? == false+; reused by every fresh +Sandbox+ and
43
- # by +Sandbox#run+ between invocations to denote "no capture yet".
42
+ # Pre-invocation sentinel ({docs/behavior.md B-05}[link:../../docs/behavior.md]).
43
+ # Empty UTF-8 bytes and +truncated? == false+; reused by every fresh
44
+ # +Sandbox+ and by +Sandbox+ between invocations to denote "no capture
45
+ # yet".
44
46
  EMPTY = new(bytes: "", truncated: false)
45
47
  end
46
48
  end
@@ -9,7 +9,7 @@ require_relative "utils"
9
9
  module Kobako
10
10
  module Codec
11
11
  # Module-level entry point for the host side of the kobako wire
12
- # (SPEC.md Wire Codec → Type Mapping).
12
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping).
13
13
  #
14
14
  # Translates msgpack gem exceptions into the kobako error taxonomy
15
15
  # ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
@@ -42,7 +42,8 @@ module Kobako
42
42
  raise InvalidEncoding, e.message
43
43
  end
44
44
 
45
- # SPEC pins +str+ family payloads to UTF-8 (Wire Codec → str/bin
45
+ # SPEC pins +str+ family payloads to UTF-8
46
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § str/bin
46
47
  # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
47
48
  # str family but does not validate the bytes; +bin+ family decodes
48
49
  # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
@@ -8,7 +8,7 @@ require_relative "factory"
8
8
  module Kobako
9
9
  module Codec
10
10
  # Module-level entry point for the host side of the kobako wire
11
- # (SPEC.md Wire Codec → Type Mapping).
11
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping).
12
12
  #
13
13
  # The codec backbone is the official +msgpack+ gem: integers, floats,
14
14
  # strings, arrays, and maps go through the gem's narrowest-encoding
@@ -4,8 +4,9 @@ module Kobako
4
4
  module Codec
5
5
  # Base class for all wire-codec faults raised by the pure-Ruby host codec.
6
6
  #
7
- # The wire codec implements the binary contract pinned in SPEC.md
8
- # (Wire Codec Type Mapping). Every wire violation surfaces as a
7
+ # The wire codec implements the binary contract pinned in
8
+ # {docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping.
9
+ # Every wire violation surfaces as a
9
10
  # subclass of {Error} so callers can pattern-match on the specific
10
11
  # fault while still rescuing all codec faults via this base class.
11
12
  #
@@ -12,7 +12,8 @@ require_relative "../rpc/fault"
12
12
  module Kobako
13
13
  module Codec
14
14
  # Cached +MessagePack::Factory+ that owns the kobako wire ext-type
15
- # registration (SPEC.md → Wire Codec → Ext Types).
15
+ # registration ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
16
+ # § Ext Types).
16
17
  #
17
18
  # The factory is the single place in the host gem that touches the
18
19
  # msgpack API — both {Encoder} and {Decoder} delegate through it, so
@@ -31,16 +32,19 @@ module Kobako
31
32
  extend SingleForwardable
32
33
 
33
34
  # MessagePack ext type code reserved for Symbol
34
- # (SPEC.md Wire Codec → Ext Types → ext 0x00). Class-private —
35
- # mirrors +codec::EXT_SYMBOL+ on the Rust side.
35
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
36
+ # → ext 0x00). Class-private — mirrors +codec::EXT_SYMBOL+ on the
37
+ # Rust side.
36
38
  EXT_SYMBOL = 0x00
37
39
  # MessagePack ext type code reserved for Capability Handle
38
- # (SPEC.md Wire Codec → Ext Types → ext 0x01). Class-private —
39
- # mirrors +codec::EXT_HANDLE+ on the Rust side.
40
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
41
+ # → ext 0x01). Class-private — mirrors +codec::EXT_HANDLE+ on the
42
+ # Rust side.
40
43
  EXT_HANDLE = 0x01
41
44
  # MessagePack ext type code reserved for Exception envelope
42
- # (SPEC.md Wire Codec → Ext Types → ext 0x02). Class-private —
43
- # mirrors +codec::EXT_ERRENV+ on the Rust side.
45
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
46
+ # → ext 0x02). Class-private — mirrors +codec::EXT_ERRENV+ on the
47
+ # Rust side.
44
48
  EXT_ERRENV = 0x02
45
49
  private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
46
50
 
@@ -6,8 +6,9 @@ module Kobako
6
6
  module Codec
7
7
  # Wire-codec helpers shared by the host-side encoders and decoders.
8
8
  # The single concern today is UTF-8 assertion at the wire boundary
9
- # (SPEC.md Wire Codec → str/bin Encoding Rules and Ext Types →
10
- # ext 0x00). Two call sites lean on this:
9
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § str/bin
10
+ # Encoding Rules and § Ext Types → ext 0x00). Two call sites lean on
11
+ # this:
11
12
  #
12
13
  # - {Decoder} validates +str+ family payloads as it walks the
13
14
  # decoded value tree.
data/lib/kobako/codec.rb CHANGED
@@ -4,7 +4,8 @@ require_relative "codec/error"
4
4
 
5
5
  module Kobako
6
6
  # Host-side MessagePack codec for the kobako wire contract — the
7
- # byte-level layer (SPEC.md → Wire Codec). Two consumers sit on top:
7
+ # byte-level layer ({docs/wire-codec.md}[link:../../docs/wire-codec.md]).
8
+ # Two consumers sit on top:
8
9
  # +Kobako::RPC+ pins the RPC framing (Request / Response)
9
10
  # and +Kobako::Outcome+ owns the per-+#run+ outcome envelope (Result
10
11
  # body / Panic map).
data/lib/kobako/errors.rb CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  # Top-level Kobako namespace.
4
4
  module Kobako
5
- # Three-class error taxonomy (SPEC.md Error Scenarios).
5
+ # Three-class error taxonomy (docs/behavior.md § Error Scenarios).
6
6
  #
7
- # Every `Kobako::Sandbox#run` invocation either returns a value or raises
7
+ # Every `Kobako::Sandbox` invocation (`#eval` or `#run`) either returns a value or raises
8
8
  # exactly one of these three classes. Attribution is decided after the
9
- # guest binary returns control to the host (SPEC "Step 1 — Wasm trap"
10
- # then "Step 2 — Outcome envelope tag").
9
+ # guest binary returns control to the host (docs/behavior.md
10
+ # "Step 1 — Wasm trap" then "Step 2 — Outcome envelope tag").
11
11
  #
12
12
  # Three top-level branches:
13
13
  #
@@ -20,7 +20,7 @@ module Kobako
20
20
  # * {ServiceError} — service / capability layer (a Service RPC that
21
21
  # failed and was not rescued inside the script).
22
22
  #
23
- # Subclasses pinned by SPEC "Error Classes":
23
+ # Subclasses pinned by docs/behavior.md Error Classes:
24
24
  #
25
25
  # * {HandleTableExhausted} < {SandboxError} — id cap hit (B-21).
26
26
  # * {ServiceError::Disconnected} < {ServiceError} — `:disconnected`
@@ -35,7 +35,7 @@ module Kobako
35
35
  # violation that signals a corrupted guest execution environment
36
36
  # (zero-length OUTCOME_BUFFER, unknown outcome tag — SPEC E-02 / E-03).
37
37
  #
38
- # Two named subclasses cover the configured per-run caps from B-01:
38
+ # Two named subclasses cover the configured per-invocation caps from B-01:
39
39
  #
40
40
  # * {TimeoutError} — wall-clock +timeout+ exceeded (E-19).
41
41
  # * {MemoryLimitError} — guest +memory.grow+ would exceed
@@ -47,13 +47,13 @@ module Kobako
47
47
  # first.
48
48
  class TrapError < Error; end
49
49
 
50
- # Wall-clock timeout cap exhausted. {SPEC.md E-19}[link:../../SPEC.md]:
50
+ # Wall-clock timeout cap exhausted. {docs/behavior.md E-19}[link:../../docs/behavior.md]:
51
51
  # the absolute deadline +entry_time + timeout+ passed and the next guest
52
52
  # wasm safepoint trapped. The Sandbox is unrecoverable after this point;
53
53
  # discard and recreate before another execution.
54
54
  class TimeoutError < TrapError; end
55
55
 
56
- # Linear-memory cap exhausted. {SPEC.md E-20}[link:../../SPEC.md]:
56
+ # Linear-memory cap exhausted. {docs/behavior.md E-20}[link:../../docs/behavior.md]:
57
57
  # a guest +memory.grow+ would have pushed linear memory past the
58
58
  # configured +memory_limit+. The Sandbox is unrecoverable after this
59
59
  # point; discard and recreate before another execution.
@@ -88,7 +88,7 @@ module Kobako
88
88
  @details = details
89
89
  end
90
90
 
91
- # SPEC "Error Classes": ServiceError::Disconnected is raised
91
+ # docs/behavior.md Error Classes: ServiceError::Disconnected is raised
92
92
  # when the RPC target Handle resolves to the `:disconnected` sentinel
93
93
  # in the HandleTable (ABA protection rule — id exists but entry was
94
94
  # invalidated). E-14.
@@ -103,9 +103,21 @@ module Kobako
103
103
  # directly), it surfaces as a SandboxError.
104
104
  class HandleTableError < SandboxError; end
105
105
 
106
- # SPEC "Error Classes": HandleTableExhausted is the canonical
106
+ # docs/behavior.md Error Classes: HandleTableExhausted is the canonical
107
107
  # SandboxError subclass for the id-cap-hit path (B-21). Inherits from
108
108
  # HandleTableError so a single `rescue Kobako::HandleTableError` covers
109
109
  # both lookup-failure and cap-exhaustion paths.
110
110
  class HandleTableExhausted < HandleTableError; end
111
+
112
+ # docs/behavior.md Error Classes: BytecodeError is the SandboxError
113
+ # subclass raised when a `#preload(binary:)` snippet fails structural
114
+ # validation during the first invocation's snippet replay against a
115
+ # fresh `mrb_state` (E-37 RITE version mismatch, E-38 corrupt body).
116
+ # Bytecode that loads cleanly and then raises at top level is E-36
117
+ # and surfaces as plain `SandboxError` with the natural mruby class
118
+ # preserved. Inherits from SandboxError so a single
119
+ # `rescue Kobako::SandboxError` covers both source and bytecode
120
+ # snippet failures while callers wanting bytecode-specific handling
121
+ # can `rescue Kobako::BytecodeError` directly.
122
+ class BytecodeError < SandboxError; end
111
123
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rpc/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 is
19
+ # the guest→host capability channel (Server / Client / Request /
20
+ # Response / Handle); Invocation is the opposite direction (host→guest
21
+ # entrypoint dispatch) and structurally rejects Handles (E-29), so it
22
+ # has no relationship with the HandleTable. The +#encode+ output is the
23
+ # "Invocation envelope" that ships through the +__kobako_run+ command
24
+ # buffer.
25
+ #
26
+ # Built on the +class X < Data.define(...)+ subclass form (the
27
+ # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
28
+ class Invocation < Data.define(:entrypoint, :args, :kwargs)
29
+ # Ruby constant-name pattern enforced on the +entrypoint+ Symbol
30
+ # ({docs/behavior.md E-25}[link:../../docs/behavior.md]). Parallel to
31
+ # +Kobako::Snippet::Table::NAME_PATTERN+; the two constants name the
32
+ # same regex but cover distinct surfaces (snippet identity vs.
33
+ # entrypoint resolution) so a future divergence stays local.
34
+ NAME_PATTERN = /\A[A-Z]\w*\z/
35
+
36
+ # steep:ignore:start
37
+ def initialize(entrypoint:, args: [], kwargs: {})
38
+ super(
39
+ entrypoint: normalize_entrypoint(entrypoint),
40
+ args: validate_args!(args),
41
+ kwargs: validate_kwargs!(kwargs)
42
+ )
43
+ end
44
+ # steep:ignore:end
45
+
46
+ # Encode this Invocation to the msgpack bytes the guest's
47
+ # +__kobako_run+ entry point consumes as its command-buffer payload
48
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]).
49
+ # The Value Object's own invariants are the contract; this method
50
+ # does not re-check the shape. Layout: msgpack map with string keys
51
+ # +"entrypoint"+ (Symbol via ext 0x00), +"args"+ (Array), +"kwargs"+
52
+ # (Map with Symbol keys).
53
+ def encode
54
+ Codec::Encoder.encode(
55
+ "entrypoint" => entrypoint,
56
+ "args" => args,
57
+ "kwargs" => kwargs
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ # steep:ignore:start
64
+ # E-24: target must be a Symbol or String (TypeError, not
65
+ # ArgumentError — the wrong-type case is a Host App programming
66
+ # error before the invocation reaches the guest). E-25: after
67
+ # +.to_s+ the value must match NAME_PATTERN (ArgumentError),
68
+ # rejecting +::+-segmented names and any non-constant form.
69
+ def normalize_entrypoint(target)
70
+ unless target.is_a?(Symbol) || target.is_a?(String)
71
+ raise TypeError, "Invocation entrypoint must be a Symbol or String, got #{target.class}"
72
+ end
73
+
74
+ target_str = target.to_s
75
+ unless NAME_PATTERN.match?(target_str)
76
+ raise ArgumentError,
77
+ "Invocation entrypoint must match #{NAME_PATTERN.inspect} (got #{target.inspect})"
78
+ end
79
+
80
+ target_str.to_sym
81
+ end
82
+
83
+ # E-29: +args+ must not contain a +Kobako::RPC::Handle+. Handles
84
+ # are per-invocation and cannot enter the next invocation through
85
+ # a control-plane channel; a guest that needs to call into a
86
+ # stateful host object must obtain a fresh Handle through a
87
+ # Service RPC inside the dispatched entrypoint.
88
+ def validate_args!(args)
89
+ raise ArgumentError, "Invocation args must be Array" unless args.is_a?(Array)
90
+ raise ArgumentError, "Invocation args must not contain a Kobako::RPC::Handle" if args.any?(Kobako::RPC::Handle)
91
+
92
+ args
93
+ end
94
+
95
+ # E-30: +kwargs+ keys must be Symbols, mirroring the wire codec's
96
+ # Request kwargs rule. Validation lives here (not in the codec) so
97
+ # the Host App sees the host-side error message before any encode
98
+ # / decode boundary.
99
+ def validate_kwargs!(kwargs)
100
+ raise ArgumentError, "Invocation kwargs must be Hash" unless kwargs.is_a?(Hash)
101
+
102
+ bad_keys = kwargs.each_key.grep_v(Symbol)
103
+ unless bad_keys.empty?
104
+ raise ArgumentError,
105
+ "Invocation kwargs keys must be Symbols (got #{bad_keys.inspect})"
106
+ end
107
+
108
+ kwargs
109
+ end
110
+ # steep:ignore:end
111
+ end
112
+ 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
  #
@@ -4,10 +4,10 @@ require_relative "outcome/panic"
4
4
 
5
5
  module Kobako
6
6
  # Host-facing boundary for the OUTCOME_BUFFER produced by
7
- # +__kobako_run+. Takes raw outcome bytes — a one-byte tag followed by
7
+ # +__kobako_eval+. Takes raw outcome bytes — a one-byte tag followed by
8
8
  # the msgpack-encoded body — and maps them to either the unwrapped
9
9
  # mruby return value or a raised three-layer
10
- # ({SPEC.md "Error Scenarios"}[link:../../SPEC.md]) exception.
10
+ # ({docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]) exception.
11
11
  #
12
12
  # Self-contained: this module owns the wire framing (tag bytes,
13
13
  # body decoding), and the +Panic+ wire record lives at
@@ -24,7 +24,7 @@ module Kobako
24
24
  module Outcome
25
25
  # First byte of the OUTCOME_BUFFER for the success branch — body is
26
26
  # the bare msgpack encoding of the returned value
27
- # ({SPEC.md Outcome Envelope}[link:../../SPEC.md]).
27
+ # ({docs/wire-contract.md Outcome Envelope}[link:../../docs/wire-contract.md]).
28
28
  TYPE_VALUE = 0x01
29
29
  # First byte of the OUTCOME_BUFFER for the failure branch — body is
30
30
  # the msgpack Panic map.
@@ -45,8 +45,8 @@ module Kobako
45
45
  end
46
46
 
47
47
  # 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).
48
+ # ({docs/wire-codec.md ABI Signatures}[link:../../docs/wire-codec.md]:
49
+ # len=0 and unknown-tag both walk the trap path).
50
50
  def build_trap_error(tag)
51
51
  return TrapError.new("guest exited without writing an outcome (len=0)") if tag.nil?
52
52
 
@@ -100,7 +100,7 @@ module Kobako
100
100
  # Ruby exception. +origin == "service"+ → ServiceError (with the
101
101
  # +::Disconnected+ subclass selected when the panic carries the
102
102
  # disconnected sentinel —
103
- # {SPEC "Error Classes"}[link:../../SPEC.md]); everything else
103
+ # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]); everything else
104
104
  # → SandboxError.
105
105
  def build_panic_error(panic)
106
106
  panic_target_class(panic).new(
@@ -112,14 +112,21 @@ module Kobako
112
112
  )
113
113
  end
114
114
 
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).
115
+ # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]: map
116
+ # the panic +class+ field to the matching Ruby exception subclass so
117
+ # callers can rescue specific failure paths. +origin="service"+ plus
118
+ # +class="Kobako::ServiceError::Disconnected"+ selects the
119
+ # +Disconnected+ subclass (E-14); +origin="sandbox"+ plus
120
+ # +class="Kobako::BytecodeError"+ selects the +BytecodeError+
121
+ # subclass (E-37 / E-38). Everything else falls back to the base
122
+ # class for the origin.
119
123
  def panic_target_class(panic)
120
- return SandboxError unless panic.origin == Panic::ORIGIN_SERVICE
121
-
122
- panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
124
+ case panic.origin
125
+ when Panic::ORIGIN_SERVICE
126
+ panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
127
+ else
128
+ panic.klass == "Kobako::BytecodeError" ? BytecodeError : SandboxError
129
+ end
123
130
  end
124
131
 
125
132
  def build_wire_violation_error(message)
@@ -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,12 +22,12 @@ 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
 
@@ -41,7 +41,7 @@ module Kobako
41
41
  # during decode, lookup, or method invocation is reified as a
42
42
  # Response.error envelope so the guest sees the failure as a normal RPC
43
43
  # error rather than a wasm trap
44
- # ({SPEC.md B-12}[link:../../../SPEC.md]).
44
+ # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
45
45
  def dispatch(request_bytes, server)
46
46
  request = Kobako::RPC.decode_request(request_bytes)
47
47
  handle_table = server.handle_table
@@ -57,7 +57,7 @@ module Kobako
57
57
  # Map an error caught at the dispatch boundary to a +Response.error+
58
58
  # envelope. +error+ is the +StandardError+ caught by {#dispatch}'s
59
59
  # rescue. Returns a msgpack-encoded Response envelope (binary). Four
60
- # error buckets ({SPEC.md B-12}[link:../../../SPEC.md]):
60
+ # error buckets ({docs/behavior.md B-12}[link:../../../docs/behavior.md]):
61
61
  # +Kobako::Codec::Error+ → type="runtime" (wire decode failed);
62
62
  # +DisconnectedTargetError+ → type="disconnected" (E-14);
63
63
  # +UndefinedTargetError+ → type="undefined" (E-13); +ArgumentError+ →
@@ -86,7 +86,7 @@ module Kobako
86
86
  end
87
87
  end
88
88
 
89
- # {SPEC.md B-16}[link:../../../SPEC.md] — An RPC::Handle arriving as a positional or keyword
89
+ # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An RPC::Handle arriving as a positional or keyword
90
90
  # argument identifies a host-side object previously allocated by a prior
91
91
  # RPC's Handle wrap (B-14). Resolve it back to the Ruby object before
92
92
  # the dispatch reaches +public_send+. A Handle whose entry is the
@@ -139,10 +139,10 @@ module Kobako
139
139
  end
140
140
 
141
141
  # 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
142
+ # wire-representable per {docs/behavior.md B-13}[link:../../../docs/behavior.md]'s type
143
143
  # mapping, the +UnsupportedType+ rescue routes it through the
144
144
  # HandleTable via {#wrap_as_handle} and re-encodes with the Capability
145
- # Handle in place ({SPEC.md B-14}[link:../../../SPEC.md]). The happy
145
+ # Handle in place ({docs/behavior.md B-14}[link:../../../docs/behavior.md]). The happy
146
146
  # path encodes exactly once.
147
147
  def encode_ok(value, server)
148
148
  response = Kobako::RPC::Response.ok(value)
@@ -152,7 +152,7 @@ module Kobako
152
152
  end
153
153
 
154
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]).
155
+ # that the wire codec can carry ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
156
156
  # Used as the fallback path of {#encode_ok} when +value+ has no wire
157
157
  # representation.
158
158
  def wrap_as_handle(value, server)
@@ -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
@@ -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
@@ -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
@@ -5,8 +5,9 @@ module Kobako
5
5
  # Wire-level value object for an ext-0x01 Capability Handle.
6
6
  #
7
7
  # SPEC pins the binary layout to fixext 4 with a 4-byte big-endian u32
8
- # payload (Wire Codec → Ext Types → ext 0x01). ID 0 is reserved as the
9
- # invalid sentinel; the maximum valid ID is 0x7fff_ffff (2^31 - 1).
8
+ # payload ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
9
+ # § Ext Types ext 0x01). ID 0 is reserved as the invalid sentinel;
10
+ # the maximum valid ID is 0x7fff_ffff (2^31 - 1).
10
11
  #
11
12
  # This is intentionally a thin value object built on +Data.define+ so
12
13
  # equality, hash, and immutability are inherited. The runtime-facing