cuboid 0.3.6 → 0.4
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.
- checksums.yaml +4 -4
- data/README.md +195 -0
- data/cuboid.gemspec +4 -0
- data/lib/cuboid/application.rb +84 -3
- data/lib/cuboid/mcp/auth.rb +99 -0
- data/lib/cuboid/mcp/core_tools.rb +318 -0
- data/lib/cuboid/mcp/live.rb +166 -0
- data/lib/cuboid/mcp/server.rb +426 -0
- data/lib/cuboid/option_groups/paths.rb +40 -0
- data/lib/cuboid/processes/executables/base.rb +37 -0
- data/lib/cuboid/processes/executables/mcp.rb +20 -0
- data/lib/cuboid/processes/instances.rb +9 -1
- data/lib/cuboid/processes/manager.rb +22 -1
- data/lib/cuboid/rest/server/instance_helpers.rb +21 -70
- data/lib/cuboid/rest/server/routes/instances.rb +1 -3
- data/lib/cuboid/rest/server.rb +1 -1
- data/lib/cuboid/rpc/server/agent.rb +6 -1
- data/lib/cuboid/rpc/server/instance.rb +32 -0
- data/lib/cuboid/server/instance_helpers.rb +131 -0
- data/lib/version +1 -1
- data/spec/cuboid/mcp/auth_spec.rb +179 -0
- data/spec/cuboid/mcp/server_spec.rb +346 -0
- data/spec/cuboid/rest/server_spec.rb +3 -4
- data/spec/support/shared/option_group.rb +11 -1
- metadata +26 -2
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
require 'puma'
|
|
2
|
+
require 'puma/minissl'
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'mcp'
|
|
5
|
+
require 'mcp/server/transports/streamable_http_transport'
|
|
6
|
+
|
|
7
|
+
# json-schema (a transitive dep of `mcp` for MCP::Tool input/output
|
|
8
|
+
# schema validation) emits a one-time deprecation notice at first use
|
|
9
|
+
# unless we opt out of its MultiJson backend. Stdlib JSON is faster
|
|
10
|
+
# and already loaded — no reason to keep MultiJson in the chain.
|
|
11
|
+
require 'json-schema'
|
|
12
|
+
JSON::Validator.use_multi_json = false
|
|
13
|
+
|
|
14
|
+
require_relative 'auth'
|
|
15
|
+
require_relative 'core_tools'
|
|
16
|
+
require_relative 'live'
|
|
17
|
+
require_relative '../server/instance_helpers'
|
|
18
|
+
|
|
19
|
+
module Cuboid
|
|
20
|
+
module MCP
|
|
21
|
+
|
|
22
|
+
# Cuboid's MCP-server framework. Mirrors `Cuboid::Rest::Server`:
|
|
23
|
+
# application gems register tool handlers via `mcp_service_for(name,
|
|
24
|
+
# handler)` on their `Cuboid::Application` subclass; this server
|
|
25
|
+
# mounts one MCP transport per (instance, service) pair under
|
|
26
|
+
# `/instances/:instance/<service>` and proxies tool calls to the
|
|
27
|
+
# resolved engine instance via RPC.
|
|
28
|
+
#
|
|
29
|
+
# Spawnable as `:mcp` via Cuboid::Processes::Manager (see
|
|
30
|
+
# `lib/cuboid/processes/executables/mcp.rb`).
|
|
31
|
+
class Server
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
|
|
35
|
+
# Boot the MCP server.
|
|
36
|
+
#
|
|
37
|
+
# @param [Hash] options
|
|
38
|
+
# @option options [String] :bind IP/host to bind
|
|
39
|
+
# @option options [Integer] :port Port to listen on
|
|
40
|
+
# @option options [String] :name MCP server name advertised to clients
|
|
41
|
+
# @option options [String] :version MCP server version advertised to clients
|
|
42
|
+
# @option options [Hash] :tls Optional TLS — same shape as Rest::Server
|
|
43
|
+
# @option options [Boolean] :stateless Streamable HTTP stateless mode
|
|
44
|
+
def run!( options )
|
|
45
|
+
puma = Puma::Server.new( rack_app( options ) )
|
|
46
|
+
|
|
47
|
+
ssl = configure_listener( puma, options )
|
|
48
|
+
|
|
49
|
+
# Configure the Live registry so its `url_for(token)` can
|
|
50
|
+
# synthesise the loopback URL the engine subprocess will
|
|
51
|
+
# POST live events to. Done after listener configuration so
|
|
52
|
+
# we know the resolved scheme.
|
|
53
|
+
Live.configure(
|
|
54
|
+
bind: options[:bind],
|
|
55
|
+
port: options[:port],
|
|
56
|
+
tls: ssl
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
puts "MCP server listening on " \
|
|
60
|
+
"http#{'s' if ssl}://#{options[:bind]}:#{options[:port]}/mcp"
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
puma.run.join
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
puma.stop( true )
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Build (without booting) the Rack app — exposed for tests
|
|
70
|
+
# (Rack::Test against `rack_app({})`) and for embedders that
|
|
71
|
+
# want to mount MCP under a larger Rack tree.
|
|
72
|
+
def rack_app( options = {} )
|
|
73
|
+
dispatcher = Dispatcher.new(
|
|
74
|
+
name: options[:name],
|
|
75
|
+
version: options[:version],
|
|
76
|
+
stateless: options.fetch( :stateless, false )
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
Rack::Builder.new do
|
|
80
|
+
use Cuboid::MCP::Auth
|
|
81
|
+
run dispatcher
|
|
82
|
+
end.to_app
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def configure_listener( puma_server, options )
|
|
88
|
+
tls = options[:tls]
|
|
89
|
+
|
|
90
|
+
if tls && tls[:private_key] && tls[:certificate]
|
|
91
|
+
ctx = Puma::MiniSSL::Context.new
|
|
92
|
+
ctx.key = tls[:private_key]
|
|
93
|
+
ctx.cert = tls[:certificate]
|
|
94
|
+
|
|
95
|
+
if tls[:ca]
|
|
96
|
+
puts 'CA provided, peer verification enabled.'
|
|
97
|
+
ctx.ca = tls[:ca]
|
|
98
|
+
ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER |
|
|
99
|
+
Puma::MiniSSL::VERIFY_FAIL_IF_NO_PEER_CERT
|
|
100
|
+
else
|
|
101
|
+
puts 'CA missing, peer verification disabled.'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puma_server.binder.add_ssl_listener(
|
|
105
|
+
options[:bind], options[:port], ctx
|
|
106
|
+
)
|
|
107
|
+
true
|
|
108
|
+
else
|
|
109
|
+
puma_server.add_tcp_listener( options[:bind], options[:port] )
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mounts a single MCP transport at `/mcp`. Tools are flattened into
|
|
117
|
+
# one server:
|
|
118
|
+
#
|
|
119
|
+
# * Framework tools (`list_instances`, `spawn_instance`,
|
|
120
|
+
# `kill_instance`) ship from `Cuboid::MCP::CoreTools`.
|
|
121
|
+
# * Application service tools registered via `mcp_service_for` on
|
|
122
|
+
# the `Cuboid::Application` subclass are wrapped to take an
|
|
123
|
+
# `instance_id` argument and are exposed under
|
|
124
|
+
# `<service>_<original_tool_name>` (e.g. `scan_progress`).
|
|
125
|
+
#
|
|
126
|
+
# The wrapper resolves `instance_id` against the shared instance
|
|
127
|
+
# map at call time and forwards the looked-up RPC client to the
|
|
128
|
+
# original tool via `server_context[:instance]` — so existing
|
|
129
|
+
# `MCPProxy.instrumented_call(server_context) { |instance| … }` code
|
|
130
|
+
# works unchanged.
|
|
131
|
+
#
|
|
132
|
+
# Earlier revisions exposed a `/instances/:instance/<service>`
|
|
133
|
+
# second route; that's gone. One endpoint, one session, no
|
|
134
|
+
# runtime URL handoff to the client.
|
|
135
|
+
class Dispatcher
|
|
136
|
+
# InstanceHelpers' @@instances class variable is shared across
|
|
137
|
+
# all includers of the module — so the same map populated by
|
|
138
|
+
# Rest::Server / scheduler-sync / our own SpawnInstance core
|
|
139
|
+
# tool is visible here without any explicit cross-process
|
|
140
|
+
# plumbing.
|
|
141
|
+
include ::Cuboid::Server::InstanceHelpers
|
|
142
|
+
|
|
143
|
+
# `/mcp/live/<token>` matches BEFORE `/mcp` would swallow it
|
|
144
|
+
# in the regex chain — order checks accordingly in `call`.
|
|
145
|
+
LIVE_PATH_RE = %r{\A/mcp/live/(?<token>[A-Za-z0-9_-]+)/?\z}
|
|
146
|
+
MCP_PATH_RE = %r{\A/mcp(?<rest>/.*)?\z}
|
|
147
|
+
|
|
148
|
+
def initialize( name: nil, version: nil, stateless: false )
|
|
149
|
+
@name = name
|
|
150
|
+
@version = version
|
|
151
|
+
@stateless = stateless
|
|
152
|
+
@mutex = Mutex.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def call( env )
|
|
156
|
+
update_from_scheduler
|
|
157
|
+
|
|
158
|
+
path = env['PATH_INFO'].to_s
|
|
159
|
+
|
|
160
|
+
if (m = LIVE_PATH_RE.match( path ))
|
|
161
|
+
handle_live_push( env, m[:token] )
|
|
162
|
+
elsif (m = MCP_PATH_RE.match( path ))
|
|
163
|
+
sub_env = env.dup
|
|
164
|
+
sub_env['PATH_INFO'] = m[:rest].to_s
|
|
165
|
+
sub_env['SCRIPT_NAME'] = "#{env['SCRIPT_NAME']}/mcp"
|
|
166
|
+
transport.call( sub_env )
|
|
167
|
+
else
|
|
168
|
+
not_found( 'route does not match /mcp or /mcp/live/<token>' )
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Forward a single engine-side push (msgpack/json/yaml body) to
|
|
173
|
+
# the MCP session that registered the token. Loopback-only:
|
|
174
|
+
# the engine subprocess pushes from the same host, never from
|
|
175
|
+
# an external network. No auth — the token is the auth.
|
|
176
|
+
def handle_live_push( env, token )
|
|
177
|
+
remote = env['REMOTE_ADDR'].to_s
|
|
178
|
+
unless %w(127.0.0.1 ::1 ::ffff:127.0.0.1).include?( remote )
|
|
179
|
+
return not_found( 'live push must come from loopback' )
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Drain Rack's input. Rack 3 may have already consumed it
|
|
183
|
+
# for known content types; rewind defensively.
|
|
184
|
+
input = env['rack.input']
|
|
185
|
+
input.rewind if input.respond_to?( :rewind )
|
|
186
|
+
body = input.read
|
|
187
|
+
|
|
188
|
+
envelope =
|
|
189
|
+
begin
|
|
190
|
+
Live.decode( env['CONTENT_TYPE'], body )
|
|
191
|
+
rescue => e
|
|
192
|
+
return [ 400, { 'content-type' => 'application/json' },
|
|
193
|
+
[ { error: "could not decode #{env['CONTENT_TYPE']}: #{e.class}" }.to_json ] ]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
ok = Live.deliver( token, envelope )
|
|
197
|
+
return [ 410, { 'content-type' => 'application/json' },
|
|
198
|
+
[ { error: 'live token unknown or session gone' }.to_json ] ] if !ok
|
|
199
|
+
|
|
200
|
+
[ 204, {}, [] ]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def transport
|
|
206
|
+
@mutex.synchronize do
|
|
207
|
+
@transport ||= begin
|
|
208
|
+
mcp_server = ::MCP::Server.new(
|
|
209
|
+
name: @name || application_brand_name || 'cuboid',
|
|
210
|
+
version: @version || application_brand_version || ::Cuboid::VERSION,
|
|
211
|
+
tools: build_tools,
|
|
212
|
+
prompts: build_prompts,
|
|
213
|
+
resources: build_resources
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (read_handler = build_resources_read_handler)
|
|
217
|
+
mcp_server.resources_read_handler( &read_handler )
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
t = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
221
|
+
mcp_server,
|
|
222
|
+
stateless: @stateless
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Hand the transport to Live so `/mcp/live/<token>`
|
|
226
|
+
# pushes can be relayed to the right session as
|
|
227
|
+
# `notifications/cuboid/live` notifications.
|
|
228
|
+
Live.transport = t
|
|
229
|
+
|
|
230
|
+
t
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_tools
|
|
236
|
+
tools = ::Cuboid::MCP::CoreTools.tools.dup
|
|
237
|
+
|
|
238
|
+
# App-level top-level tools registered via
|
|
239
|
+
# `Cuboid::Application.mcp_app_tool` — ride the same
|
|
240
|
+
# routing as CoreTools (no instance_id requirement).
|
|
241
|
+
app = ::Cuboid::Application.application
|
|
242
|
+
if app.respond_to?( :mcp_app_tools )
|
|
243
|
+
tools.concat( app.mcp_app_tools )
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
mcp_services.each do |service_name, handler|
|
|
247
|
+
Array( handler.tools ).each do |tool_class|
|
|
248
|
+
tools << Dispatcher.wrap_service_tool( service_name, tool_class )
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
tools
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Application MCP-service handlers may optionally expose
|
|
256
|
+
# `prompts` (canned conversation templates the client can
|
|
257
|
+
# surface to a user — e.g. "scan this URL with the quick-scan
|
|
258
|
+
# preset and summarise findings") and `resources` (read-only
|
|
259
|
+
# documents — glossary, option DSL reference, presets — that
|
|
260
|
+
# an LLM client pulls on demand instead of needing them
|
|
261
|
+
# bundled into every tool description).
|
|
262
|
+
#
|
|
263
|
+
# Both are additive: a handler that doesn't define `prompts` /
|
|
264
|
+
# `resources` is silently treated as exposing none.
|
|
265
|
+
def build_prompts
|
|
266
|
+
mcp_services.values.flat_map do |handler|
|
|
267
|
+
handler.respond_to?( :prompts ) ? Array( handler.prompts ) : []
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def build_resources
|
|
272
|
+
mcp_services.values.flat_map do |handler|
|
|
273
|
+
handler.respond_to?( :resources ) ? Array( handler.resources ) : []
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Returns a Proc the MCP::Server uses for `resources/read`. The
|
|
278
|
+
# proc walks every handler that implements `read_resource(uri)`
|
|
279
|
+
# and returns the first non-nil match, normalised to the
|
|
280
|
+
# `Array<Resource::Contents-as-Hash>` shape the spec expects.
|
|
281
|
+
# Nil if no handler implements the protocol — letting the
|
|
282
|
+
# gem's default no-content responder do its thing.
|
|
283
|
+
def build_resources_read_handler
|
|
284
|
+
handlers = mcp_services.values.select { |h| h.respond_to?( :read_resource ) }
|
|
285
|
+
return nil if handlers.empty?
|
|
286
|
+
|
|
287
|
+
->( params ) {
|
|
288
|
+
uri = params[:uri].to_s
|
|
289
|
+
handlers.each do |h|
|
|
290
|
+
content = h.read_resource( uri )
|
|
291
|
+
next if content.nil?
|
|
292
|
+
return Array( content ).map { |c|
|
|
293
|
+
c.respond_to?( :to_h ) ? c.to_h : c
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
[]
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Wraps an application-supplied `MCP::Tool` subclass so it can
|
|
301
|
+
# live in the unified `/mcp` server. The wrapper:
|
|
302
|
+
#
|
|
303
|
+
# * exposes `<service>_<original_tool_name>` as its name
|
|
304
|
+
# * augments the input schema with a required `instance_id`
|
|
305
|
+
# string (the only piece the client must supply that the
|
|
306
|
+
# old per-instance routing carried in the URL)
|
|
307
|
+
# * resolves `instance_id` to a registered RPC client at call
|
|
308
|
+
# time and hands it to the wrapped tool via
|
|
309
|
+
# `server_context[:instance]` — preserving the original
|
|
310
|
+
# `instrumented_call(server_context) { |instance| … }`
|
|
311
|
+
# contract.
|
|
312
|
+
#
|
|
313
|
+
# Class method (not instance) so the registry that owns the
|
|
314
|
+
# wrapper class doesn't carry hidden state across requests.
|
|
315
|
+
def self.wrap_service_tool( service_name, tool_class )
|
|
316
|
+
base_schema = tool_class.input_schema.to_h
|
|
317
|
+
base_props = base_schema[:properties] || {}
|
|
318
|
+
base_required = (base_schema[:required] || []).map( &:to_s )
|
|
319
|
+
|
|
320
|
+
new_props = base_props.merge(
|
|
321
|
+
instance_id: {
|
|
322
|
+
type: 'string',
|
|
323
|
+
description: 'Engine-instance handle returned by `spawn_instance` / present in `list_instances`.'
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
new_required = (base_required + ['instance_id']).uniq
|
|
327
|
+
|
|
328
|
+
wrapped_name = "#{service_name}_#{tool_class.tool_name}"
|
|
329
|
+
wrapped_desc = tool_class.description
|
|
330
|
+
|
|
331
|
+
klass = Class.new( ::MCP::Tool )
|
|
332
|
+
klass.tool_name wrapped_name
|
|
333
|
+
klass.description wrapped_desc
|
|
334
|
+
klass.input_schema(
|
|
335
|
+
properties: new_props,
|
|
336
|
+
required: new_required
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Pass the original tool's output_schema through to the
|
|
340
|
+
# wrapper so a typed-output client sees the same contract
|
|
341
|
+
# whether the tool ships from CoreTools or from a service.
|
|
342
|
+
if (out = tool_class.output_schema_value)
|
|
343
|
+
klass.output_schema( out.to_h )
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
klass.define_singleton_method( :call ) do |server_context: nil, instance_id: nil, **kwargs|
|
|
347
|
+
instance = ::Cuboid::Server::InstanceHelpers
|
|
348
|
+
.instances[instance_id]
|
|
349
|
+
if instance.nil?
|
|
350
|
+
next ::MCP::Tool::Response.new(
|
|
351
|
+
[{ type: 'text', text: "unknown instance: #{instance_id.inspect}" }],
|
|
352
|
+
error: true
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# MCPProxy reads `server_context[:instance]` etc. via
|
|
357
|
+
# Hash-style access, which `MCP::ServerContext` forwards
|
|
358
|
+
# to the underlying context Hash via method_missing —
|
|
359
|
+
# so passing a plain Hash here keeps the proxy contract.
|
|
360
|
+
ctx = {
|
|
361
|
+
instance: instance,
|
|
362
|
+
instance_id: instance_id,
|
|
363
|
+
service: service_name
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
tool_class.call( server_context: ctx, **kwargs )
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
klass
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def mcp_services
|
|
373
|
+
app = ::Cuboid::Application.application
|
|
374
|
+
return {} if app.nil?
|
|
375
|
+
return {} if !app.respond_to?( :mcp_services )
|
|
376
|
+
app.mcp_services
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Identity advertised in MCP `serverInfo` defaults to the running
|
|
380
|
+
# Cuboid::Application's top-level namespace — preferring its
|
|
381
|
+
# branded `shortname` / `version` methods over the raw module
|
|
382
|
+
# name + VERSION constant. Falls back to 'cuboid' /
|
|
383
|
+
# Cuboid::VERSION when no application is registered (bare
|
|
384
|
+
# framework / specs). Explicit `name:` / `version:` passed to
|
|
385
|
+
# `run!` always win.
|
|
386
|
+
def application_brand
|
|
387
|
+
return @application_brand if defined?( @application_brand )
|
|
388
|
+
|
|
389
|
+
app = ::Cuboid::Application.application
|
|
390
|
+
@application_brand =
|
|
391
|
+
if app && (ns = app.name.to_s.split( '::' ).first) && !ns.empty?
|
|
392
|
+
mod = Object.const_get( ns )
|
|
393
|
+
|
|
394
|
+
name =
|
|
395
|
+
if mod.respond_to?( :shortname )
|
|
396
|
+
mod.shortname.to_s
|
|
397
|
+
else
|
|
398
|
+
ns
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
version =
|
|
402
|
+
if mod.respond_to?( :version )
|
|
403
|
+
mod.version.to_s
|
|
404
|
+
elsif mod.const_defined?( :VERSION )
|
|
405
|
+
mod::VERSION
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
{ name: name, version: version }
|
|
409
|
+
else
|
|
410
|
+
{ name: nil, version: nil }
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def application_brand_name; application_brand[:name]; end
|
|
415
|
+
def application_brand_version; application_brand[:version]; end
|
|
416
|
+
|
|
417
|
+
def not_found( message )
|
|
418
|
+
body = { jsonrpc: '2.0', error: { code: -32601, message: message } }.to_json
|
|
419
|
+
[ 404, { 'content-type' => 'application/json' }, [body] ]
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -132,6 +132,13 @@ class Paths < Cuboid::OptionGroup
|
|
|
132
132
|
def tmpdir
|
|
133
133
|
return @tmpdir if @tmpdir
|
|
134
134
|
|
|
135
|
+
# Reap dirs left over from prior Cuboid runs that were
|
|
136
|
+
# SIGKILL'd / segfaulted / had their host crash — anything
|
|
137
|
+
# that bypassed the `at_exit` block below. Done at boot
|
|
138
|
+
# rather than on a timer so the user doesn't have to think
|
|
139
|
+
# about it.
|
|
140
|
+
sweep_orphaned_tmpdirs
|
|
141
|
+
|
|
135
142
|
dir = tmp_dir_for( Process.pid )
|
|
136
143
|
|
|
137
144
|
FileUtils.mkdir_p dir
|
|
@@ -146,6 +153,39 @@ class Paths < Cuboid::OptionGroup
|
|
|
146
153
|
"#{os_tmpdir}/#{TMPDIR_SUFFIX}#{pid}"
|
|
147
154
|
end
|
|
148
155
|
|
|
156
|
+
# Sweep `<os_tmpdir>/Cuboid_<pid>` dirs whose pid is no longer
|
|
157
|
+
# alive. Best-effort: a dir we can't probe / remove (permission,
|
|
158
|
+
# racing peer boot, etc.) is silently skipped — never block boot
|
|
159
|
+
# on cleanup. Ignores `Cuboid_Snapshot_<token>` and any other
|
|
160
|
+
# non-pid-suffixed siblings.
|
|
161
|
+
def sweep_orphaned_tmpdirs
|
|
162
|
+
Dir.glob( "#{os_tmpdir}/#{TMPDIR_SUFFIX}*" ).each do |path|
|
|
163
|
+
next if !File.directory?( path )
|
|
164
|
+
|
|
165
|
+
suffix = File.basename( path ).sub( TMPDIR_SUFFIX, '' )
|
|
166
|
+
next if suffix !~ /\A\d+\z/
|
|
167
|
+
|
|
168
|
+
pid = suffix.to_i
|
|
169
|
+
begin
|
|
170
|
+
Process.kill( 0, pid )
|
|
171
|
+
# Still alive — leave it.
|
|
172
|
+
next
|
|
173
|
+
rescue Errno::ESRCH
|
|
174
|
+
# No such process — orphan; fall through to cleanup.
|
|
175
|
+
rescue Errno::EPERM
|
|
176
|
+
# Alive but owned by another user — leave it.
|
|
177
|
+
next
|
|
178
|
+
rescue
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
FileUtils.rm_rf( path )
|
|
183
|
+
rescue
|
|
184
|
+
# Anything unexpected (filesystem race, transient I/O
|
|
185
|
+
# error) — skip this entry, keep going.
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
149
189
|
def config
|
|
150
190
|
self.class.config
|
|
151
191
|
end
|
|
@@ -51,4 +51,41 @@ def puts_stderr( str )
|
|
|
51
51
|
rescue
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Parent-death watchdog. If the spawning process dies (rspec
|
|
55
|
+
# crashed / Ctrl-C'd before the after-hooks could run, MCP server
|
|
56
|
+
# SIGKILL'd, host shell exited) the engine subprocess gets
|
|
57
|
+
# reparented to init and would otherwise survive forever. Poll the
|
|
58
|
+
# **original** spawn-time parent pid (`ppid`) — not Process.ppid,
|
|
59
|
+
# which goes to 1 the moment we daemonise — and force a clean exit
|
|
60
|
+
# the moment ESRCH says the parent is gone.
|
|
61
|
+
#
|
|
62
|
+
# Polling cadence is 5 s: cheap, fires before tmpdirs accumulate.
|
|
63
|
+
# Skipped when no `ppid` was stamped on the options (manual
|
|
64
|
+
# invocations / tests that don't go through Manager.spawn).
|
|
65
|
+
PARENT_WATCHDOG_INTERVAL = 5.0
|
|
66
|
+
|
|
67
|
+
if ppid && ppid > 0
|
|
68
|
+
Thread.new do
|
|
69
|
+
loop do
|
|
70
|
+
sleep PARENT_WATCHDOG_INTERVAL
|
|
71
|
+
break if !parent_alive?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parent's gone. Try a graceful exit first so at_exit
|
|
75
|
+
# handlers fire (Cuboid_<pid> tmpdir cleanup, the live
|
|
76
|
+
# plugin's `exited` push, etc.); fall back to SIGKILL ourselves
|
|
77
|
+
# if a non-daemon Application thread refuses to release the
|
|
78
|
+
# runtime.
|
|
79
|
+
Thread.new do
|
|
80
|
+
sleep 5
|
|
81
|
+
Process.kill( 'KILL', Process.pid ) rescue nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
main = Thread.main
|
|
85
|
+
if main && main.alive? && main != Thread.current
|
|
86
|
+
main.raise( SystemExit.new( 0 ) ) rescue nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
54
91
|
load ARGV.pop
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Spawnable as `:mcp` via Cuboid::Processes::Manager — mirror of
|
|
2
|
+
# `executables/rest_service.rb`. Boots Cuboid::MCP::Server on the
|
|
3
|
+
# RPC-port/address from Cuboid::Options so the operator can spawn an
|
|
4
|
+
# MCP server alongside REST/agent/scheduler with the same option
|
|
5
|
+
# plumbing.
|
|
6
|
+
require Options.paths.lib + 'mcp/server'
|
|
7
|
+
|
|
8
|
+
# Fully qualified — the `mcp` gem also exposes a top-level `MCP::Server`
|
|
9
|
+
# (different class entirely), and `include Cuboid` in executables/base.rb
|
|
10
|
+
# would otherwise hide that disambiguation.
|
|
11
|
+
Cuboid::MCP::Server.run!(
|
|
12
|
+
bind: Options.rpc.server_address,
|
|
13
|
+
port: Options.rpc.server_port,
|
|
14
|
+
|
|
15
|
+
tls: {
|
|
16
|
+
ca: Options.rpc.ssl_ca,
|
|
17
|
+
private_key: Options.rpc.server_ssl_private_key,
|
|
18
|
+
certificate: Options.rpc.server_ssl_certificate
|
|
19
|
+
}
|
|
20
|
+
)
|
|
@@ -66,6 +66,7 @@ class Instances
|
|
|
66
66
|
fork = options.delete(:fork)
|
|
67
67
|
|
|
68
68
|
daemonize = options.delete(:daemonize)
|
|
69
|
+
detached = options.delete(:detached)
|
|
69
70
|
port_range = options.delete( :port_range )
|
|
70
71
|
|
|
71
72
|
options[:ssl] ||= {
|
|
@@ -100,7 +101,14 @@ class Instances
|
|
|
100
101
|
url = "#{options[:rpc][:server_address]}:#{options[:rpc][:server_port]}"
|
|
101
102
|
end
|
|
102
103
|
|
|
103
|
-
pid = Manager.spawn(
|
|
104
|
+
pid = Manager.spawn(
|
|
105
|
+
:instance,
|
|
106
|
+
options: options,
|
|
107
|
+
token: token,
|
|
108
|
+
fork: fork,
|
|
109
|
+
daemonize: daemonize,
|
|
110
|
+
detached: detached
|
|
111
|
+
)
|
|
104
112
|
|
|
105
113
|
System.slots.use pid
|
|
106
114
|
|
|
@@ -58,6 +58,16 @@ class Manager
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
rescue Timeout::Error
|
|
61
|
+
# SIGTERM didn't take in 10s — escalate. Ruby's default TERM
|
|
62
|
+
# handler only raises SignalException on the main thread; if
|
|
63
|
+
# the main thread already exited but a non-daemon Application
|
|
64
|
+
# thread is keeping the runtime up (audit workers, browser
|
|
65
|
+
# cluster manager, etc.) then no amount of TERMs will land.
|
|
66
|
+
# Without this escalation the spec suite leaks engine
|
|
67
|
+
# subprocesses every time a kill_instance / killall fails to
|
|
68
|
+
# land cleanly.
|
|
69
|
+
Process.kill( 'KILL', pid ) rescue nil
|
|
70
|
+
@pids.delete pid
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
def find( bin )
|
|
@@ -212,7 +222,18 @@ class Manager
|
|
|
212
222
|
spawn_options[:out] = stdout if stdout
|
|
213
223
|
spawn_options[:err] = stderr if stderr
|
|
214
224
|
|
|
215
|
-
|
|
225
|
+
# `:detached` decouples "child outlives spawner" from
|
|
226
|
+
# `:daemonize`, which only controls whether THIS thread
|
|
227
|
+
# `waitpid`s. MCP and the spec helpers spawn with
|
|
228
|
+
# `daemonize: true` but want the child tethered (die with
|
|
229
|
+
# parent); only Agent-managed instances genuinely want to
|
|
230
|
+
# outlive their spawner. Default = tethered; Agent's
|
|
231
|
+
# `spawn_instance` opts out by passing `detached: true`.
|
|
232
|
+
# base.rb's parent-death watchdog reads `$options[:ppid]`;
|
|
233
|
+
# a missing ppid means "no tether" and the watchdog
|
|
234
|
+
# short-circuits.
|
|
235
|
+
detached = options.delete( :detached )
|
|
236
|
+
options[:ppid] = Process.pid if !detached
|
|
216
237
|
options[:tmpdir] = Options.paths.tmpdir
|
|
217
238
|
|
|
218
239
|
cuboid_options = Options.dup.update( options.delete(:options) || {} ).to_h
|