libtmux-mcp 0.1.0.alpha.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/exe/libtmux-mcp +6 -0
- data/lib/libtmux/mcp/application.rb +519 -0
- data/lib/libtmux/mcp/catalog.rb +258 -0
- data/lib/libtmux/mcp/catalog_tool.rb +33 -0
- data/lib/libtmux/mcp/cli.rb +239 -0
- data/lib/libtmux/mcp/enrollment.rb +670 -0
- data/lib/libtmux/mcp/mutations.rb +120 -0
- data/lib/libtmux/mcp/observation.rb +474 -0
- data/lib/libtmux/mcp/process_identity.rb +271 -0
- data/lib/libtmux/mcp/resources.rb +101 -0
- data/lib/libtmux/mcp/shell/integration.zsh +50 -0
- data/lib/libtmux/mcp/shell/prepare.rb +137 -0
- data/lib/libtmux/mcp/stdio_transport.rb +504 -0
- data/lib/libtmux/mcp/version.rb +7 -0
- data/lib/libtmux/mcp.rb +7 -0
- data/sig/libtmux-mcp.rbs +26 -0
- data/sig/transport.rbs +16 -0
- metadata +136 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
module MCP
|
|
8
|
+
# Descriptors observe borrowed processes; this class never signals or reaps.
|
|
9
|
+
class ProcessIdentity
|
|
10
|
+
PEER_PIDFD = 77 # Linux asm-generic/socket.h, available since Linux 6.6.
|
|
11
|
+
private_constant :PEER_PIDFD
|
|
12
|
+
module CleanupDetails
|
|
13
|
+
attr_reader :mcp_cleanup_errors
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.attach_cleanup(failure, errors)
|
|
17
|
+
if failure.is_a?(LibTmux::Error)
|
|
18
|
+
failure.__send__(:attach_cleanup_errors, errors)
|
|
19
|
+
else
|
|
20
|
+
failure.extend(CleanupDetails)
|
|
21
|
+
failure.instance_variable_set(:@mcp_cleanup_errors, ((failure.mcp_cleanup_errors || []) + errors).freeze)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Resources
|
|
26
|
+
def initialize(*ios)
|
|
27
|
+
@ios = ios
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add(io)
|
|
31
|
+
@ios << io
|
|
32
|
+
io
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def release(*ios)
|
|
36
|
+
@ios -= ios
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def empty?
|
|
40
|
+
@ios.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
errors = []
|
|
45
|
+
@ios.dup.each do |io|
|
|
46
|
+
begin
|
|
47
|
+
io.close unless io.closed?
|
|
48
|
+
rescue Exception => error
|
|
49
|
+
errors << "native observer close failed (#{error.class})"
|
|
50
|
+
ensure
|
|
51
|
+
@ios.delete(io) if io.closed?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
raise TransportError.new("native observer cleanup failed", phase: :retire, cleanup_errors: errors) unless errors.empty?
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :io, :peer, :generation, :pid
|
|
61
|
+
|
|
62
|
+
def self.native(name, arguments, result)
|
|
63
|
+
unless /\A(?:(?:x86_64|aarch64)-linux|(?:x86_64|arm64)-darwin)/.match?(RUBY_PLATFORM) && Fiddle::SIZEOF_LONG == 8
|
|
64
|
+
raise UnsupportedFeatureError.new("process cursors require 64-bit Linux or Darwin", phase: :admission)
|
|
65
|
+
end
|
|
66
|
+
Fiddle::Function.new(Fiddle::Handle::DEFAULT[name], arguments, result)
|
|
67
|
+
rescue Fiddle::DLError
|
|
68
|
+
raise UnsupportedFeatureError.new("native process cursor support is unavailable", phase: :admission), cause: nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.readable?(io)
|
|
72
|
+
poll = native("poll", [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
73
|
+
data = [io.fileno, 1, 0].pack("iss")
|
|
74
|
+
# Retry one interrupted nonblocking check without consuming readiness.
|
|
75
|
+
2.times do
|
|
76
|
+
result = poll.call(data, 1, 0)
|
|
77
|
+
if result.negative?
|
|
78
|
+
errno = Fiddle.last_error
|
|
79
|
+
next if errno == Errno::EINTR::Errno
|
|
80
|
+
|
|
81
|
+
raise TransportError.new("process descriptor poll failed (errno #{errno})", phase: :read)
|
|
82
|
+
end
|
|
83
|
+
events = data.unpack("iss").last
|
|
84
|
+
if (events & 0x28).positive? # POLLERR | POLLNVAL are not process exits.
|
|
85
|
+
raise TransportError.new("process descriptor poll returned an invalid event", phase: :read)
|
|
86
|
+
end
|
|
87
|
+
return (events & 0x11).positive? # POLLIN | POLLHUP retain terminal readiness.
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise TransportError.new("process descriptor poll remained interrupted", phase: :read)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.procfs_namespace
|
|
94
|
+
File.open("/proc/self/status") do |status|
|
|
95
|
+
buffer = "\0" * 256
|
|
96
|
+
statfs = native("fstatfs", [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
|
|
97
|
+
valid = statfs.call(status.fileno, buffer).zero? && buffer.unpack1("l!") == 0x9fa0
|
|
98
|
+
rows = status.read(65_537).lines.grep(/^NSpid:/)
|
|
99
|
+
valid &&= rows.length == 1 && rows.first.split.drop(1) == [Process.pid.to_s]
|
|
100
|
+
raise UnsupportedFeatureError.new("process cursors require procfs in the caller PID namespace", phase: :admission) unless valid
|
|
101
|
+
end
|
|
102
|
+
File.open("/proc/self/ns/pid")
|
|
103
|
+
rescue SystemCallError, IOError
|
|
104
|
+
raise UnsupportedFeatureError.new("process namespace evidence is unavailable", phase: :admission), cause: nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.acquire(server, server_pid:, pane_pid:, budget:, on_retire: nil)
|
|
108
|
+
resources = Resources.new
|
|
109
|
+
failure = identity = nil
|
|
110
|
+
begin
|
|
111
|
+
pane, peer = if RUBY_PLATFORM.include?("darwin")
|
|
112
|
+
acquire_darwin(server, server_pid, pane_pid, budget, resources)
|
|
113
|
+
else
|
|
114
|
+
acquire_linux(server, server_pid, pane_pid, budget, resources)
|
|
115
|
+
end
|
|
116
|
+
identity = new(pane, peer, pane_pid)
|
|
117
|
+
resources.release(pane, peer)
|
|
118
|
+
identity.ensure_live!
|
|
119
|
+
rescue Errno::ENOPROTOOPT, Errno::EINVAL, Errno::EPERM, Errno::EACCES, Errno::ENOENT
|
|
120
|
+
failure = UnsupportedFeatureError.new("peer process identity is unavailable", phase: :admission)
|
|
121
|
+
rescue Exception => error
|
|
122
|
+
failure = error
|
|
123
|
+
ensure
|
|
124
|
+
begin
|
|
125
|
+
resources.close
|
|
126
|
+
rescue TransportError => cleanup
|
|
127
|
+
on_retire&.call(resources) unless resources.empty?
|
|
128
|
+
attach_cleanup(failure, cleanup.cleanup_errors) if failure
|
|
129
|
+
failure ||= cleanup
|
|
130
|
+
end
|
|
131
|
+
if failure && identity
|
|
132
|
+
begin
|
|
133
|
+
identity.close
|
|
134
|
+
rescue TransportError => cleanup
|
|
135
|
+
on_retire&.call(identity)
|
|
136
|
+
attach_cleanup(failure, cleanup.cleanup_errors)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
raise failure, cause: nil if failure
|
|
141
|
+
|
|
142
|
+
identity
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.acquire_linux(server, server_pid, pane_pid, budget, resources)
|
|
146
|
+
own_namespace = resources.add(procfs_namespace)
|
|
147
|
+
route = server.__send__(:with_bound_endpoint) { |_endpoint, pin| pin.command_prefix.last }
|
|
148
|
+
socket = resources.add(Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0))
|
|
149
|
+
address = Socket.sockaddr_un(route)
|
|
150
|
+
loop do
|
|
151
|
+
remaining = budget.options.fetch(:timeout)
|
|
152
|
+
connected = socket.connect_nonblock(address, exception: false)
|
|
153
|
+
break unless connected == :wait_writable
|
|
154
|
+
|
|
155
|
+
Fiber.scheduler.io_wait(socket, IO::WRITABLE, remaining)
|
|
156
|
+
rescue Errno::EISCONN
|
|
157
|
+
break
|
|
158
|
+
end
|
|
159
|
+
# SO_PEERPIDFD avoids reacquiring a potentially reused SO_PEERCRED PID.
|
|
160
|
+
peer = resources.add(IO.for_fd(socket.getsockopt(Socket::SOL_SOCKET, PEER_PIDFD).int))
|
|
161
|
+
peer.close_on_exec = true
|
|
162
|
+
peer_pid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack1("i")
|
|
163
|
+
unless peer_pid == server_pid && !readable?(peer)
|
|
164
|
+
raise UnsupportedFeatureError.new("socket peer identity does not establish the tmux process", phase: :admission)
|
|
165
|
+
end
|
|
166
|
+
other_namespace = resources.add(File.open("/proc/#{peer_pid}/ns/pid"))
|
|
167
|
+
same_namespace = [own_namespace.stat.dev, own_namespace.stat.ino] == [other_namespace.stat.dev, other_namespace.stat.ino]
|
|
168
|
+
unless same_namespace && !readable?(peer)
|
|
169
|
+
raise UnsupportedFeatureError.new("tmux and observer PID namespaces differ", phase: :admission)
|
|
170
|
+
end
|
|
171
|
+
opener = native("pidfd_open", [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
172
|
+
descriptor = opener.call(pane_pid, 0)
|
|
173
|
+
if descriptor.negative?
|
|
174
|
+
error = Fiddle.last_error
|
|
175
|
+
if [Errno::EMFILE::Errno, Errno::ENFILE::Errno].include?(error)
|
|
176
|
+
raise CapacityError.new("process descriptor capacity exhausted", phase: :admission)
|
|
177
|
+
elsif error == Errno::ESRCH::Errno
|
|
178
|
+
raise TargetNotFoundError.new("pane process is unavailable", phase: :admission)
|
|
179
|
+
end
|
|
180
|
+
raise UnsupportedFeatureError.new("pane process identity is unavailable", phase: :admission)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
pane = resources.add(IO.for_fd(descriptor))
|
|
184
|
+
pane.close_on_exec = true
|
|
185
|
+
[pane, peer]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.acquire_darwin(server, server_pid, pane_pid, budget, resources)
|
|
189
|
+
budget.options
|
|
190
|
+
peer = process_events(server_pid, resources)
|
|
191
|
+
if readable?(peer)
|
|
192
|
+
raise TargetNotFoundError.new("tmux process is unavailable", phase: :admission)
|
|
193
|
+
end
|
|
194
|
+
# Registration may find a recycled PID. A fresh pinned-route reply must
|
|
195
|
+
# name that same daemon while its retained process observer stays live.
|
|
196
|
+
name = server.__send__(:builtin_spellings, "display-message", budget: budget).fetch("display-message")
|
|
197
|
+
result = server.__send__(:execute_typed, [name, "-p", '#{pid}'], **budget.options)
|
|
198
|
+
unless result.stdout == "#{server_pid}\n".b && !readable?(peer)
|
|
199
|
+
raise UnsupportedFeatureError.new("bound response does not establish the tmux process", phase: :admission)
|
|
200
|
+
end
|
|
201
|
+
budget.options
|
|
202
|
+
pane = process_events(pane_pid, resources)
|
|
203
|
+
budget.options
|
|
204
|
+
[pane, peer]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.process_events(pid, resources)
|
|
208
|
+
create = native("kqueue", [], Fiddle::TYPE_INT)
|
|
209
|
+
register = native("kevent", [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
|
|
210
|
+
Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
|
|
211
|
+
descriptor = create.call
|
|
212
|
+
if descriptor.negative?
|
|
213
|
+
if [Errno::EMFILE::Errno, Errno::ENFILE::Errno, Errno::ENOMEM::Errno].include?(Fiddle.last_error)
|
|
214
|
+
raise CapacityError.new("process observer capacity exhausted", phase: :admission)
|
|
215
|
+
end
|
|
216
|
+
raise UnsupportedFeatureError.new("process observation is unavailable", phase: :admission)
|
|
217
|
+
end
|
|
218
|
+
events = resources.add(IO.for_fd(descriptor))
|
|
219
|
+
events.close_on_exec = true
|
|
220
|
+
# NOTE_REAP also covers traced exits whose NOTE_EXIT was suppressed.
|
|
221
|
+
# Keep the terminal event queued: readiness is the retained death latch.
|
|
222
|
+
change = [pid, -5, 1, 0x90000000, 0, 0].pack("QsS I qQ")
|
|
223
|
+
if register.call(events.fileno, change, 1, nil, 0, nil).negative?
|
|
224
|
+
if Fiddle.last_error == Errno::ESRCH::Errno
|
|
225
|
+
raise TargetNotFoundError.new("process is unavailable", phase: :admission)
|
|
226
|
+
end
|
|
227
|
+
raise UnsupportedFeatureError.new("process observation is unavailable", phase: :admission)
|
|
228
|
+
end
|
|
229
|
+
events
|
|
230
|
+
end
|
|
231
|
+
private_class_method :acquire_linux, :acquire_darwin, :process_events
|
|
232
|
+
|
|
233
|
+
def initialize(io, peer, pid)
|
|
234
|
+
@io, @peer, @pid = io, peer, pid
|
|
235
|
+
@generation = SecureRandom.hex(16).freeze
|
|
236
|
+
@references = 1
|
|
237
|
+
@resources = Resources.new(io, peer)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :new
|
|
240
|
+
|
|
241
|
+
def retain
|
|
242
|
+
raise ClosedError.new("process cursor is closed", phase: :admission) if @references.zero?
|
|
243
|
+
|
|
244
|
+
@references += 1
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def close
|
|
249
|
+
@references -= 1 if @references.positive?
|
|
250
|
+
@resources.close if @references.zero?
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def peer_alive!
|
|
255
|
+
if @references.zero? || self.class.readable?(@peer)
|
|
256
|
+
raise TargetNotFoundError.new("retained tmux process is unavailable", phase: :read, delivery: :observed)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def exited?
|
|
261
|
+
peer_alive!
|
|
262
|
+
self.class.readable?(@io)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def ensure_live!
|
|
266
|
+
raise TargetNotFoundError.new("retained pane process exited", phase: :read, delivery: :observed) if exited?
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
private_constant :ProcessIdentity
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
module MCP
|
|
5
|
+
class Resources
|
|
6
|
+
def initialize(application:, endpoint_name:, enabled_tools:, max_response_bytes:)
|
|
7
|
+
@application, @endpoint, @enabled, @max_bytes = application, endpoint_name, enabled_tools, max_response_bytes
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def install(server)
|
|
11
|
+
server.capabilities[:resources] = {subscribe: false, listChanged: false}
|
|
12
|
+
if @enabled.include?("tmux_snapshot")
|
|
13
|
+
prefix = "tmux://{endpoint}/{generation}/snapshots/{entity}"
|
|
14
|
+
server.resource_templates.concat([
|
|
15
|
+
::MCP::ResourceTemplate.new(uri_template: prefix, name: "tmux_metadata", mime_type: "application/json",
|
|
16
|
+
description: "Acquire metadata through tmux_snapshot with its default page limit. Encode every URI component; generation must match discovery. Reports capture interval and truncation."),
|
|
17
|
+
::MCP::ResourceTemplate.new(uri_template: "#{prefix}/pages/{cursor}", name: "tmux_metadata_page", mime_type: "application/json",
|
|
18
|
+
description: "Read a retained tmux_snapshot page without refreshing it. Encode the cursor component; endpoint, generation and entity must match its captured query.")
|
|
19
|
+
])
|
|
20
|
+
end
|
|
21
|
+
if @enabled.include?("tmux_capture")
|
|
22
|
+
server.resource_templates << ::MCP::ResourceTemplate.new(
|
|
23
|
+
uri_template: "tmux://{endpoint}/{generation}/panes/{pane_id}/screen",
|
|
24
|
+
name: "tmux_screen", mime_type: "text/plain",
|
|
25
|
+
description: "Capture an exact pane through tmux_capture with its default byte/line bounds, without cursor tracking. Percent-encode pane IDs. Invalid UTF-8 is an application/octet-stream blob; _meta includes capture interval, truncation and unknown history continuity.")
|
|
26
|
+
end
|
|
27
|
+
server.resources_read_handler do |params, server_context: nil|
|
|
28
|
+
read(params[:uri], cancellation: server_context&.cancellation)
|
|
29
|
+
end
|
|
30
|
+
server
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def decode(component)
|
|
36
|
+
return unless component && component.match?(/\A(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+\z/)
|
|
37
|
+
|
|
38
|
+
value = component.gsub(/%([0-9A-F]{2})/) { [$1.to_i(16)].pack("C") }.force_encoding(Encoding::UTF_8)
|
|
39
|
+
return unless value.valid_encoding?
|
|
40
|
+
|
|
41
|
+
encoded = value.bytes.map { |byte| (byte.chr.match?(/[A-Za-z0-9._~-]/) ? byte.chr : "%%%02X" % byte) }.join
|
|
42
|
+
value if encoded == component
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read(uri, cancellation:)
|
|
46
|
+
unless uri.is_a?(String) && uri.valid_encoding? && uri.bytesize <= 1024
|
|
47
|
+
error("invalid_input", "The resource URI is invalid.", protocol: true)
|
|
48
|
+
end
|
|
49
|
+
match = /\Atmux:\/\/([^\/]+)\/([^\/]+)\/(snapshots|panes)\/([^\/]+)(?:\/(pages|screen)(?:\/([^\/]+))?)?\z/.match(uri)
|
|
50
|
+
error("invalid_input", "The resource URI is invalid.", protocol: true) unless match
|
|
51
|
+
endpoint, generation, collection, target, suffix, cursor = match.captures.map { |part| part && decode(part) }
|
|
52
|
+
unless endpoint == @endpoint && generation && generation.bytesize <= 128 &&
|
|
53
|
+
(!match[6] || cursor)
|
|
54
|
+
error("invalid_input", "The resource URI is invalid.", protocol: true)
|
|
55
|
+
end
|
|
56
|
+
if collection == "snapshots" && Internal::Catalog.kinds.map(&:to_s).include?(target) &&
|
|
57
|
+
(suffix.nil? || (suffix == "pages" && cursor))
|
|
58
|
+
name = "tmux_snapshot"
|
|
59
|
+
arguments = cursor ? {"cursor" => cursor} : {"entity" => target}
|
|
60
|
+
elsif collection == "panes" && target&.match?(/\A%[0-9]+\z/) && suffix == "screen" && cursor.nil?
|
|
61
|
+
name = "tmux_capture"
|
|
62
|
+
arguments = {"target" => {"generation" => generation, "kind" => "pane", "id" => target}}
|
|
63
|
+
else
|
|
64
|
+
error("invalid_input", "The resource URI is invalid.", protocol: true)
|
|
65
|
+
end
|
|
66
|
+
response = @application.call(name, arguments, cancellation: cancellation).structured_content
|
|
67
|
+
unless response.fetch("ok")
|
|
68
|
+
failure = response.fetch("error")
|
|
69
|
+
error(failure.fetch("code"), failure.fetch("message"), details: failure)
|
|
70
|
+
end
|
|
71
|
+
data = response.fetch("data")
|
|
72
|
+
if name == "tmux_snapshot"
|
|
73
|
+
unless data.fetch("server_identity").fetch("generation") == generation && data.fetch("entity") == target
|
|
74
|
+
error("stale_target", "The resource generation or captured entity does not match.", delivery: "observed")
|
|
75
|
+
end
|
|
76
|
+
contents = [{uri: uri, mimeType: "application/json", text: JSON.generate(response)}]
|
|
77
|
+
else
|
|
78
|
+
metadata = data.reject { |key, _| key == "rows" }
|
|
79
|
+
contents = [{uri: uri, _meta: {"io.github.libtmux/capture" => metadata}}]
|
|
80
|
+
if data.fetch("encoding") == "utf-8"
|
|
81
|
+
contents.first.merge!(mimeType: "text/plain", text: data.fetch("rows").join)
|
|
82
|
+
else
|
|
83
|
+
bytes = data.fetch("rows").map { |row| row.unpack1("m0") }.join
|
|
84
|
+
contents.first.merge!(mimeType: "application/octet-stream", blob: [bytes].pack("m0"))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
if JSON.generate(contents).bytesize > @max_bytes
|
|
88
|
+
error("capacity", "The resource exceeds its response byte limit.", delivery: "observed")
|
|
89
|
+
end
|
|
90
|
+
contents
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def error(code, message, protocol: false, details: nil, delivery: "not_sent")
|
|
94
|
+
raise ::MCP::Server::RequestHandlerError.new(message, nil,
|
|
95
|
+
error_code: protocol ? -32602 : -32000,
|
|
96
|
+
error_data: details || {"code" => code, "message" => message, "delivery" => delivery})
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_constant :Resources
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Explicitly source with socket, invitation token, Ruby, helper, and load paths.
|
|
2
|
+
[[ -o interactive && -o zle && $# == 5 && -z ${_libtmux_run_fd-} ]] || return 1
|
|
3
|
+
[[ $ZSH_VERSION == 5.9 || $ZSH_VERSION == 5.9.<-> ]] || return 1
|
|
4
|
+
zmodload zsh/net/socket || return 1
|
|
5
|
+
zmodload zsh/system || return 1
|
|
6
|
+
zsocket "$1" || return 1
|
|
7
|
+
typeset -g _libtmux_run_fd=$REPLY
|
|
8
|
+
typeset -g _libtmux_run_ruby=$3 _libtmux_run_helper=$4 _libtmux_run_loadpath=$5 _libtmux_run_buffer=''
|
|
9
|
+
print -r -- "ZLE1 $2 $ZSH_VERSION $TMUX_PANE" >&$_libtmux_run_fd || return 1
|
|
10
|
+
typeset _libtmux_enrollment_ack
|
|
11
|
+
if ! sysread -i $_libtmux_run_fd -s 1 -t 0.5 _libtmux_enrollment_ack || [[ $_libtmux_enrollment_ack != A ]]; then
|
|
12
|
+
exec {_libtmux_run_fd}<&-
|
|
13
|
+
unset _libtmux_run_fd
|
|
14
|
+
return 1
|
|
15
|
+
fi
|
|
16
|
+
unset _libtmux_enrollment_ack
|
|
17
|
+
|
|
18
|
+
_libtmux_run_widget() {
|
|
19
|
+
emulate -L zsh
|
|
20
|
+
setopt localtraps
|
|
21
|
+
trap '' PIPE
|
|
22
|
+
local chunk frame exit_status readiness=ready
|
|
23
|
+
local -a fields
|
|
24
|
+
if ! sysread -i $_libtmux_run_fd -s 1024 -t 0 chunk; then
|
|
25
|
+
zle -F $_libtmux_run_fd
|
|
26
|
+
exec {_libtmux_run_fd}<&-
|
|
27
|
+
unset _libtmux_run_fd
|
|
28
|
+
return
|
|
29
|
+
fi
|
|
30
|
+
_libtmux_run_buffer+=$chunk
|
|
31
|
+
if (( ${#_libtmux_run_buffer} > 1024 )); then
|
|
32
|
+
zle -F $_libtmux_run_fd
|
|
33
|
+
exec {_libtmux_run_fd}<&-
|
|
34
|
+
unset _libtmux_run_fd
|
|
35
|
+
return
|
|
36
|
+
fi
|
|
37
|
+
[[ $_libtmux_run_buffer == *$'\n'* ]] || return
|
|
38
|
+
frame=${_libtmux_run_buffer%%$'\n'*}
|
|
39
|
+
_libtmux_run_buffer=${_libtmux_run_buffer#*$'\n'}
|
|
40
|
+
fields=(${=frame})
|
|
41
|
+
[[ $#fields == 6 && $fields[1] == P && ${#fields[2]} == 32 && ${#fields[3]} == 32 && $fields[2] != *[^0-9a-f]* && $fields[3] != *[^0-9a-f]* ]] || return
|
|
42
|
+
if [[ $CONTEXT != start || -n $BUFFER || -n $PREBUFFER || $PENDING -gt 0 || $KEYS_QUEUED_COUNT -gt 0 ]]; then
|
|
43
|
+
readiness=refused
|
|
44
|
+
fi
|
|
45
|
+
"$_libtmux_run_ruby" --disable=rubyopt,gems -I "$_libtmux_run_loadpath" "$_libtmux_run_helper" "$fields[4]" "$fields[3]" "$fields[5]" "$fields[2]" "$fields[6]" "$readiness" </dev/null >/dev/null 2>&1
|
|
46
|
+
exit_status=$?
|
|
47
|
+
print -r -- "DONE $fields[2] $exit_status" >&$_libtmux_run_fd
|
|
48
|
+
}
|
|
49
|
+
zle -N _libtmux_run_widget
|
|
50
|
+
zle -F -w $_libtmux_run_fd _libtmux_run_widget
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'digest/sha2'
|
|
5
|
+
require 'libtmux/process'
|
|
6
|
+
|
|
7
|
+
# This process is launched with explicit installed load paths and disabled gems.
|
|
8
|
+
class AuthoredShellHelper
|
|
9
|
+
def initialize(socket, deadline)
|
|
10
|
+
@socket, @deadline, @buffer = socket, deadline, +''.b
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def remaining
|
|
14
|
+
value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
raise LibTmux::DeadlineExceeded.new('helper deadline expired') unless value.positive?
|
|
16
|
+
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read_line
|
|
21
|
+
until (ending = @buffer.index("\n"))
|
|
22
|
+
raise LibTmux::ProtocolError.new('helper frame is too large') if @buffer.bytesize >= 1024
|
|
23
|
+
|
|
24
|
+
read_more(1024 - @buffer.bytesize)
|
|
25
|
+
end
|
|
26
|
+
@buffer.slice!(0, ending + 1).chomp
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read_bytes(length)
|
|
30
|
+
read_more([length - @buffer.bytesize, 16_384].min) while @buffer.bytesize < length
|
|
31
|
+
@buffer.slice!(0, length)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write(bytes)
|
|
35
|
+
offset = 0
|
|
36
|
+
while offset < bytes.bytesize
|
|
37
|
+
duration = remaining
|
|
38
|
+
count = @socket.write_nonblock(bytes.byteslice(offset, 16_384), exception: false)
|
|
39
|
+
if count == :wait_writable
|
|
40
|
+
IO.select(nil, [@socket], nil, duration)
|
|
41
|
+
else
|
|
42
|
+
offset += count
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read_more(limit)
|
|
50
|
+
duration = remaining
|
|
51
|
+
bytes = @socket.read_nonblock(limit, exception: false)
|
|
52
|
+
if bytes == :wait_readable
|
|
53
|
+
IO.select([@socket], nil, nil, duration)
|
|
54
|
+
elsif bytes
|
|
55
|
+
@buffer << bytes
|
|
56
|
+
else
|
|
57
|
+
raise EOFError
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
path, token, deadline_text, run_id, digest, readiness = ARGV
|
|
64
|
+
path = path.unpack1('m0')
|
|
65
|
+
raise ArgumentError unless ARGV.length == 6 && /\A[0-9a-f]{32}\z/.match?(token) &&
|
|
66
|
+
/\A[0-9a-f]{32}\z/.match?(run_id) && /\A[0-9a-f]{64}\z/.match?(digest)
|
|
67
|
+
deadline = Float(deadline_text)
|
|
68
|
+
raise ArgumentError unless deadline.finite?
|
|
69
|
+
socket = UNIXSocket.new(path)
|
|
70
|
+
wire = AuthoredShellHelper.new(socket, deadline)
|
|
71
|
+
prefix = readiness == 'ready' ? 'READY' : 'REFUSED'
|
|
72
|
+
wire.write("#{prefix} #{run_id} #{token} #{digest} #{Process.ppid}\n")
|
|
73
|
+
exit 72 unless readiness == 'ready'
|
|
74
|
+
raise LibTmux::ProtocolError.new('invalid helper grant') unless wire.read_line == "GRANT #{run_id} #{token} #{digest}"
|
|
75
|
+
|
|
76
|
+
wire.write("AUTHORIZED #{run_id} #{token} #{digest}\n")
|
|
77
|
+
fields = wire.read_line.split(' ')
|
|
78
|
+
unless fields.length == 7 && fields[0, 4] == ['SCRIPT', run_id, token, digest] && fields[4, 3].all? { |value| /\A\d{1,6}\z/.match?(value) }
|
|
79
|
+
raise LibTmux::ProtocolError.new('invalid helper script envelope')
|
|
80
|
+
end
|
|
81
|
+
length, stdout_limit, stderr_limit = fields[4, 3].map(&:to_i)
|
|
82
|
+
unless length <= 65_536 && stdout_limit <= 262_144 && stderr_limit <= 262_144
|
|
83
|
+
raise LibTmux::ProtocolError.new('invalid helper script limits')
|
|
84
|
+
end
|
|
85
|
+
script = wire.read_bytes(length)
|
|
86
|
+
unless !script.include?("\0") && Digest::SHA256.hexdigest(script) == digest
|
|
87
|
+
raise LibTmux::ProtocolError.new('helper script digest mismatch')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
cancellation = LibTmux::Internal::Cancellation.new
|
|
91
|
+
wake_reader, wake_writer = IO.pipe
|
|
92
|
+
watcher = Thread.new do
|
|
93
|
+
ready = IO.select([socket, wake_reader])
|
|
94
|
+
if ready.first.include?(socket)
|
|
95
|
+
# EOF or unsolicited client bytes revoke this helper's outstanding work.
|
|
96
|
+
socket.read_nonblock(1, exception: false)
|
|
97
|
+
cancellation.cancel
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
environment = %w[TMUX TMUX_PANE].filter_map { |name| "#{name}=#{ENV.fetch(name)}" if ENV.key?(name) }
|
|
101
|
+
result = LibTmux::Internal::ProcessExecutor.new(stdout_limit: stdout_limit, stderr_limit: stderr_limit,
|
|
102
|
+
cleanup_timeout: 0.25, drain_timeout: 0.25).run(['/usr/bin/env', *environment, '/bin/sh', '-c', script],
|
|
103
|
+
timeout: wire.remaining, cancel: cancellation)
|
|
104
|
+
wake_writer.write_nonblock('x', exception: false)
|
|
105
|
+
unless watcher.join(0.25)
|
|
106
|
+
raise LibTmux::TransportError.new('helper cancellation watcher remains active', cleanup_errors: ['watcher retirement remains pending'])
|
|
107
|
+
end
|
|
108
|
+
watcher = nil
|
|
109
|
+
kind, status = result.status.exited? ? ['EXIT', result.status.exitstatus] : ['SIGNAL', result.status.termsig]
|
|
110
|
+
wire.write("RESULT #{run_id} #{token} #{digest} #{kind} #{status} #{result.stdout.bytesize} #{result.stderr.bytesize}\n")
|
|
111
|
+
wire.write(result.stdout)
|
|
112
|
+
wire.write(result.stderr)
|
|
113
|
+
rescue LibTmux::Error => error
|
|
114
|
+
code = case error
|
|
115
|
+
when LibTmux::CapacityError then 'capacity'
|
|
116
|
+
when LibTmux::DeadlineExceeded then 'deadline'
|
|
117
|
+
when LibTmux::Cancelled then 'cancelled'
|
|
118
|
+
when LibTmux::ProtocolError then 'protocol'
|
|
119
|
+
else 'unknown'
|
|
120
|
+
end
|
|
121
|
+
begin
|
|
122
|
+
wire&.write("ERROR #{run_id} #{token} #{digest} #{code} #{error.cleanup_errors.empty? ? 'clean' : 'pending'}\n")
|
|
123
|
+
rescue StandardError
|
|
124
|
+
# The client observes unknown completion when the bounded channel is gone.
|
|
125
|
+
end
|
|
126
|
+
exit 78
|
|
127
|
+
rescue ArgumentError, SystemCallError, IOError, EOFError
|
|
128
|
+
exit 77
|
|
129
|
+
ensure
|
|
130
|
+
if watcher
|
|
131
|
+
wake_writer.write_nonblock('x', exception: false)
|
|
132
|
+
retired = watcher.join(0.25)
|
|
133
|
+
end
|
|
134
|
+
[wake_reader, wake_writer, socket].compact.each { |io| io.close unless io.closed? }
|
|
135
|
+
cancellation&.close
|
|
136
|
+
exit 79 if watcher && !retired
|
|
137
|
+
end
|