terminalwire 0.3.5.alpha2 → 2.0.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.
- checksums.yaml +4 -4
- data/lib/terminalwire/v2/codec.rb +52 -0
- data/lib/terminalwire/v2/conformance.rb +229 -0
- data/lib/terminalwire/v2/errors.rb +26 -0
- data/lib/terminalwire/v2/frames.rb +107 -0
- data/lib/terminalwire/v2/mux.rb +48 -0
- data/lib/terminalwire/v2/negotiator.rb +30 -0
- data/lib/terminalwire/v2/protocol.rb +91 -0
- data/lib/terminalwire/v2/rails.rb +335 -0
- data/lib/terminalwire/v2/server/connection.rb +179 -0
- data/lib/terminalwire/v2/server/context.rb +251 -0
- data/lib/terminalwire/v2/server/dual_thor.rb +61 -0
- data/lib/terminalwire/v2/server/flow.rb +77 -0
- data/lib/terminalwire/v2/server/handler.rb +143 -0
- data/lib/terminalwire/v2/server/io.rb +91 -0
- data/lib/terminalwire/v2/server/rack.rb +377 -0
- data/lib/terminalwire/v2/server/redirect.rb +71 -0
- data/lib/terminalwire/v2/server/runtime.rb +267 -0
- data/lib/terminalwire/v2/server/session.rb +51 -0
- data/lib/terminalwire/v2/server/stream_router.rb +60 -0
- data/lib/terminalwire/v2/server/terminal.rb +99 -0
- data/lib/terminalwire/v2/server/thor.rb +78 -0
- data/lib/terminalwire/v2/transport/memory.rb +40 -0
- data/lib/terminalwire/v2/transport/queue.rb +46 -0
- data/lib/terminalwire/v2/version.rb +9 -0
- data/lib/terminalwire/v2/window.rb +33 -0
- data/lib/terminalwire/v2.rb +39 -0
- metadata +140 -23
- data/exe/terminalwire +0 -9
- data/exe/terminalwire-exec +0 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a3b1a0d008654c84320fb1300e080d289e6b620b1526556427b8bdf493286dc
|
|
4
|
+
data.tar.gz: 0ef9c8b606d22d79d44e258ae2d38fc19a4cfcb18d92ce106b0e3fc33806e408
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75d87cd218444227f5e1a5c17e65ed887e28992810dfe249050e22b159b636b77a40fca4fbf3346f60467526a4c4603060e67f0819c1ff9e4afa1ff105246417
|
|
7
|
+
data.tar.gz: 613575a6abb5e75498be37aaaf94e970997545d780dce942293c129d5b99848e18d9cd0757f738481d38efb09d903533c5eb10e833714797b93196c9f1c4afed
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "msgpack"
|
|
4
|
+
|
|
5
|
+
module Terminalwire::V2
|
|
6
|
+
# Pure bytes <-> frame conversion. A frame is a Hash with string keys (the wire
|
|
7
|
+
# shape). No I/O, no transport — this is the sans-IO seam the conformance corpus
|
|
8
|
+
# exercises directly.
|
|
9
|
+
module Codec
|
|
10
|
+
# Largest valid stream id: a signed 64-bit max. Go decodes sids into int64, so
|
|
11
|
+
# anything above this would wrap to a negative (colliding) sid there; bounding
|
|
12
|
+
# it here keeps all three impls' sid validity identical.
|
|
13
|
+
MAX_SID = (1 << 63) - 1
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param frame [Hash] a frame with string keys
|
|
18
|
+
# @return [String] MessagePack bytes (binary encoding)
|
|
19
|
+
def encode(frame)
|
|
20
|
+
raise ProtocolError, "frame must be a Hash, got #{frame.class}" unless frame.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
MessagePack.pack(frame)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param bytes [String] MessagePack bytes for exactly one frame
|
|
26
|
+
# @return [Hash] the decoded frame with string keys
|
|
27
|
+
# @raise [ProtocolError] if the bytes are not a well-formed frame
|
|
28
|
+
def decode(bytes)
|
|
29
|
+
obj =
|
|
30
|
+
begin
|
|
31
|
+
MessagePack.unpack(bytes)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise ProtocolError, "malformed msgpack: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise ProtocolError, "frame must be a map" unless obj.is_a?(Hash)
|
|
37
|
+
# 't' must be a NON-EMPTY type string. Go and Elixir reject "" at the codec;
|
|
38
|
+
# Ruby used to let it through to the state machine. An empty type is not a
|
|
39
|
+
# valid frame — reject it here so all three behave identically.
|
|
40
|
+
raise ProtocolError, "frame missing string 't'" unless obj["t"].is_a?(String) && !obj["t"].empty?
|
|
41
|
+
# 'sid' must be a non-negative integer that fits in a signed 64-bit int (see
|
|
42
|
+
# MAX_SID): real sids are small and server-allocated, and the range bound keeps
|
|
43
|
+
# the three impls aligned (Go would otherwise wrap a uint64 sid to a negative).
|
|
44
|
+
sid = obj["sid"]
|
|
45
|
+
unless sid.is_a?(Integer) && sid >= 0 && sid <= MAX_SID
|
|
46
|
+
raise ProtocolError, "frame missing integer 'sid'"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
obj
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Terminalwire::V2
|
|
9
|
+
# Loads the language-neutral conformance corpus and resolves its typed sentinels
|
|
10
|
+
# into native Ruby values. The Go and Elixir runners do the equivalent. This is
|
|
11
|
+
# what lets one corpus validate every implementation.
|
|
12
|
+
#
|
|
13
|
+
# Two file shapes live in the corpus:
|
|
14
|
+
# * simple vector tables (negotiate/roundtrip/golden/validate/flow) — YAML/JSON,
|
|
15
|
+
# with `$bin` base64 + `bytes_hex` sentinels;
|
|
16
|
+
# * "tapes" (session) — S-expressions (see Sexp): recorded client<->server
|
|
17
|
+
# interactions. Sexp is used for the tapes because it is trivial and
|
|
18
|
+
# unambiguous to parse in every language and reads like a transcript.
|
|
19
|
+
module Conformance
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def root
|
|
23
|
+
Pathname.new(ENV.fetch("TERMINALWIRE_CORPUS") do
|
|
24
|
+
File.expand_path("../../../conformance", __dir__)
|
|
25
|
+
end)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def vectors_dir
|
|
29
|
+
root.join("vectors")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load every vector file in a category, dispatching by extension, and return a
|
|
33
|
+
# flat array of cases with sentinels resolved to native values. Fails LOUDLY
|
|
34
|
+
# when the corpus is absent rather than silently running zero cases.
|
|
35
|
+
def load(category)
|
|
36
|
+
unless vectors_dir.directory?
|
|
37
|
+
raise "conformance corpus not found at #{vectors_dir} — set TERMINALWIRE_CORPUS " \
|
|
38
|
+
"to the corpus directory (it ships in terminalwire/protocol). Without it " \
|
|
39
|
+
"the corpus specs would silently run zero cases."
|
|
40
|
+
end
|
|
41
|
+
Dir.glob(vectors_dir.join(category, "*.{yml,yaml,json,sexp}")).sort.flat_map do |path|
|
|
42
|
+
case File.extname(path)
|
|
43
|
+
when ".sexp" then Sexp.load(File.read(path)) # tapes (bin already resolved)
|
|
44
|
+
when ".json" then resolve(JSON.parse(File.read(path)))
|
|
45
|
+
else resolve(YAML.safe_load_file(path))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Recursively resolve { "$bin" => base64 } sentinels into binary strings.
|
|
51
|
+
def resolve(value)
|
|
52
|
+
case value
|
|
53
|
+
when Hash
|
|
54
|
+
if value.size == 1 && value.key?("$bin")
|
|
55
|
+
Base64.decode64(value.fetch("$bin")).b
|
|
56
|
+
else
|
|
57
|
+
value.transform_values { |v| resolve(v) }
|
|
58
|
+
end
|
|
59
|
+
when Array
|
|
60
|
+
value.map { |v| resolve(v) }
|
|
61
|
+
else
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# "a1 74 ff" -> binary string
|
|
67
|
+
def hex_to_bytes(hex)
|
|
68
|
+
hex.split.map { |byte| Integer(byte, 16) }.pack("C*")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A tiny S-expression reader + tape interpreter. The whole grammar:
|
|
72
|
+
# list = "(" form* ")" ; atom = token | "string" | :keyword | number | true|false|nil ; ; comment
|
|
73
|
+
# Data mapping (unambiguous by each list's shape):
|
|
74
|
+
# (type :k v ...) FRAME -> {"t"=>type, k=>v} (:k v ...) MAP -> {k=>v}
|
|
75
|
+
# (a b c) LIST -> [a,b,c] (bin "b64") -> bytes
|
|
76
|
+
# A tape file is (tape NAME (ROLE ...config) <transcript>...). The transcript is
|
|
77
|
+
# flat and reads like a recording; #interpret groups it into the step shape the
|
|
78
|
+
# runners consume ({recv|do, emit:[...], reject} / {process, out:[...], exit, stdout}).
|
|
79
|
+
module Sexp
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
def load(text)
|
|
83
|
+
read_all(text).map { |form| interpret(form) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def read_all(text)
|
|
87
|
+
toks = tokenize(text)
|
|
88
|
+
forms = []
|
|
89
|
+
forms << read_form(toks) until toks.empty?
|
|
90
|
+
forms
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tokenize(str)
|
|
94
|
+
toks = []
|
|
95
|
+
i = 0
|
|
96
|
+
n = str.length
|
|
97
|
+
while i < n
|
|
98
|
+
c = str[i]
|
|
99
|
+
if c == ";"
|
|
100
|
+
i += 1 while i < n && str[i] != "\n"
|
|
101
|
+
elsif c =~ /\s/
|
|
102
|
+
i += 1
|
|
103
|
+
elsif c == "(" || c == ")"
|
|
104
|
+
toks << c
|
|
105
|
+
i += 1
|
|
106
|
+
elsif c == '"'
|
|
107
|
+
j = i + 1
|
|
108
|
+
buf = +""
|
|
109
|
+
while j < n && str[j] != '"'
|
|
110
|
+
if str[j] == "\\"
|
|
111
|
+
buf << { "n" => "\n", "t" => "\t", "r" => "\r" }.fetch(str[j + 1], str[j + 1])
|
|
112
|
+
j += 2
|
|
113
|
+
else
|
|
114
|
+
buf << str[j]
|
|
115
|
+
j += 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
toks << [:str, buf]
|
|
119
|
+
i = j + 1
|
|
120
|
+
else
|
|
121
|
+
j = i
|
|
122
|
+
j += 1 while j < n && !"() \t\r\n;\"".include?(str[j])
|
|
123
|
+
toks << [:tok, str[i...j]]
|
|
124
|
+
i = j
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
toks
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read_form(toks)
|
|
131
|
+
t = toks.shift
|
|
132
|
+
if t == "("
|
|
133
|
+
list = []
|
|
134
|
+
list << read_form(toks) until toks.first == ")"
|
|
135
|
+
toks.shift
|
|
136
|
+
list
|
|
137
|
+
elsif t.is_a?(Array) && t[0] == :str
|
|
138
|
+
t[1]
|
|
139
|
+
else
|
|
140
|
+
atom(t[1])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def atom(text)
|
|
145
|
+
case text
|
|
146
|
+
when "true" then true
|
|
147
|
+
when "false" then false
|
|
148
|
+
when "nil" then nil
|
|
149
|
+
else
|
|
150
|
+
if text.start_with?(":") then text[1..].to_sym
|
|
151
|
+
elsif text.match?(/\A-?\d+\z/) then text.to_i
|
|
152
|
+
elsif text.match?(/\A-?\d+\.\d+\z/) then text.to_f
|
|
153
|
+
else text
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def value(form)
|
|
159
|
+
return form unless form.is_a?(Array)
|
|
160
|
+
return [] if form.empty?
|
|
161
|
+
|
|
162
|
+
head = form[0]
|
|
163
|
+
if head.is_a?(Symbol)
|
|
164
|
+
to_map(form)
|
|
165
|
+
elsif head == "bin"
|
|
166
|
+
Base64.decode64(form[1]).b
|
|
167
|
+
elsif form[1..].any? { |e| e.is_a?(Symbol) }
|
|
168
|
+
{ "t" => head }.merge(to_map(form[1..]))
|
|
169
|
+
else
|
|
170
|
+
form.map { |e| value(e) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_map(pairs)
|
|
175
|
+
h = {}
|
|
176
|
+
pairs.each_slice(2) { |k, v| h[k.to_s] = value(v) }
|
|
177
|
+
h
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def interpret(form)
|
|
181
|
+
_tape, name, config_form, *transcript = form
|
|
182
|
+
role = config_form[0]
|
|
183
|
+
config = to_map(config_form[1..])
|
|
184
|
+
steps = role == "client" ? group_client(transcript) : group_server(transcript)
|
|
185
|
+
{ "name" => name, "role" => role, "config" => config, "tape" => steps }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def group_server(forms)
|
|
189
|
+
steps = []
|
|
190
|
+
forms.each do |f|
|
|
191
|
+
case f[0]
|
|
192
|
+
when "recv" then steps << { "recv" => value(f[1]), "emit" => [] }
|
|
193
|
+
when "do" then steps << { "do" => action(f[1]), "emit" => [] }
|
|
194
|
+
when "send" then steps.last["emit"] << { "send" => value(f[1]) }
|
|
195
|
+
when "event"
|
|
196
|
+
ev = { "event" => f[1].to_s }
|
|
197
|
+
ev["data"] = to_map(f[2..]) unless f[2..].empty?
|
|
198
|
+
steps.last["emit"] << ev
|
|
199
|
+
when "reject" then steps.last["reject"] = true
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
steps
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def group_client(forms)
|
|
206
|
+
steps = []
|
|
207
|
+
forms.each do |f|
|
|
208
|
+
case f[0]
|
|
209
|
+
when "process" then steps << { "process" => value(f[1]), "out" => [] }
|
|
210
|
+
when "out" then steps.last["out"] << value(f[1])
|
|
211
|
+
when "exit" then steps.last["exit"] = f[1]
|
|
212
|
+
when "stdout" then steps.last["stdout"] = f[1]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
steps
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def action(form)
|
|
219
|
+
head = form[0]
|
|
220
|
+
rest = form[1..]
|
|
221
|
+
if rest.length == 1 && !rest[0].is_a?(Symbol) && !rest[0].is_a?(Array)
|
|
222
|
+
{ head => rest[0] }
|
|
223
|
+
else
|
|
224
|
+
{ head => to_map(rest) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised into the CLI thread to deliver a client Ctrl-C, like a local SIGINT —
|
|
7
|
+
# but NOT a SignalException (Ruby's Interrupt). Raising the real Interrupt into a
|
|
8
|
+
# thread inside a Falcon worker disturbs the async reactor and kills the
|
|
9
|
+
# connection; a plain Exception subclass interrupts blocking calls without that.
|
|
10
|
+
# Subclasses Exception (not StandardError) so user CLI code's `rescue
|
|
11
|
+
# StandardError` can't swallow it — only the Handler catches it -> exit 130.
|
|
12
|
+
class Interrupted < Exception; end
|
|
13
|
+
|
|
14
|
+
# Raised when bytes off the wire are not a well-formed frame.
|
|
15
|
+
class ProtocolError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised on the server side when a `response` came back with ok: false.
|
|
18
|
+
class ResponseError < Error
|
|
19
|
+
attr_reader :code
|
|
20
|
+
|
|
21
|
+
def initialize(code, message)
|
|
22
|
+
@code = code
|
|
23
|
+
super("#{code}: #{message}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
# Builders for each frame type. Keep frame construction in one place so the wire
|
|
5
|
+
# shape is defined once. All builders return a Hash with string keys.
|
|
6
|
+
module Frames
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def hello(protocol:, capabilities:, program:, entitlement:, terminal: DEFAULT_TERMINAL, flow: DEFAULT_FLOW)
|
|
10
|
+
{
|
|
11
|
+
"t" => Protocol::Type::HELLO, "sid" => Protocol::CONTROL_SID,
|
|
12
|
+
"protocol" => protocol, "capabilities" => capabilities,
|
|
13
|
+
"program" => program, "entitlement" => entitlement,
|
|
14
|
+
"terminal" => terminal, "flow" => flow
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The client's initial flow-control offer: how many bytes of output it will
|
|
19
|
+
# accept per stream before the server must wait for a window_adjust.
|
|
20
|
+
DEFAULT_FLOW = { "window" => Protocol::DEFAULT_WINDOW }.freeze
|
|
21
|
+
|
|
22
|
+
# Client -> server: extend the output window for a stream by `bytes`.
|
|
23
|
+
def window_adjust(sid:, bytes:)
|
|
24
|
+
{ "t" => Protocol::Type::WINDOW_ADJUST, "sid" => sid, "bytes" => bytes }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The client's terminal at connect time (structured per TERMINAL.md: per-stream
|
|
28
|
+
# kinds + a device block); resize/mode frames update the device thereafter.
|
|
29
|
+
DEFAULT_TERMINAL = {
|
|
30
|
+
"stdin" => { "kind" => "tty" },
|
|
31
|
+
"stdout" => { "kind" => "tty" },
|
|
32
|
+
"stderr" => { "kind" => "tty" },
|
|
33
|
+
"device" => {
|
|
34
|
+
"cols" => 80, "rows" => 24, "xpixels" => 0, "ypixels" => 0,
|
|
35
|
+
"term" => "", "color" => "none", "encoding" => "UTF-8", "mode" => "cooked"
|
|
36
|
+
}
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Generic async terminal signal (client -> server). resize/interrupt are the
|
|
40
|
+
# named variants; collapsing them into one frame type keeps the protocol small.
|
|
41
|
+
def signal(name, payload = {})
|
|
42
|
+
{ "t" => Protocol::Type::SIGNAL, "sid" => Protocol::CONTROL_SID, "name" => name }.merge(payload)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resize(cols:, rows:)
|
|
46
|
+
signal(Protocol::Signal::RESIZE, { "cols" => cols, "rows" => rows })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def interrupt
|
|
50
|
+
signal(Protocol::Signal::INTERRUPT)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def welcome(protocol:, capabilities:)
|
|
54
|
+
{
|
|
55
|
+
"t" => Protocol::Type::WELCOME, "sid" => Protocol::CONTROL_SID,
|
|
56
|
+
"protocol" => protocol, "capabilities" => capabilities
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def incompatible(supported:, message:)
|
|
61
|
+
# Normalize to string keys so every wire frame is uniformly string-keyed
|
|
62
|
+
# (the negotiator hands us a symbol-keyed Ruby hash).
|
|
63
|
+
min = supported[:min] || supported["min"]
|
|
64
|
+
max = supported[:max] || supported["max"]
|
|
65
|
+
{
|
|
66
|
+
"t" => Protocol::Type::INCOMPATIBLE, "sid" => Protocol::CONTROL_SID,
|
|
67
|
+
"supported" => { "min" => min, "max" => max }, "message" => message
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def exit(status:)
|
|
72
|
+
{ "t" => Protocol::Type::EXIT, "sid" => Protocol::CONTROL_SID, "status" => status }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def open(sid:, stream:, mode: nil)
|
|
76
|
+
frame = { "t" => Protocol::Type::OPEN, "sid" => sid, "stream" => stream }
|
|
77
|
+
frame["mode"] = mode if mode # input streams carry the line-discipline mode
|
|
78
|
+
frame
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def data(sid:, bytes:)
|
|
82
|
+
{ "t" => Protocol::Type::DATA, "sid" => sid, "bytes" => bytes.b }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def close(sid:)
|
|
86
|
+
{ "t" => Protocol::Type::CLOSE, "sid" => sid }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def request(sid:, resource:, method:, params: {})
|
|
90
|
+
{
|
|
91
|
+
"t" => Protocol::Type::REQUEST, "sid" => sid,
|
|
92
|
+
"resource" => resource, "method" => method, "params" => params
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def response_ok(sid:, value:)
|
|
97
|
+
{ "t" => Protocol::Type::RESPONSE, "sid" => sid, "ok" => true, "value" => value }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def response_error(sid:, code:, message:)
|
|
101
|
+
{
|
|
102
|
+
"t" => Protocol::Type::RESPONSE, "sid" => sid, "ok" => false,
|
|
103
|
+
"error" => { "code" => code, "message" => message }
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
# Allocates stream ids and correlates in-flight requests to their responses.
|
|
5
|
+
# The starting id is injectable so recorded vectors replay deterministically.
|
|
6
|
+
class Mux
|
|
7
|
+
def initialize(start: 1)
|
|
8
|
+
raise ArgumentError, "start must be >= 1 (0 is the control stream)" if start < 1
|
|
9
|
+
|
|
10
|
+
@next = start
|
|
11
|
+
@pending = {}
|
|
12
|
+
# The runtime allocates/registers from the caller thread while the read
|
|
13
|
+
# pump resolves from its own thread, so the registry is mutex-guarded.
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Allocate a fresh stream id.
|
|
18
|
+
def allocate
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
sid = @next
|
|
21
|
+
@next += 1
|
|
22
|
+
sid
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Mark a request stream as awaiting a response, stashing caller context.
|
|
27
|
+
def register(sid, context = nil)
|
|
28
|
+
@mutex.synchronize { @pending[sid] = context }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pending?(sid)
|
|
32
|
+
@mutex.synchronize { @pending.key?(sid) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resolve a pending request, returning (and removing) its context.
|
|
36
|
+
def resolve(sid)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
raise ProtocolError, "response for unknown stream #{sid}" unless @pending.key?(sid)
|
|
39
|
+
|
|
40
|
+
@pending.delete(sid)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pending_count
|
|
45
|
+
@mutex.synchronize { @pending.size }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
# Pure handshake negotiation: given what the client speaks and what the server
|
|
5
|
+
# supports, decide the agreed protocol version and capability set. This is a
|
|
6
|
+
# function, not a state machine, so it is trivially testable and identical
|
|
7
|
+
# across languages (see conformance/vectors/negotiate).
|
|
8
|
+
module Negotiator
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @return [Hash] either
|
|
12
|
+
# { decision: "welcome", protocol: Integer, capabilities: Array<String> }
|
|
13
|
+
# or
|
|
14
|
+
# { decision: "incompatible", supported: { min:, max: } }
|
|
15
|
+
def negotiate(client_protocol:, client_capabilities:, server_min:, server_max:, server_capabilities:)
|
|
16
|
+
if client_protocol < server_min
|
|
17
|
+
return {
|
|
18
|
+
decision: "incompatible",
|
|
19
|
+
supported: { min: server_min, max: server_max }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
agreed = [client_protocol, server_max].min
|
|
24
|
+
# Intersection, preserving the client's advertised order.
|
|
25
|
+
capabilities = client_capabilities & server_capabilities
|
|
26
|
+
|
|
27
|
+
{ decision: "welcome", protocol: agreed, capabilities: capabilities }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
# Wire-level constants for the v2 protocol. See ../../PROTOCOL.md.
|
|
5
|
+
module Protocol
|
|
6
|
+
# Frame protocol version this implementation speaks.
|
|
7
|
+
VERSION = 2
|
|
8
|
+
|
|
9
|
+
# Range of protocol versions this implementation can negotiate.
|
|
10
|
+
MIN_VERSION = 2
|
|
11
|
+
MAX_VERSION = 2
|
|
12
|
+
|
|
13
|
+
# The reserved control stream id.
|
|
14
|
+
CONTROL_SID = 0
|
|
15
|
+
|
|
16
|
+
# Capabilities advertised by a fully-featured client/server. Negotiation
|
|
17
|
+
# intersects the two sides' sets, so a peer only uses a feature the other
|
|
18
|
+
# advertises. ADD a capability here when you add an optional feature; that is
|
|
19
|
+
# the additive-change path (old peers simply won't list it, and the feature
|
|
20
|
+
# stays dormant for them).
|
|
21
|
+
#
|
|
22
|
+
# NOTE: negotiation records the intersection, but the server does NOT currently
|
|
23
|
+
# refuse a request() for a capability the client didn't advertise — Context
|
|
24
|
+
# issues file/directory/browser/env/terminal-query requests regardless, and the
|
|
25
|
+
# CLIENT is the enforcement point (it rejects an un-granted or un-negotiated op).
|
|
26
|
+
# Don't rely on this list as a server-side gate; it isn't one yet.
|
|
27
|
+
CAPABILITIES = %w[
|
|
28
|
+
stdio file directory browser env
|
|
29
|
+
signal flow raw-input terminal-query
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Output stream names (open.stream).
|
|
33
|
+
module Stream
|
|
34
|
+
STDOUT = "stdout"
|
|
35
|
+
STDERR = "stderr"
|
|
36
|
+
STDIN_RAW = "stdin-raw"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Terminal line-discipline modes the client applies to its real terminal while
|
|
40
|
+
# a raw input stream / secret read is open (see ../../TERMINAL.md §5).
|
|
41
|
+
module Mode
|
|
42
|
+
COOKED = "cooked" # line editing + echo + signal keys (default)
|
|
43
|
+
NOECHO = "noecho" # cooked, echo off (passwords)
|
|
44
|
+
CBREAK = "cbreak" # char-at-a-time, echo ON, signal keys ON (single-key y/n)
|
|
45
|
+
RAW = "raw" # char-at-a-time, echo off, signals delivered as bytes (TUIs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Frame types.
|
|
49
|
+
module Type
|
|
50
|
+
HELLO = "hello"
|
|
51
|
+
WELCOME = "welcome"
|
|
52
|
+
INCOMPATIBLE = "incompatible"
|
|
53
|
+
EXIT = "exit"
|
|
54
|
+
OPEN = "open"
|
|
55
|
+
DATA = "data"
|
|
56
|
+
CLOSE = "close"
|
|
57
|
+
REQUEST = "request"
|
|
58
|
+
RESPONSE = "response"
|
|
59
|
+
SIGNAL = "signal"
|
|
60
|
+
WINDOW_ADJUST = "window_adjust"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Names carried by a `signal` frame (client -> server, async terminal events).
|
|
64
|
+
module Signal
|
|
65
|
+
RESIZE = "resize"
|
|
66
|
+
INTERRUPT = "interrupt"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Default initial per-output-stream flow-control window (bytes) the client
|
|
70
|
+
# grants the server. Must be >= the server's output chunk size so a single
|
|
71
|
+
# chunk can never exceed an empty window and deadlock.
|
|
72
|
+
DEFAULT_WINDOW = 256 * 1024
|
|
73
|
+
|
|
74
|
+
# Hard ceiling on a flow window (bytes). A window can never grow past this no
|
|
75
|
+
# matter what a peer offers or grants — the credit ledger clamps to it (see
|
|
76
|
+
# Window). This bounds how much output a server may buffer ahead of a slow or
|
|
77
|
+
# hostile client: without it, a client could offer/grant an enormous window and
|
|
78
|
+
# dissolve backpressure entirely, ballooning the server's transport buffers. 64×
|
|
79
|
+
# the default offer — ample for any terminal stream, far below a memory hazard.
|
|
80
|
+
MAX_WINDOW = 16 * 1024 * 1024
|
|
81
|
+
|
|
82
|
+
# Error codes carried on a `response` with ok: false.
|
|
83
|
+
module ErrorCode
|
|
84
|
+
DENIED = "denied"
|
|
85
|
+
NOT_FOUND = "not_found"
|
|
86
|
+
IO = "io"
|
|
87
|
+
PROTOCOL = "protocol"
|
|
88
|
+
INTERNAL = "internal"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|