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.
@@ -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