canopus 0.5.0 → 0.6.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.
@@ -0,0 +1,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "net/http"
7
+ require "open3"
8
+ require "tempfile"
9
+ require "timeout"
10
+ require "uri"
11
+
12
+ module Canopus
13
+ module Plugins
14
+ # Canopus vocabulary on top of the generic out-of-process host.
15
+ class Host
16
+ API_VERSION = 2
17
+
18
+ attr_reader :runtime
19
+
20
+ def initialize(workspace, sandbox: true, limits: {})
21
+ require_gienah
22
+ @workspace = workspace
23
+ @sandbox = sandbox
24
+ @runtime = Gienah::Host.new(api_version: API_VERSION, sandbox: sandbox, limits: limits)
25
+ @surfaces = {}
26
+ @subscriptions = {}
27
+ @decorations = {}
28
+ @completion_sources = {}
29
+ @status_items = {}
30
+ @storage_lock = Mutex.new
31
+ @activation_threads = {}
32
+ @subscribed_instances = {}
33
+ @selection_subscriptions = {}
34
+ @selection_threads = {}
35
+ @event_lock = Mutex.new
36
+ register_buffer_api
37
+ register_workspace_api
38
+ register_ui_api
39
+ @runtime.on_contribution { |id, contributes| register_contributions(id, contributes) }
40
+ @workspace_event_subscription = @workspace.on_plugin_event { |method, payload| handle_workspace_event(method, payload) }
41
+ @runtime.on_error do |error, instance|
42
+ cleanup_instance(instance.id) if instance&.state == :failed
43
+ @workspace.message = error.message
44
+ end
45
+ end
46
+
47
+ def discover(directories) = @runtime.discover(directories)
48
+ def add(manifest) = @runtime.add(manifest)
49
+
50
+ def activate(id, reason:)
51
+ raise PermissionDenied, "workspace is not trusted" unless @workspace.trust.trusted?
52
+
53
+ @runtime.activate(id, reason: reason)
54
+ end
55
+
56
+ def deactivate(id)
57
+ cleanup_instance(id)
58
+ @runtime.deactivate(id)
59
+ end
60
+ def instances = @runtime.instances
61
+ def shutdown
62
+ @activation_threads.values.each { |thread| thread.kill unless thread.equal?(Thread.current) }
63
+ @activation_threads.clear
64
+ @workspace_event_subscription&.detach
65
+ @selection_threads.each_value { |thread| thread.kill unless thread.equal?(Thread.current) }
66
+ @selection_threads.clear
67
+ @selection_subscriptions.each_value { |_editor, subscription| subscription.detach }
68
+ @selection_subscriptions.clear
69
+ @subscriptions.each_value(&:detach)
70
+ @subscriptions.clear
71
+ @subscribed_instances.clear
72
+ @runtime.shutdown
73
+ end
74
+
75
+ def status_items
76
+ @status_items.dup
77
+ end
78
+
79
+ private
80
+
81
+ def require_gienah
82
+ path = ENV["GIENAH_PATH"]
83
+ if path
84
+ require File.expand_path("lib/gienah", File.expand_path(path))
85
+ else
86
+ require "gienah"
87
+ end
88
+ end
89
+
90
+ def expose(name, capability: nil, &handler)
91
+ @runtime.expose(name, capability: capability) { |instance, params| handler.call(instance, normalize_params(params)) }
92
+ end
93
+
94
+ def register_buffer_api
95
+ expose("buffer/text", capability: "buffer.read") do |_instance, params|
96
+ buffer = current_buffer
97
+ range = params["range"]
98
+ unless range
99
+ text = buffer.text
100
+ raise Error, "buffer text exceeds 1 MiB; request a range" if text.bytesize > Plugins::BUFFER_CONTEXT_LIMIT
101
+ next text
102
+ end
103
+ first = Integer(range.fetch("start"))
104
+ last = Integer(range.fetch("end"))
105
+ raise ArgumentError, "invalid buffer text range" unless first >= 0 && last >= first && last <= buffer.rope.bytesize
106
+ buffer.text.byteslice(first, last - first).to_s
107
+ end
108
+ expose("buffer/info", capability: "buffer.read") do |_instance, _params|
109
+ buffer = current_buffer
110
+ {"path" => buffer.path, "version" => buffer.version, "bytes" => buffer.rope.bytesize, "read_only" => buffer.read_only}
111
+ end
112
+ expose("buffer/selection", capability: "buffer.read") do |_instance, _params|
113
+ @workspace.editor&.selections.to_a.map { |selection| {"anchor" => selection.anchor, "head" => selection.head} }
114
+ end
115
+ expose("buffer/edit", capability: "buffer.edit") do |_instance, params|
116
+ buffer = current_buffer
117
+ expected = params["version"]
118
+ raise Error, "buffer version is required" unless expected.is_a?(Integer) && expected == buffer.version
119
+ changes = Array(params["changes"])
120
+ raise ArgumentError, "changes must be an Array" unless changes.all? { |change| change.is_a?(Hash) }
121
+ edits = changes.map do |change|
122
+ first = change.fetch("start")
123
+ last = change.fetch("end")
124
+ text = change.fetch("text")
125
+ unless first.is_a?(Integer) && last.is_a?(Integer) && first >= 0 && last >= first && last <= buffer.rope.bytesize && text.is_a?(String)
126
+ raise ArgumentError, "invalid buffer edit"
127
+ end
128
+ [first...last, text]
129
+ end
130
+ buffer.edit(edits, kind: :plugin)
131
+ {"version" => buffer.version}
132
+ end
133
+ expose("buffer/subscribe", capability: "buffer.read") do |instance, params|
134
+ buffer = current_buffer
135
+ requested_uri = params["uri"]
136
+ actual_uri = buffer.path && Sadr::Protocol.uri(buffer.path)
137
+ raise ArgumentError, "buffer URI does not match the active buffer" if requested_uri && requested_uri != actual_uri
138
+ key = [instance.id, buffer.object_id]
139
+ @subscribed_instances[instance.id] = instance
140
+ @subscriptions[key] ||= buffer.on_edit do
141
+ notify_buffer_event(instance, buffer, "buffer/didChange", buffer_change_params(buffer))
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ attach_selection_listener(@workspace.editor) if @workspace.editor&.buffer.equal?(buffer)
146
+ {"version" => buffer.version}
147
+ end
148
+ end
149
+
150
+ def register_workspace_api
151
+ expose("workspace/root", capability: "workspace.read") { |_instance, _params| @workspace.root }
152
+ expose("workspace/files", capability: "workspace.read") { |_instance, _params| @workspace.files }
153
+ expose("workspace/open", capability: "ui.command") { |_instance, params| @workspace.open(params.fetch("path")).path }
154
+ expose("workspace/search", capability: "workspace.read") do |_instance, params|
155
+ query = String(params.fetch("query"))
156
+ raise ArgumentError, "search query must not be empty" if query.empty?
157
+ @workspace.files.filter_map do |path|
158
+ next unless File.file?(path)
159
+ next unless File.read(path, 1 << 20, encoding: "UTF-8").include?(query)
160
+ path
161
+ rescue ArgumentError, EncodingError
162
+ nil
163
+ end.first(1_000)
164
+ end
165
+ expose("workspace/notify") { |_instance, params| @workspace.notify(params.fetch("text")); nil }
166
+ expose("storage/get") do |instance, params|
167
+ storage_read(instance.id).fetch(String(params.fetch("key")), nil)
168
+ end
169
+ expose("storage/set") do |instance, params|
170
+ key = storage_key(params.fetch("key"))
171
+ value = params.fetch("value")
172
+ encoded = JSON.generate(value)
173
+ raise ArgumentError, "storage value exceeds 1 MiB" if encoded.bytesize > (1 << 20)
174
+ storage_update(instance.id) { |values| values[key] = value }
175
+ nil
176
+ end
177
+ expose("process/exec") do |instance, params|
178
+ raise PermissionDenied, "plugin requires process.exec" unless instance.granted?("process.exec")
179
+ command = params.fetch("command")
180
+ raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty? && command.all? { |part| part.is_a?(String) && !part.empty? && !part.include?("\0") }
181
+ timeout_ms = Integer(params.fetch("timeout_ms", 5_000))
182
+ raise ArgumentError, "timeout_ms must be between 1 and 30000" unless timeout_ms.between?(1, 30_000)
183
+ output, error, status = execute_process(command, instance, timeout_ms / 1_000.0)
184
+ {"stdout" => output.byteslice(0, 1 << 20).to_s, "stderr" => error.byteslice(0, 1 << 20).to_s,
185
+ "status" => status.exitstatus, "signaled" => status.signaled?}
186
+ end
187
+ expose("net/fetch") do |instance, params|
188
+ uri = URI(String(params.fetch("url")))
189
+ raise ArgumentError, "HTTP or HTTPS URL required" unless %w[http https].include?(uri.scheme) && uri.host
190
+ raise PermissionDenied, "plugin requires net:#{uri.host}" unless instance.granted?("net:#{uri.host}")
191
+ body = +""
192
+ status = nil
193
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
194
+ http.request_get(uri.request_uri) do |response|
195
+ status = response.code.to_i
196
+ response.read_body do |chunk|
197
+ raise Error, "plugin HTTP response exceeds 1 MiB" if body.bytesize + chunk.bytesize > (1 << 20)
198
+ body << chunk
199
+ end
200
+ end
201
+ end
202
+ {"status" => status, "body" => body}
203
+ end
204
+ expose("ui/status", capability: "ui.statusbar") do |instance, params|
205
+ id = String(params.fetch("id"))
206
+ raise ArgumentError, "invalid status item id" unless id.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
207
+ key = [instance.id, id]
208
+ if params["remove"]
209
+ @status_items.delete(key)
210
+ @workspace.plugin_status_items.delete(key) if @workspace.respond_to?(:plugin_status_items)
211
+ else
212
+ text = String(params.fetch("text"))
213
+ raise ArgumentError, "status item text is too long" if text.bytesize > 1_024
214
+ @status_items[key] = {text: text, priority: Integer(params.fetch("priority", 0)), command: params["command"]}
215
+ @workspace.plugin_status_items[key] = @status_items[key] if @workspace.respond_to?(:plugin_status_items)
216
+ end
217
+ @workspace.window&.request_frame
218
+ nil
219
+ end
220
+ expose("ui/quick_pick", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:quick_pick, params) }
221
+ expose("ui/input", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:input, params) }
222
+ expose("ui/confirm", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:confirm, params) }
223
+ expose("lsp/configure", capability: "process.exec") do |instance, params|
224
+ language = params.fetch("language")
225
+ command = params.fetch("command")
226
+ raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty? && command.all?(String)
227
+ @workspace.configure_plugin_language_server(instance.id, language, command)
228
+ end
229
+ expose("language/register", capability: "language.define") do |_instance, params|
230
+ @workspace.register_language(params.fetch("name"), extensions: Array(params.fetch("extensions")),
231
+ lexer: params.fetch("lexer", "plaintext"), comment: params.fetch("comment", "#"), servers: Array(params.fetch("servers", [])))
232
+ nil
233
+ end
234
+ end
235
+
236
+ def register_ui_api
237
+ expose("ui/render", capability: "ui.panel") do |instance, params|
238
+ surface = surface_for(instance.id, params.fetch("panel"))
239
+ surface.replace(params.fetch("tree"))
240
+ @workspace.window&.request_frame
241
+ nil
242
+ end
243
+ expose("ui/patch", capability: "ui.panel") do |instance, params|
244
+ surface_for(instance.id, params.fetch("panel")).apply(params.fetch("patches"))
245
+ @workspace.window&.request_frame
246
+ nil
247
+ end
248
+ expose("decoration/publish", capability: "ui.decoration") do |instance, params|
249
+ source = decoration_source(instance.id, params.fetch("source"))
250
+ items = Array(params.fetch("items"))
251
+ @decorations[source] = items
252
+ @workspace.decorations.register(source) { |buffer, _rows, _context| decoration_items(items, source, buffer) }
253
+ @workspace.decorations.invalidate(source)
254
+ source.to_s
255
+ end
256
+ expose("decoration/clear", capability: "ui.decoration") do |instance, params|
257
+ source = decoration_source(instance.id, params.fetch("source"))
258
+ @decorations.delete(source)
259
+ @workspace.decorations.unregister(source)
260
+ nil
261
+ end
262
+ expose("completion/register", capability: "completion.provide") do |instance, params|
263
+ source = completion_source(instance.id, params.fetch("source"))
264
+ unless @completion_sources.key?(source)
265
+ @completion_sources[source] = true
266
+ @workspace.providers.register_completion(source, priority: Integer(params.fetch("priority", 0))) do |buffer, offset, context|
267
+ response = instance.call("completion/provide", {"offset" => offset, "version" => buffer.version, "context" => context}).await(timeout: 0.2)
268
+ Array(response).map { |item| completion_item(item, source) }
269
+ end
270
+ end
271
+ source.to_s
272
+ end
273
+ end
274
+
275
+ def register_contributions(id, contributes)
276
+ Array(contributes["commands"]).each do |entry|
277
+ next unless entry.is_a?(Hash) && entry["id"] && entry["title"]
278
+
279
+ @workspace.register_action(entry["id"], description: entry["title"]) do
280
+ instance = activate(id, reason: "onCommand:#{entry['id']}")
281
+ instance&.call(entry["id"], {}).then { |_value, error| @workspace.message = error.message if error }
282
+ end
283
+ end
284
+ Array(contributes["panels"]).each do |entry|
285
+ next unless entry.is_a?(Hash) && entry["id"]
286
+
287
+ side = entry.fetch("dock", "right").to_sym
288
+ panel_id = entry["id"]
289
+ title = String(entry.fetch("title", panel_id))
290
+ @workspace.register_panel(panel_id, title: title, side: side, cache: false) { panel_element(id, panel_id) }
291
+ end
292
+ end
293
+
294
+ def surface_for(plugin_id, panel_id)
295
+ key = [plugin_id.to_s, panel_id.to_s]
296
+ @surfaces[key] ||= Zaniah::Describe::Surface.new(
297
+ vocabulary: Vocabulary.build,
298
+ on_event: ->(event_id, payload) {
299
+ instance = @runtime.instances.find { |candidate| candidate.id == plugin_id }
300
+ instance&.notify("ui/event", {"panel" => panel_id, "id" => event_id, "payload" => payload})
301
+ }
302
+ )
303
+ end
304
+
305
+ def panel_element(plugin_id, panel_id)
306
+ instance = @runtime.instances.find { |candidate| candidate.id == plugin_id }
307
+ unless instance
308
+ activate_panel_async(plugin_id, panel_id)
309
+ return Zaniah::Div.new.flex_col.gap(1).children([Zaniah::UI::Badge.new(plugin_id.to_s), Zaniah::Text.new("Loading #{panel_id}…")])
310
+ end
311
+ surface = surface_for(plugin_id, panel_id)
312
+ element = surface.element || Zaniah::Text.new("Loading #{panel_id}…")
313
+ Zaniah::Div.new.flex_col.gap(1).children([Zaniah::UI::Badge.new(instance.manifest.name), element])
314
+ rescue StandardError => error
315
+ @workspace.message = error.message
316
+ Zaniah::Text.new("Plugin unavailable")
317
+ end
318
+
319
+ def decoration_source(plugin_id, source)
320
+ value = String(source)
321
+ raise ArgumentError, "invalid decoration source" unless value.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
322
+ "plugin_#{plugin_id}_#{value}".to_sym
323
+ end
324
+
325
+ def decoration_items(items, source, buffer)
326
+ items.map do |item|
327
+ raise ArgumentError, "decoration item must be an object" unless item.is_a?(Hash)
328
+ range = if item["range"]
329
+ first = Integer(item["range"].fetch("start"))
330
+ last = Integer(item["range"].fetch("end"))
331
+ raise ArgumentError, "decoration range is outside the buffer" unless first >= 0 && last >= first && last <= buffer.rope.bytesize
332
+ first...last
333
+ end
334
+ Decoration::Item.new(item.fetch("kind").to_sym, range, item["row"], item["content"], item["style"],
335
+ item.fetch("priority", 0), source, nil)
336
+ end
337
+ end
338
+
339
+ def completion_source(plugin_id, source)
340
+ value = String(source)
341
+ raise ArgumentError, "invalid completion source" unless value.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
342
+ "plugin_#{plugin_id}_#{value}".to_sym
343
+ end
344
+
345
+ def completion_item(item, source)
346
+ raise ArgumentError, "completion item must be an object" unless item.is_a?(Hash)
347
+ Provider::Completion.new(item.fetch("label"), item["insert_text"], item["kind"], item["detail"], item["documentation"],
348
+ item["sort_text"], item["filter_text"], [], source)
349
+ end
350
+
351
+ def handle_workspace_event(method, payload)
352
+ case method
353
+ when "buffer/didOpen", "buffer/didSave"
354
+ attach_selection_listener(@workspace.editor) if @workspace.editor&.buffer.equal?(payload)
355
+ notify_buffer_event(nil, payload, method, buffer_event_params(payload))
356
+ when "buffer/didClose"
357
+ notify_buffer_event(nil, payload, method, buffer_event_params(payload))
358
+ detach_buffer(payload)
359
+ when "workspace/didChangeFiles", "settings/didChange"
360
+ @subscribed_instances.values.dup.each do |instance|
361
+ instance.notify(method, payload)
362
+ rescue StandardError
363
+ nil
364
+ end
365
+ end
366
+ rescue StandardError => error
367
+ @workspace.message = error.message
368
+ end
369
+
370
+ def buffer_event_params(buffer)
371
+ {"uri" => buffer.path && Sadr::Protocol.uri(buffer.path), "version" => buffer.version}
372
+ end
373
+
374
+ def buffer_change_params(buffer)
375
+ transaction = buffer.history.last
376
+ changes = transaction.patch.edits.map do |edit|
377
+ range = Sadr::Protocol.range(buffer.rope, edit.old_range)
378
+ {"range" => {"start" => {"line" => range.start.line, "character" => range.start.character},
379
+ "end" => {"line" => range.end.line, "character" => range.end.character}}, "text" => edit.new_text}
380
+ end
381
+ buffer_event_params(buffer).merge("changes" => changes)
382
+ end
383
+
384
+ def notify_buffer_event(instance, buffer, method, params)
385
+ targets = if instance
386
+ [instance]
387
+ else
388
+ @subscriptions.keys.filter_map { |id, object_id| @subscribed_instances[id] if object_id == buffer.object_id }
389
+ end
390
+ targets.uniq.each do |target|
391
+ target.notify(method, params)
392
+ rescue StandardError
393
+ nil
394
+ end
395
+ end
396
+
397
+ def attach_selection_listener(editor)
398
+ return unless editor
399
+ key = editor.object_id
400
+ return if @selection_subscriptions.key?(key)
401
+
402
+ subscription = editor.on_selection { queue_selection_event(editor) }
403
+ @selection_subscriptions[key] = [editor, subscription]
404
+ rescue StandardError
405
+ nil
406
+ end
407
+
408
+ def queue_selection_event(editor)
409
+ key = editor.object_id
410
+ @event_lock.synchronize do
411
+ return if @selection_threads[key]&.alive?
412
+
413
+ thread = Thread.new do
414
+ sleep 0.1
415
+ selections = editor.selections.map { |selection| {"anchor" => selection.anchor, "head" => selection.head} }
416
+ notify_buffer_event(nil, editor.buffer, "selection/didChange", buffer_event_params(editor.buffer).merge("selections" => selections))
417
+ ensure
418
+ @event_lock.synchronize { @selection_threads.delete(key) if @selection_threads[key].equal?(Thread.current) }
419
+ end
420
+ thread.report_on_exception = false
421
+ @selection_threads[key] = thread
422
+ end
423
+ end
424
+
425
+ def detach_buffer(buffer)
426
+ @subscriptions.keys.select { |_id, object_id| object_id == buffer.object_id }.each do |key|
427
+ @subscriptions.delete(key)&.detach
428
+ end
429
+ @selection_subscriptions.keys.each do |key|
430
+ editor, subscription = @selection_subscriptions[key]
431
+ next unless editor.buffer.equal?(buffer)
432
+
433
+ subscription.detach
434
+ @selection_subscriptions.delete(key)
435
+ end
436
+ end
437
+
438
+ def cleanup_instance(id)
439
+ @subscriptions.keys.select { |instance_id, _object_id| instance_id == id.to_s }.each do |key|
440
+ @subscriptions.delete(key)&.detach
441
+ end
442
+ @subscribed_instances.delete(id.to_s)
443
+ end
444
+
445
+ def current_buffer
446
+ @workspace.editor&.buffer || raise(Error, "no active buffer")
447
+ end
448
+
449
+ def normalize_params(params)
450
+ raise ArgumentError, "params must be an object" unless params.is_a?(Hash)
451
+
452
+ params.transform_keys(&:to_s)
453
+ end
454
+
455
+ def activate_panel_async(plugin_id, panel_id)
456
+ key = [plugin_id.to_s, panel_id.to_s]
457
+ return if @activation_threads[key]&.alive?
458
+
459
+ @activation_threads[key] = Thread.new do
460
+ begin
461
+ instance = activate(plugin_id, reason: "onPanel:#{panel_id}")
462
+ instance&.notify("ui/activate", {"panel" => panel_id})
463
+ rescue StandardError => error
464
+ @workspace.message = error.message
465
+ ensure
466
+ @activation_threads.delete(key)
467
+ @workspace.window&.request_frame
468
+ end
469
+ end
470
+ @activation_threads[key].report_on_exception = false
471
+ end
472
+
473
+ def queue_plugin_dialog(kind, params)
474
+ raise ArgumentError, "dialog parameters must be an object" unless params.is_a?(Hash)
475
+ @workspace.plugin_dialogs ||= [] if @workspace.respond_to?(:plugin_dialogs=)
476
+ @workspace.plugin_dialogs << {kind: kind, params: params.dup.freeze}.freeze if @workspace.respond_to?(:plugin_dialogs)
477
+ @workspace.window&.request_frame
478
+ nil
479
+ end
480
+
481
+ def storage_path(plugin_id)
482
+ File.join(@workspace.root, ".canopus", "plugin-storage", "#{Digest::SHA256.hexdigest(plugin_id.to_s)}.json")
483
+ end
484
+
485
+ def storage_key(value)
486
+ key = String(value)
487
+ raise ArgumentError, "storage key must be 1..128 bytes" unless key.bytesize.between?(1, 128) && !key.include?("\0")
488
+ key
489
+ end
490
+
491
+ def storage_read(plugin_id)
492
+ path = storage_path(plugin_id)
493
+ value = @storage_lock.synchronize { File.file?(path) ? JSON.parse(File.read(path)) : {} }
494
+ raise Error, "plugin storage is not an object" unless value.is_a?(Hash)
495
+ value
496
+ rescue JSON::ParserError => error
497
+ raise Error, "invalid plugin storage: #{error.message}"
498
+ end
499
+
500
+ def storage_update(plugin_id)
501
+ path = storage_path(plugin_id)
502
+ @storage_lock.synchronize do
503
+ values = File.file?(path) ? JSON.parse(File.read(path)) : {}
504
+ raise Error, "plugin storage is not an object" unless values.is_a?(Hash)
505
+ yield(values)
506
+ FileUtils.mkdir_p(File.dirname(path))
507
+ Tempfile.create([".storage-", ".json"], File.dirname(path), perm: 0o600) do |file|
508
+ file.write(JSON.generate(values))
509
+ file.flush
510
+ file.fsync
511
+ file.close
512
+ File.chmod(0o600, file.path)
513
+ File.rename(file.path, path)
514
+ end
515
+ end
516
+ end
517
+
518
+ def execute_process(command, instance, timeout)
519
+ return Timeout.timeout(timeout) { Open3.capture3(*command, chdir: @workspace.root) } unless @sandbox
520
+
521
+ load_saiph
522
+ raise PermissionDenied, "plugin process sandbox is unavailable" unless Saiph.available?
523
+
524
+ output_reader, output_writer = IO.pipe
525
+ error_reader, error_writer = IO.pipe
526
+ pid = Saiph.spawn(command, policy: process_policy(instance), chdir: @workspace.root,
527
+ in: File::NULL, out: output_writer, err: error_writer)
528
+ output_writer.close
529
+ error_writer.close
530
+ output_thread = Thread.new { read_process_output(output_reader) }
531
+ error_thread = Thread.new { read_process_output(error_reader) }
532
+ status = Timeout.timeout(timeout) { Process.waitpid2(pid).last }
533
+ [output_thread.value, error_thread.value, status]
534
+ rescue Timeout::Error
535
+ begin
536
+ Process.kill("TERM", pid) if pid
537
+ rescue Errno::ESRCH
538
+ nil
539
+ end
540
+ begin
541
+ Process.wait(pid) if pid
542
+ rescue Errno::ECHILD
543
+ nil
544
+ end
545
+ raise
546
+ ensure
547
+ [output_writer, error_writer, output_reader, error_reader].each { |io| io&.close unless io&.closed? }
548
+ end
549
+
550
+ def process_policy(instance)
551
+ load_saiph
552
+ reads = instance.granted?("workspace.read") ? [@workspace.root] : []
553
+ network = instance.manifest.capabilities.any? { |capability| capability.start_with?("net:") }
554
+ Saiph::Policy.new(reads, [], network, true, [])
555
+ end
556
+
557
+ def read_process_output(io)
558
+ output = +""
559
+ loop do
560
+ chunk = io.readpartial(16 * 1024)
561
+ output << chunk if output.bytesize < (1 << 20)
562
+ end
563
+ rescue EOFError, IOError
564
+ output
565
+ end
566
+
567
+ def load_saiph
568
+ return if defined?(Saiph::Policy)
569
+
570
+ path = ENV["SAIPH_PATH"]
571
+ root = File.expand_path("../..", __dir__)
572
+ path ? require(File.expand_path("lib/saiph", File.expand_path(path, root))) : require("saiph")
573
+ end
574
+ end
575
+ end
576
+ end