kobako 0.5.0-x86_64-linux

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/LICENSE +201 -0
  5. data/README.md +408 -0
  6. data/data/kobako.wasm +0 -0
  7. data/lib/kobako/3.3/kobako.so +0 -0
  8. data/lib/kobako/3.4/kobako.so +0 -0
  9. data/lib/kobako/4.0/kobako.so +0 -0
  10. data/lib/kobako/capture.rb +43 -0
  11. data/lib/kobako/catalog/handles.rb +107 -0
  12. data/lib/kobako/catalog/namespaces.rb +99 -0
  13. data/lib/kobako/catalog/snippets.rb +149 -0
  14. data/lib/kobako/catalog.rb +18 -0
  15. data/lib/kobako/codec/decoder.rb +73 -0
  16. data/lib/kobako/codec/encoder.rb +37 -0
  17. data/lib/kobako/codec/error.rb +34 -0
  18. data/lib/kobako/codec/factory.rb +162 -0
  19. data/lib/kobako/codec/utils.rb +145 -0
  20. data/lib/kobako/codec.rb +31 -0
  21. data/lib/kobako/errors.rb +140 -0
  22. data/lib/kobako/fault.rb +40 -0
  23. data/lib/kobako/handle.rb +60 -0
  24. data/lib/kobako/namespace.rb +67 -0
  25. data/lib/kobako/outcome/panic.rb +42 -0
  26. data/lib/kobako/outcome.rb +166 -0
  27. data/lib/kobako/runtime.rb +30 -0
  28. data/lib/kobako/sandbox.rb +314 -0
  29. data/lib/kobako/sandbox_options.rb +70 -0
  30. data/lib/kobako/snapshot.rb +40 -0
  31. data/lib/kobako/snippet/binary.rb +29 -0
  32. data/lib/kobako/snippet/source.rb +28 -0
  33. data/lib/kobako/snippet.rb +18 -0
  34. data/lib/kobako/transport/dispatcher.rb +195 -0
  35. data/lib/kobako/transport/error.rb +24 -0
  36. data/lib/kobako/transport/request.rb +78 -0
  37. data/lib/kobako/transport/response.rb +69 -0
  38. data/lib/kobako/transport/run.rb +141 -0
  39. data/lib/kobako/transport/yield.rb +91 -0
  40. data/lib/kobako/transport/yielder.rb +89 -0
  41. data/lib/kobako/transport.rb +24 -0
  42. data/lib/kobako/usage.rb +41 -0
  43. data/lib/kobako/version.rb +5 -0
  44. data/lib/kobako.rb +10 -0
  45. data/release-please-config.json +24 -0
  46. data/sig/kobako/capture.rbs +11 -0
  47. data/sig/kobako/catalog/handles.rbs +19 -0
  48. data/sig/kobako/catalog/namespaces.rbs +17 -0
  49. data/sig/kobako/catalog/snippets.rbs +27 -0
  50. data/sig/kobako/catalog.rbs +4 -0
  51. data/sig/kobako/codec/decoder.rbs +12 -0
  52. data/sig/kobako/codec/encoder.rbs +7 -0
  53. data/sig/kobako/codec/error.rbs +18 -0
  54. data/sig/kobako/codec/factory.rbs +31 -0
  55. data/sig/kobako/codec/utils.rbs +19 -0
  56. data/sig/kobako/errors.rbs +55 -0
  57. data/sig/kobako/fault.rbs +19 -0
  58. data/sig/kobako/handle.rbs +18 -0
  59. data/sig/kobako/namespace.rbs +19 -0
  60. data/sig/kobako/outcome/panic.rbs +34 -0
  61. data/sig/kobako/outcome.rbs +24 -0
  62. data/sig/kobako/runtime.rbs +23 -0
  63. data/sig/kobako/sandbox.rbs +55 -0
  64. data/sig/kobako/sandbox_options.rbs +32 -0
  65. data/sig/kobako/snapshot.rbs +15 -0
  66. data/sig/kobako/snippet/binary.rbs +12 -0
  67. data/sig/kobako/snippet/source.rbs +13 -0
  68. data/sig/kobako/snippet.rbs +4 -0
  69. data/sig/kobako/transport/dispatcher.rbs +34 -0
  70. data/sig/kobako/transport/error.rbs +6 -0
  71. data/sig/kobako/transport/request.rbs +32 -0
  72. data/sig/kobako/transport/response.rbs +30 -0
  73. data/sig/kobako/transport/run.rbs +27 -0
  74. data/sig/kobako/transport/yield.rbs +34 -0
  75. data/sig/kobako/transport/yielder.rbs +21 -0
  76. data/sig/kobako/transport.rbs +4 -0
  77. data/sig/kobako/usage.rbs +11 -0
  78. data/sig/kobako.rbs +3 -0
  79. metadata +145 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Outcome
5
+ # Wire-contract Outcome Envelope → Panic envelope ({docs/wire-contract.md
6
+ # Outcome Envelope}[link:../../../docs/wire-contract.md]). Wire-shaped failure record
7
+ # carried in the OUTCOME_BUFFER when the guest run terminates with
8
+ # an uncaught top-level exception.
9
+ #
10
+ # This is the **wire data**, not a raisable Ruby exception. The
11
+ # mapping from Panic to a three-layer Ruby exception (TrapError /
12
+ # SandboxError / ServiceError) happens at +Kobako::Outcome.decode+
13
+ # via +build_panic_error+ — callers never raise +Panic+ directly.
14
+ #
15
+ # The five fields mirror SPEC: +origin+ ("sandbox" / "service"),
16
+ # +klass+ (the guest-side exception class name as a String),
17
+ # +message+, +backtrace+ (Array of String), +details+ (any
18
+ # wire-legal value, nil when absent). Required-field validation is
19
+ # enforced at construction; the +ORIGIN_SANDBOX+ / +ORIGIN_SERVICE+
20
+ # constants pin the two SPEC-defined origin values.
21
+ #
22
+ # Built on the +class X < Data.define(...)+ subclass form so the
23
+ # class body is fully Steep-visible; ruby/rbs upstream documents
24
+ # this as the Steep-friendly shape and the +Style/DataInheritance+
25
+ # cop is disabled on that basis (see +.rubocop.yml+).
26
+ class Panic < Data.define(:origin, :klass, :message, :backtrace, :details)
27
+ ORIGIN_SANDBOX = "sandbox"
28
+ ORIGIN_SERVICE = "service"
29
+
30
+ def initialize(origin:, klass:, message:, backtrace: [], details: nil)
31
+ raise ArgumentError, "Panic origin must be String" unless origin.is_a?(String)
32
+ raise ArgumentError, "Panic class must be String" unless klass.is_a?(String)
33
+ raise ArgumentError, "Panic message must be String" unless message.is_a?(String)
34
+ unless backtrace.is_a?(Array) && backtrace.all?(String)
35
+ raise ArgumentError, "Panic backtrace must be Array of String"
36
+ end
37
+
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outcome/panic"
4
+ require_relative "transport/error"
5
+
6
+ module Kobako
7
+ # Host-facing boundary for the OUTCOME_BUFFER produced by
8
+ # +__kobako_eval+. Takes raw outcome bytes — a one-byte tag followed by
9
+ # the msgpack-encoded body — and maps them to either the unwrapped
10
+ # mruby return value or a raised three-layer
11
+ # ({docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]) exception.
12
+ #
13
+ # Self-contained: this module owns the wire framing (tag bytes,
14
+ # body decoding), and the +Panic+ wire record lives at
15
+ # +Kobako::Outcome::Panic+. The byte-level msgpack codec at
16
+ # +Kobako::Codec+ is invoked for the body itself; otherwise
17
+ # nothing in +Transport+ participates.
18
+ #
19
+ # * tag 0x01, decode OK → return decoded value
20
+ # * tag 0x01, decode fails → SandboxError (E-09)
21
+ # * tag 0x02, origin="service" → ServiceError (E-13)
22
+ # * tag 0x02, origin="sandbox"/missing → SandboxError (E-04..E-07)
23
+ # * tag 0x02, decode fails → SandboxError (E-08)
24
+ # * unknown tag → TrapError (E-03)
25
+ module Outcome
26
+ # First byte of the OUTCOME_BUFFER for the success branch — body is
27
+ # the bare msgpack encoding of the returned value
28
+ # ({docs/wire-contract.md Outcome Envelope}[link:../../docs/wire-contract.md]).
29
+ TYPE_VALUE = 0x01
30
+ # First byte of the OUTCOME_BUFFER for the failure branch — body is
31
+ # the msgpack Panic map.
32
+ TYPE_PANIC = 0x02
33
+
34
+ module_function
35
+
36
+ def decode(bytes)
37
+ tag, body = split_tag(bytes)
38
+ case tag
39
+ when TYPE_VALUE
40
+ decode_value(body)
41
+ when TYPE_PANIC
42
+ decode_panic(body)
43
+ else
44
+ raise build_trap_error(tag)
45
+ end
46
+ end
47
+
48
+ # TrapError for unknown or absent tag
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.
54
+ def build_trap_error(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
63
+ end
64
+
65
+ def split_tag(bytes)
66
+ bytes = bytes.b
67
+ return [nil, "".b] if bytes.empty?
68
+
69
+ tag = bytes.getbyte(0) # : Integer
70
+ body = bytes.byteslice(1, bytes.bytesize - 1) # : String
71
+ [tag, body]
72
+ end
73
+
74
+ # Decode failure on the success tag is a SandboxError (E-09): the
75
+ # framing was fine, but the carried value is unrepresentable. The
76
+ # specific codec fault is stashed in +details+ 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.
80
+ def decode_value(body)
81
+ Kobako::Codec::Decoder.decode(body)
82
+ rescue Kobako::Codec::Error => e
83
+ raise build_transport_error(
84
+ "Sandbox produced an invalid result value",
85
+ detail: e.message
86
+ )
87
+ end
88
+
89
+ # Decode failure on the panic tag is a SandboxError (E-08). Either
90
+ # path raises — on success the decoded Panic is mapped to its three-
91
+ # layer exception via +build_panic_error+ and raised; on wire-decode
92
+ # failure the rescue path raises the wire-violation +SandboxError+.
93
+ def decode_panic(body)
94
+ raise build_panic_error(build_panic_record(body))
95
+ rescue Kobako::Codec::Error => e
96
+ raise build_transport_error(
97
+ "Sandbox produced an invalid panic record",
98
+ detail: e.message
99
+ )
100
+ end
101
+
102
+ # Build a +Panic+ value object from the msgpack-decoded body. Raises
103
+ # +Kobako::Codec::InvalidType+ when the body is not a map or when
104
+ # required keys are missing — both routed by +decode_panic+ to
105
+ # +build_transport_error+. The decode runs in block form so
106
+ # +Panic.new+'s +ArgumentError+ invariants surface as +InvalidType+
107
+ # through the decoder boundary; the message itself is never user-
108
+ # facing — it lands in +details+ via the rescue chain above.
109
+ def build_panic_record(body)
110
+ Kobako::Codec::Decoder.decode(body) do |map|
111
+ raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
112
+
113
+ Panic.new(
114
+ origin: map["origin"], klass: map["class"], message: map["message"],
115
+ backtrace: map["backtrace"] || [], details: map["details"]
116
+ )
117
+ end
118
+ end
119
+
120
+ # Map a decoded Panic record into the corresponding three-layer
121
+ # Ruby exception. +origin == "service"+ → ServiceError; everything
122
+ # else → SandboxError.
123
+ def build_panic_error(panic)
124
+ panic_target_class(panic).new(
125
+ panic.message,
126
+ origin: panic.origin,
127
+ klass: panic.klass,
128
+ backtrace_lines: panic.backtrace,
129
+ details: panic.details
130
+ )
131
+ end
132
+
133
+ # {docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]: map
134
+ # the panic +class+ field to the matching Ruby exception subclass so
135
+ # callers can rescue specific failure paths. +origin="service"+ →
136
+ # +ServiceError+; +origin="sandbox"+ plus
137
+ # +class="Kobako::BytecodeError"+ selects the +BytecodeError+
138
+ # subclass (E-37 / E-38). Everything else falls back to the base
139
+ # class for the origin.
140
+ def panic_target_class(panic)
141
+ case panic.origin
142
+ when Panic::ORIGIN_SERVICE
143
+ ServiceError
144
+ else
145
+ panic.klass == "Kobako::BytecodeError" ? BytecodeError : SandboxError
146
+ end
147
+ end
148
+
149
+ # Lift the wire-violation fallback to the real
150
+ # +Kobako::Transport::Error+ class so callers can +rescue+ it
151
+ # specifically instead of pattern-matching on +error.klass+. The
152
+ # +klass+ field is still populated so existing operator-side
153
+ # tooling that greps on the string continues to work.
154
+ # +detail+ carries the inner codec / framing message, stashed
155
+ # directly into +details+ for operator diagnosis without polluting
156
+ # the user-facing +#message+.
157
+ def build_transport_error(message, detail: nil)
158
+ Kobako::Transport::Error.new(
159
+ message,
160
+ origin: Panic::ORIGIN_SANDBOX,
161
+ klass: "Kobako::Transport::Error",
162
+ details: detail
163
+ )
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Host-side wasmtime runtime, surfaced as a Ruby class by the native ext
5
+ # (see ext/kobako/src/runtime.rs). The +Kobako::Runtime+ class wraps the
6
+ # wasmtime engine + compiled module + Store; it is the only Ruby-visible
7
+ # wasmtime type and the foundational binding layer for +Kobako::Sandbox+.
8
+ #
9
+ # This file reopens the magnus-defined class only to add the pure-Ruby
10
+ # +.default_path+ helper. Every other method (+#from_path+ singleton,
11
+ # +#eval+ / +#run+, capture and usage readers) is registered from Rust
12
+ # at ext load time.
13
+ class Runtime
14
+ # Absolute path to the gem-bundled +data/kobako.wasm+ artifact. Computed
15
+ # from this file's location so it works for both +bundle exec+ (running
16
+ # from the repo) and an installed gem (running from the gem dir).
17
+ #
18
+ # Returns a String regardless of whether the file currently exists —
19
+ # call sites that need the file to be present should pass this through
20
+ # +Kobako::Runtime.from_path+, which raises
21
+ # +Kobako::ModuleNotBuiltError+ with a clear remediation message.
22
+ # Raises +Kobako::SetupError+ if +__dir__+ is unavailable so that
23
+ # +rescue Kobako::SetupError+ around +Kobako::Sandbox.new+ catches every
24
+ # construction-layer failure uniformly, including this path-resolution one.
25
+ def self.default_path
26
+ dir = __dir__ or raise Kobako::SetupError, "Kobako::Runtime.default_path requires __dir__"
27
+ File.expand_path("../../data/kobako.wasm", dir)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ require_relative "capture"
6
+ require_relative "errors"
7
+ require_relative "outcome"
8
+ require_relative "sandbox_options"
9
+ require_relative "usage"
10
+ require_relative "transport"
11
+ require_relative "catalog"
12
+
13
+ module Kobako
14
+ # Kobako::Sandbox — the user-facing entry point for executing guest mruby
15
+ # scripts inside a wasmtime-hosted Wasm module
16
+ # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
17
+ #
18
+ # The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
19
+ # +Kobako::Catalog::Handles+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
20
+ # the per-instance +Kobako::Catalog::Namespaces+ (which receives the
21
+ # +Catalog::Handles+ by injection so guest→host dispatch and host→guest
22
+ # auto-wrap share one allocator), and the dispatch +Proc+ /
23
+ # +yield_to_guest+ lambda installed on the Runtime via
24
+ # +Runtime#on_dispatch=+ ({docs/behavior.md B-12}[link:../../docs/behavior.md]).
25
+ # The underlying wasmtime Engine and compiled Module are cached at process
26
+ # scope by the native ext and never surface to Ruby — constructing many
27
+ # Sandboxes amortises both costs automatically.
28
+ #
29
+ # Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
30
+ # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
31
+ # WASI pipe — the host buffer stops growing at the cap, subsequent guest
32
+ # writes on that channel fail or are dropped, and +#run+ still returns
33
+ # normally. +#stdout+ / +#stderr+ return the captured prefix as a UTF-8
34
+ # String; the byte content never carries a truncation sentinel.
35
+ # +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
36
+ # that the cap was hit.
37
+ class Sandbox
38
+ extend Forwardable
39
+
40
+ attr_reader :wasm_path, :options
41
+
42
+ # Per-cap accessors forward to the immutable +SandboxOptions+ Value
43
+ # Object so the Host App still reads them off Sandbox directly.
44
+ def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit
45
+
46
+ # Returns the bytes the guest wrote to stdout during the most recent
47
+ # invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
48
+ # any invocation. {docs/behavior.md B-04}[link:../../docs/behavior.md] — the byte
49
+ # content never contains a truncation sentinel; use +#stdout_truncated?+ to
50
+ # observe overflow.
51
+ def stdout
52
+ @stdout_capture.bytes
53
+ end
54
+
55
+ # Returns the bytes the guest wrote to stderr during the most recent
56
+ # invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
57
+ # any invocation. Mirror of +#stdout+.
58
+ def stderr
59
+ @stderr_capture.bytes
60
+ end
61
+
62
+ # Returns +true+ iff stdout capture during the most recent invocation
63
+ # exceeded +stdout_limit+ ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Resets
64
+ # to +false+ at the start of the next invocation
65
+ # ({docs/behavior.md B-03}[link:../../docs/behavior.md]).
66
+ def stdout_truncated?
67
+ @stdout_capture.truncated?
68
+ end
69
+
70
+ # Returns +true+ iff stderr capture during the most recent invocation
71
+ # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
72
+ def stderr_truncated?
73
+ @stderr_capture.truncated?
74
+ end
75
+
76
+ # Returns the +Kobako::Usage+ value object for the most recent
77
+ # invocation ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
78
+ # Carries +wall_time+ (Float seconds the guest export call spent
79
+ # inside wasmtime) and +memory_peak+ (Integer bytes, high-water of
80
+ # the per-invocation +memory.grow+ delta past the entry-time
81
+ # baseline). Returns +Kobako::Usage::EMPTY+ before any invocation;
82
+ # populated on every outcome — including +TrapError+ — so the Host
83
+ # App can read it after rescuing a trap to diagnose budget
84
+ # consumption.
85
+ attr_reader :usage
86
+
87
+ # Build a fresh Sandbox.
88
+ #
89
+ # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
90
+ # gem-bundled +data/kobako.wasm+. The four caps (+stdout_limit+,
91
+ # +stderr_limit+, +timeout+, +memory_limit+) are forwarded verbatim to
92
+ # +Kobako::SandboxOptions+, which owns their DEFAULT fallback and
93
+ # normalisation. The constructed +SandboxOptions+ is exposed as
94
+ # +#options+ and the four caps remain readable directly on Sandbox via
95
+ # +Forwardable+ delegation.
96
+ def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
97
+ timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
98
+ memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
99
+ @wasm_path = wasm_path || Kobako::Runtime.default_path
100
+ @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
101
+ stderr_limit: stderr_limit)
102
+ @handler = Catalog::Handles.new
103
+ @services = Kobako::Catalog::Namespaces.new(handler: @handler)
104
+ @snippets = Catalog::Snippets.new
105
+ @runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
106
+ @options.stdout_limit, @options.stderr_limit)
107
+ install_dispatch_proc!
108
+ reset_invocation_state!
109
+ end
110
+
111
+ # Declare or retrieve the Namespace named +name+ on this Sandbox
112
+ # ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
113
+ # Symbol or String in constant form. Returns the
114
+ # +Kobako::Namespace+.
115
+ #
116
+ # Raises +ArgumentError+ when called after the first invocation, or
117
+ # when +name+ does not match the constant-name pattern.
118
+ def define(name)
119
+ @services.define(name)
120
+ end
121
+
122
+ # Register a snippet on this Sandbox in one of two forms
123
+ # ({docs/behavior.md B-32}[link:../../docs/behavior.md]):
124
+ #
125
+ # * +preload(code: source, name: Name)+ — +source+ is mruby source
126
+ # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. The +name+
127
+ # becomes the snippet's +(snippet:Name)+ backtrace filename and
128
+ # is the dedupe key for E-33.
129
+ # * +preload(binary: bytes)+ — +bytes+ is precompiled RITE
130
+ # bytecode as a +String+. The canonical name, when present,
131
+ # lives in the bytecode's embedded +debug_info+ and is resolved
132
+ # by the guest at load time; the host treats the bytes as
133
+ # opaque. Structural failures
134
+ # ({docs/behavior.md E-37 / E-38}[link:../../docs/behavior.md])
135
+ # surface as +Kobako::BytecodeError+ on the first invocation.
136
+ #
137
+ # Subsequent invocations (+#eval+ or +#run+) replay every registered
138
+ # snippet — in insertion order — against the fresh +mrb_state+
139
+ # before per-invocation source or entrypoint resolution.
140
+ #
141
+ # Returns +self+ to allow chaining.
142
+ #
143
+ # Raises +ArgumentError+ when neither form's keyword set is
144
+ # supplied, when both forms are mixed (e.g., +code:+ and +binary:+
145
+ # together, or +binary:+ paired with +name:+), when +code+ / +bytes+
146
+ # is not a +String+, when +name+ does not match the constant
147
+ # pattern ({docs/behavior.md E-34}[link:../../docs/behavior.md]),
148
+ # when +name+ duplicates an already-registered +code:+ form snippet
149
+ # ({docs/behavior.md E-33}[link:../../docs/behavior.md]), or when
150
+ # called after the first invocation
151
+ # ({docs/behavior.md E-35, B-33}[link:../../docs/behavior.md]).
152
+ def preload(code: nil, name: nil, binary: nil)
153
+ raise ArgumentError, "cannot preload after first Sandbox invocation" if @services.sealed?
154
+
155
+ @snippets.register(code: code, name: name, binary: binary)
156
+ self
157
+ end
158
+
159
+ # Dispatch into a preloaded entrypoint constant
160
+ # ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
161
+ # pre-flight and wire encoding to +Kobako::Transport::Run+ /
162
+ # +Kobako::Transport::Run#encode+: a non-Symbol/String +target+ raises
163
+ # +TypeError+ (E-24), while a +target+ failing the constant pattern
164
+ # (E-25), a forged +Kobako::Handle+ in +args+ / +kwargs+ (E-29), or a
165
+ # non-Symbol +kwargs+ key (E-30) raise +ArgumentError+. The guest
166
+ # resolves +target+ as a top-level constant, calls +#call+ on it with
167
+ # +args+ / +kwargs+, and returns the deserialized result. The first
168
+ # invocation seals the Service registry and snippet table (B-07 /
169
+ # B-33). Runtime errors follow the same three-class taxonomy as +#eval+.
170
+ def run(target, *args, **kwargs)
171
+ run_envelope = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
172
+ invoke!(:run) do
173
+ @runtime.run(@services.encode, @snippets.encode, run_envelope.encode(@handler))
174
+ end
175
+ end
176
+
177
+ # Execute a guest mruby source string in a fresh +mrb_state+
178
+ # ({docs/behavior.md B-02 / B-03 / B-06}[link:../../docs/behavior.md]). +code+ is the
179
+ # mruby source as a UTF-8 String. Returns the deserialized last
180
+ # expression of the source.
181
+ #
182
+ # Source delivery uses the WASI stdin three-frame protocol
183
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
184
+ # Frame 1 carries the msgpack-encoded preamble (Namespace / Member
185
+ # registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
186
+ # Frame 3 carries the snippet table registered via +#preload+ (B-32).
187
+ # Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
188
+ # mandatory-presence — an empty snippet table sends an empty msgpack
189
+ # array, never an absent frame.
190
+ #
191
+ # The first invocation seals the Service registry and snippet table
192
+ # ({docs/behavior.md B-07 / B-33}[link:../../docs/behavior.md]); subsequent
193
+ # +#define+ / +#preload+ calls raise +ArgumentError+.
194
+ #
195
+ # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
196
+ # +Kobako::SandboxError+ when the guest ran to completion but failed
197
+ # (including when +code+ is +nil+ or not a String, or when a preloaded
198
+ # snippet's replay raises — E-36);
199
+ # +Kobako::ServiceError+ on an unrescued Service capability failure.
200
+ def eval(code)
201
+ raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
202
+
203
+ invoke!(:eval) do
204
+ @runtime.eval(@services.encode, code.b, @snippets.encode)
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ # Configure the +Runtime+'s host↔guest dispatch wiring
211
+ # ({docs/behavior.md B-12}[link:../../docs/behavior.md]). Builds a
212
+ # lambda that re-enters the guest via
213
+ # +Runtime#yield_to_active_invocation+ (B-24) and a dispatch +Proc+
214
+ # that routes guest→host calls through the stateless
215
+ # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
216
+ # closure. Both are registered on the +Runtime+ once at construction
217
+ # time so the wasm ext callback can fire without further setup.
218
+ def install_dispatch_proc!
219
+ yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
220
+ @runtime.on_dispatch = lambda do |request_bytes|
221
+ Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
222
+ end
223
+ end
224
+
225
+ # Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
226
+ # B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
227
+ # registries on first call (idempotent) and zeros the per-invocation
228
+ # capability state — capture buffers, truncation predicates, and the
229
+ # +Catalog::Handles+ counter — before the guest runs. The
230
+ # +Catalog::Handles+ itself is held as +@handler+ and never exposed beyond
231
+ # this class: SPEC.md Terminology pins it as "Not exposed to the
232
+ # Host App" (B-19 / B-20 / E-29).
233
+ def begin_invocation!
234
+ @services.seal!
235
+ @handler.reset!
236
+ reset_invocation_state!
237
+ end
238
+
239
+ # Reset all per-invocation observable state to its pre-invocation
240
+ # sentinels — both per-channel captures
241
+ # ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
242
+ # per-last-invocation usage record
243
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
244
+ # +#initialize+ (first-time setup) and +#begin_invocation!+
245
+ # (between-invocation reset) so both paths agree on what
246
+ # "pre-invocation state" means.
247
+ def reset_invocation_state!
248
+ @stdout_capture = Capture::EMPTY
249
+ @stderr_capture = Capture::EMPTY
250
+ @usage = Usage::EMPTY
251
+ end
252
+
253
+ # Read the per-last-invocation +wall_time+ and +memory_peak+ from
254
+ # the ext and wrap them as a +Kobako::Usage+ value object
255
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
256
+ # the +invoke!+ +ensure+ block so the usage record is populated on
257
+ # every outcome — value return, +Kobako::TrapError+ (including
258
+ # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
259
+ # and +Kobako::ServiceError+. On the success path the same figures
260
+ # already arrive via +Snapshot#usage+; on the trap path the Snapshot
261
+ # never reaches Ruby so the ext readout here is the only source.
262
+ #
263
+ # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
264
+ # whose order matches the +Kobako::Usage+ field order; the
265
+ # destructure-then-kwargs handoff below is the explicit
266
+ # positional→keyword conversion point.
267
+ def read_usage!
268
+ wall_time, memory_peak = @runtime.usage
269
+ @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
270
+ end
271
+
272
+ # Pick the +TrapError+ subclass to re-raise based on +err+'s actual
273
+ # class. Cap-trap subclasses
274
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
275
+ # preserve their named identity; everything else collapses to the
276
+ # base +Kobako::TrapError+. The ext already raises the right subclass
277
+ # directly, so this is a pure re-attribution that lets +#invoke!+
278
+ # add the verb prefix without erasing +TimeoutError+ /
279
+ # +MemoryLimitError+.
280
+ def trap_class_for(err)
281
+ case err
282
+ when TimeoutError then TimeoutError
283
+ when MemoryLimitError then MemoryLimitError
284
+ else TrapError
285
+ end
286
+ end
287
+
288
+ # Shared prologue / epilogue + trap-class translator for both
289
+ # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
290
+ # TrapError message so the failing export is identifiable.
291
+ #
292
+ # The yielded block must return a +Kobako::Snapshot+ — i.e. the
293
+ # value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts →
294
+ # Snapshot). The success path unpacks every observable from the
295
+ # Snapshot in one go: +#stdout+ / +#stderr+ pack into +Capture+,
296
+ # +#usage+ packs into +Usage+, +#return_bytes+ feeds +Outcome.decode+.
297
+ # The rescue chain is the single trap-translation boundary —
298
+ # configured-cap paths
299
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
300
+ # surface as named TrapError subclasses; everything else surfaces as
301
+ # the base +TrapError+.
302
+ def invoke!(verb)
303
+ begin_invocation!
304
+ snapshot = yield
305
+ @stdout_capture = snapshot.stdout
306
+ @stderr_capture = snapshot.stderr
307
+ Outcome.decode(snapshot.return_bytes)
308
+ rescue Kobako::TrapError => e
309
+ raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
310
+ ensure
311
+ read_usage!
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Kobako::SandboxOptions — immutable Value Object holding the four
5
+ # per-Sandbox configuration caps ({docs/behavior.md B-01,
6
+ # E-20}[link:../../docs/behavior.md]). Built on the +class X <
7
+ # Data.define(...)+ subclass form (the Steep-friendly shape — see
8
+ # +lib/kobako/outcome/panic.rb+).
9
+ #
10
+ # The +initialize+ method does double duty: it applies DEFAULT fallback
11
+ # for absent values and normalises (timeout to Float seconds,
12
+ # memory_limit to positive Integer bytes) before delegating to Data's
13
+ # +super+. Anything that survives +SandboxOptions.new+ is a wire-ready
14
+ # cap bundle the +Kobako::Runtime+ constructor consumes as-is.
15
+ class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
16
+ # Default wall-clock timeout for a single invocation: 60 seconds
17
+ # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
18
+ DEFAULT_TIMEOUT_SECONDS = 60.0
19
+
20
+ # Default cap on the per-invocation guest linear-memory delta:
21
+ # 1 MiB ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
22
+ # The mruby image's initial allocation and prior invocations'
23
+ # watermark sit outside this budget — see B-01 Notes.
24
+ DEFAULT_MEMORY_LIMIT = 1 << 20
25
+
26
+ # Default per-channel capture ceiling: 1 MiB
27
+ # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
28
+ DEFAULT_OUTPUT_LIMIT = 1 << 20
29
+
30
+ def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
31
+ memory_limit: DEFAULT_MEMORY_LIMIT,
32
+ stdout_limit: nil,
33
+ stderr_limit: nil)
34
+ timeout = normalize_timeout(timeout)
35
+ memory_limit = normalize_memory_limit(memory_limit)
36
+ stdout_limit ||= DEFAULT_OUTPUT_LIMIT
37
+ stderr_limit ||= DEFAULT_OUTPUT_LIMIT
38
+ super
39
+ end
40
+
41
+ private
42
+
43
+ # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
44
+ # to mean the cap is disabled. Any finite non-positive value is
45
+ # rejected — a zero or negative timeout would either fire instantly
46
+ # or never, both of which would surprise callers more than an early
47
+ # +ArgumentError+.
48
+ def normalize_timeout(timeout)
49
+ return nil if timeout.nil?
50
+ raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
51
+
52
+ seconds = timeout.to_f
53
+ raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
54
+
55
+ seconds
56
+ end
57
+
58
+ # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+
59
+ # to mean unbounded. Must be a positive Integer when set; Float or
60
+ # zero/negative values are rejected.
61
+ def normalize_memory_limit(memory_limit)
62
+ return nil if memory_limit.nil?
63
+ unless memory_limit.is_a?(Integer) && memory_limit.positive?
64
+ raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
65
+ end
66
+
67
+ memory_limit
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture"
4
+ require_relative "usage"
5
+
6
+ module Kobako
7
+ # Kobako::Snapshot — per-invocation observable bundle returned from
8
+ # +Kobako::Runtime#eval+ and +#run+.
9
+ #
10
+ # The magnus class (see ext/kobako/src/snapshot.rs) carries seven raw
11
+ # readers: +return_bytes+, +stdout_bytes+, +stdout_truncated+,
12
+ # +stderr_bytes+, +stderr_truncated+, +wall_time+, +memory_peak+. This
13
+ # file reopens the class to add the Ruby-side helpers that pack those
14
+ # raw fields into the user-facing value objects +Kobako::Capture+ and
15
+ # +Kobako::Usage+ — the same shape +Kobako::Sandbox+ exposes to the
16
+ # Host App.
17
+ class Snapshot
18
+ # Wrap the stdout capture pair (+stdout_bytes+, +stdout_truncated+)
19
+ # as a +Kobako::Capture+ value object. {docs/behavior.md
20
+ # B-04}[link:../../docs/behavior.md] — the byte content never carries
21
+ # a truncation sentinel; +#truncated?+ is the only way to observe
22
+ # that the cap was hit.
23
+ def stdout
24
+ Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
25
+ end
26
+
27
+ # Wrap the stderr capture pair as a +Kobako::Capture+ value object.
28
+ # Mirror of +#stdout+.
29
+ def stderr
30
+ Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
31
+ end
32
+
33
+ # Wrap the per-last-invocation usage pair (+wall_time+,
34
+ # +memory_peak+) as a +Kobako::Usage+ value object
35
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
36
+ def usage
37
+ Usage.new(wall_time: wall_time, memory_peak: memory_peak)
38
+ end
39
+ end
40
+ end