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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "json"
|
|
6
|
+
require "jwt" # legacy (signed-only) session read + migration
|
|
7
|
+
require "active_support/key_generator"
|
|
8
|
+
require "active_support/message_encryptor"
|
|
9
|
+
require "active_support/core_ext/time" # Time#advance, used by expires_in:
|
|
10
|
+
|
|
11
|
+
require "terminalwire/v2" # full v2 server (runtime, handler, …)
|
|
12
|
+
require "terminalwire/v2/server/rack" # the v2 Rack endpoint
|
|
13
|
+
require "terminalwire/v2/server/dual_thor" # one Thor CLI, both protocols
|
|
14
|
+
|
|
15
|
+
# Rails `session` parity (v1 -> v2 with NO Thor app changes). v1's Rails shell
|
|
16
|
+
# (Terminalwire::Rails::Thor::Shell) exposes a JWT-backed `session`; the plain v2
|
|
17
|
+
# shell doesn't — so unchanged v1 Thor code that touches `session` (current_user,
|
|
18
|
+
# whoami, login) raised NoMethodError over v2. Reopen the v2 shell to provide the
|
|
19
|
+
# SAME session, backed by the protocol-agnostic context (file/directory/storage_path,
|
|
20
|
+
# which v2 implements identically). PUBLIC, not protected: Ruby 4's Forwardable —
|
|
21
|
+
# used by the v1 `def_delegators :shell, :session` — refuses to forward to a
|
|
22
|
+
# non-public method (the "forwarding to private method" warning is now a hard error).
|
|
23
|
+
# Backed by the v2-native Terminalwire::V2::Rails::Session (below) — no v1 gem needed.
|
|
24
|
+
Terminalwire::V2::Server::Thor::Shell.class_eval do
|
|
25
|
+
def session
|
|
26
|
+
@session ||= Terminalwire::V2::Rails::Session.new(context: context)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Delegate `session` from the CLI instance to its shell — matching v1's
|
|
31
|
+
# `def_delegators :shell, :session` — so unchanged Thor code that calls `session`
|
|
32
|
+
# (current_user, login, whoami) works over v2 without app changes.
|
|
33
|
+
Terminalwire::V2::Server::Thor::Helpers.module_eval do
|
|
34
|
+
def session = shell.session
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module Terminalwire
|
|
38
|
+
module V2
|
|
39
|
+
# Drop-in Rails integration for serving a Terminalwire CLI over BOTH the v1
|
|
40
|
+
# and v2 wire on a SINGLE endpoint during the transition. The whole wiring is
|
|
41
|
+
# one line in config/routes.rb:
|
|
42
|
+
#
|
|
43
|
+
# require "terminalwire/v2/rails"
|
|
44
|
+
# match "/terminal", to: Terminalwire::V2::Rails.dual_terminal(MainTerminal),
|
|
45
|
+
# via: [:get, :connect]
|
|
46
|
+
#
|
|
47
|
+
# A v2 client advertises the `terminalwire.v2` WebSocket subprotocol on the
|
|
48
|
+
# upgrade; the dispatcher detects that (before the socket is accepted) and routes
|
|
49
|
+
# to the v2 server, otherwise to the unchanged v1 handler. Same URL for both —
|
|
50
|
+
# the exec launcher's `url:` never changes. `dualize` extends the Thor tree so
|
|
51
|
+
# it's the SAME CLI answering on either wire, not a v2 fork.
|
|
52
|
+
#
|
|
53
|
+
# For a v2-only app (no v1 sub-gems), skip this and just
|
|
54
|
+
# `mount Terminalwire::V2::Server::Rack.new(cli)` directly.
|
|
55
|
+
module Rails
|
|
56
|
+
SUBPROTOCOL = "terminalwire.v2"
|
|
57
|
+
|
|
58
|
+
# A session stored on the CLIENT — the v2-native version of v1's
|
|
59
|
+
# Terminalwire::Rails::Session, so a v2-only app needs no v1 gem. It reads/writes
|
|
60
|
+
# via the context (file/directory/storage_path, which v2 implements identically
|
|
61
|
+
# to v1).
|
|
62
|
+
#
|
|
63
|
+
# The payload is ENCRYPTED and signed (ActiveSupport::MessageEncryptor,
|
|
64
|
+
# AES-256-GCM — the same primitive behind Rails' encrypted cookies), so the
|
|
65
|
+
# contents are confidential on the client machine, not just tamper-proof. The
|
|
66
|
+
# key is derived from the app's secret_key_base with a Terminalwire-specific
|
|
67
|
+
# salt, so it is never the raw secret and never shared with any other
|
|
68
|
+
# verifier/encryptor in the app. Sessions expire (default 30 days, clock reset
|
|
69
|
+
# on every write); an expired session reads as empty.
|
|
70
|
+
#
|
|
71
|
+
# Earlier releases stored the session as an HS256 JWT — signed but NOT
|
|
72
|
+
# encrypted, despite docs saying otherwise. Those legacy sessions are read one
|
|
73
|
+
# last time and immediately rewritten encrypted, so upgrading logs nobody out.
|
|
74
|
+
#
|
|
75
|
+
# Resilient by design: a missing, empty, tampered, expired, or wrong-key session
|
|
76
|
+
# reads as EMPTY, so the user simply logs in again — upgrading (or rotating the
|
|
77
|
+
# secret) never crashes a command, it just signs them out.
|
|
78
|
+
class Session
|
|
79
|
+
# Kept from the JWT era so upgraded clients keep using the same file.
|
|
80
|
+
FILENAME = "session.jwt"
|
|
81
|
+
EMPTY_SESSION = {}.freeze
|
|
82
|
+
# Key-derivation salt — Terminalwire-specific so the derived key can't
|
|
83
|
+
# collide with any key the app derives from secret_key_base elsewhere.
|
|
84
|
+
KEY_SALT = "terminalwire session"
|
|
85
|
+
# AEAD cipher: encrypts AND authenticates in one primitive (the same one
|
|
86
|
+
# behind Rails' encrypted cookies). Pinned explicitly, not left to the app's
|
|
87
|
+
# ActiveSupport default (which is aes-256-cbc unless configured otherwise).
|
|
88
|
+
CIPHER = "aes-256-gcm"
|
|
89
|
+
DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 30 # 30 days, refreshed on every write
|
|
90
|
+
|
|
91
|
+
extend Forwardable
|
|
92
|
+
def_delegators :read, :dig, :fetch, :[]
|
|
93
|
+
|
|
94
|
+
def initialize(context:, path: nil, secret_key: self.class.secret_key, expires_in: DEFAULT_EXPIRES_IN)
|
|
95
|
+
@context = context
|
|
96
|
+
@path = Pathname.new(path || context.storage_path)
|
|
97
|
+
@config_file_path = @path.join(FILENAME)
|
|
98
|
+
@secret_key = secret_key
|
|
99
|
+
@expires_in = expires_in
|
|
100
|
+
ensure_file
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The session payload, or EMPTY_SESSION when there isn't a valid one (missing,
|
|
104
|
+
# empty, tampered, expired, wrong key, unreadable). To the user these all mean
|
|
105
|
+
# the same thing — log in again — so none of them raise.
|
|
106
|
+
def read
|
|
107
|
+
token = @context.file.read(@config_file_path)
|
|
108
|
+
return EMPTY_SESSION if token.nil? || token.to_s.empty?
|
|
109
|
+
|
|
110
|
+
encryptor.decrypt_and_verify(token) || EMPTY_SESSION
|
|
111
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
112
|
+
read_and_migrate_legacy(token)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
EMPTY_SESSION
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reset
|
|
118
|
+
@context.file.delete(@config_file_path)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def edit
|
|
124
|
+
config = read.dup
|
|
125
|
+
yield config
|
|
126
|
+
write(config)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def []=(key, value)
|
|
130
|
+
edit { |config| config[key] = value }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def write(config)
|
|
134
|
+
token = encryptor.encrypt_and_sign(config, expires_in: @expires_in)
|
|
135
|
+
@context.file.write(@config_file_path, token)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.secret_key
|
|
139
|
+
::Rails.application.secret_key_base
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def encryptor
|
|
145
|
+
@encryptor ||= ActiveSupport::MessageEncryptor.new(derived_key, cipher: CIPHER, serializer: JSON)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Explicit digest so the key doesn't shift with the app's
|
|
149
|
+
# ActiveSupport::KeyGenerator.hash_digest_class config (which would silently
|
|
150
|
+
# invalidate every session).
|
|
151
|
+
def derived_key
|
|
152
|
+
ActiveSupport::KeyGenerator
|
|
153
|
+
.new(@secret_key, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA256)
|
|
154
|
+
.generate_key(KEY_SALT, ActiveSupport::MessageEncryptor.key_len(CIPHER))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Sessions written by earlier releases are HS256 JWTs: signed, NOT encrypted.
|
|
158
|
+
# Verify one last time and immediately rewrite encrypted, so upgrading never
|
|
159
|
+
# logs anyone out. Anything unreadable is just an empty session.
|
|
160
|
+
def read_and_migrate_legacy(token)
|
|
161
|
+
payload = JWT.decode(token, @secret_key, true, algorithm: "HS256").first
|
|
162
|
+
return EMPTY_SESSION unless payload
|
|
163
|
+
|
|
164
|
+
write(payload)
|
|
165
|
+
payload
|
|
166
|
+
rescue StandardError
|
|
167
|
+
EMPTY_SESSION
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Best-effort: seed an empty session file if absent. A failure here is not
|
|
171
|
+
# fatal — read/write degrade gracefully on their own.
|
|
172
|
+
def ensure_file
|
|
173
|
+
return true if file_exist?
|
|
174
|
+
|
|
175
|
+
@context.directory.create(@path)
|
|
176
|
+
write(EMPTY_SESSION)
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def file_exist?
|
|
182
|
+
@context.file.exist?(@config_file_path)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Drop-in Rails terminal mixin — the v2 equivalent of v1's `Terminalwire::Thor`.
|
|
189
|
+
# `include Terminalwire::V2::Rails::Thor` in your Thor CLI and it:
|
|
190
|
+
# * streams I/O over the v2 wire (Terminalwire::V2::Server::Thor),
|
|
191
|
+
# * exposes the client `session` (the shell delegator), and
|
|
192
|
+
# * mixes in Rails route URL helpers (root_url, *_url, *_path) so commands like
|
|
193
|
+
# login/browser-open can build links. The per-connection host is set by the
|
|
194
|
+
# handler, so the *_url helpers resolve to the host the client connected on.
|
|
195
|
+
# The url_helpers go in `no_commands` so Thor doesn't register them as commands.
|
|
196
|
+
module Thor
|
|
197
|
+
def self.included(base)
|
|
198
|
+
base.include Terminalwire::V2::Server::Thor
|
|
199
|
+
base.class_eval do
|
|
200
|
+
no_commands do
|
|
201
|
+
include ::Rails.application.routes.url_helpers
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Returns a Rack endpoint that serves `cli` over both protocols. Pass `v1:`/`v2:`
|
|
208
|
+
# to override the handlers (tests, custom adapters); by default it builds the
|
|
209
|
+
# stock v1 `Terminalwire::Rails::Thor` and v2 `Terminalwire::V2::Server::Rack`.
|
|
210
|
+
def self.dual_terminal(cli, v1: nil, v2: nil)
|
|
211
|
+
Terminalwire::V2::Server.dualize(cli)
|
|
212
|
+
Dispatcher.new(
|
|
213
|
+
v1: v1 || default_v1(cli),
|
|
214
|
+
v2: v2 || Terminalwire::V2::Server::Rack.new(cli, verbose: verbose?, report: report)
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The v2-DEFAULT endpoint: serve `cli` over v2, with no v1. Mount it the same way
|
|
219
|
+
# as dual_terminal:
|
|
220
|
+
#
|
|
221
|
+
# match "/terminal", to: Terminalwire::V2::Rails.terminal(MainTerminal),
|
|
222
|
+
# via: [:get, :connect]
|
|
223
|
+
#
|
|
224
|
+
# It returns a version endpoint (not the bare Rack): a connection advertising the
|
|
225
|
+
# `terminalwire.v2` subprotocol — and any connection that doesn't ask for another
|
|
226
|
+
# version — is served by the v2 server. The endpoint is the forward-compatible
|
|
227
|
+
# seam: a future v3 registers another handler here without changing the app's
|
|
228
|
+
# route. (A bare Rack handed to `match to:` drops streaming output in production;
|
|
229
|
+
# the endpoint, like dual_terminal's, is what Rails routing needs.)
|
|
230
|
+
def self.terminal(cli, verbose: nil, report: nil)
|
|
231
|
+
Terminalwire::V2::Server.terminalize(cli)
|
|
232
|
+
v2 = Terminalwire::V2::Server::Rack.new(
|
|
233
|
+
cli,
|
|
234
|
+
verbose: verbose.nil? ? verbose?() : verbose,
|
|
235
|
+
report: report || self.report
|
|
236
|
+
)
|
|
237
|
+
VersionEndpoint.new(default: v2, by_subprotocol: { SUBPROTOCOL => v2 })
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# In dev/test, show the full backtrace to the client (consider_all_requests_local,
|
|
241
|
+
# like v1). In production the client sees the generic message — but the real
|
|
242
|
+
# exception is still LOGGED + reported (below), never silently swallowed.
|
|
243
|
+
def self.verbose?
|
|
244
|
+
::Rails.application.config.consider_all_requests_local
|
|
245
|
+
rescue StandardError
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Log + report unexpected command errors to Rails (mirrors the v1 handler).
|
|
250
|
+
# Without this the v2 Handler drops the exception on the floor behind the
|
|
251
|
+
# generic message, which is exactly what made the missing-host bug hard to find.
|
|
252
|
+
def self.report
|
|
253
|
+
lambda do |error|
|
|
254
|
+
::Rails.error.report(error, handled: true) if ::Rails.respond_to?(:error)
|
|
255
|
+
::Rails.logger&.error("terminalwire: #{error.class}: #{error.message}\n#{Array(error.backtrace).join("\n")}")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Lazily resolve the v1 handler so this gem doesn't hard-depend on the v1
|
|
260
|
+
# `terminalwire-rails` gem at load time. Apps doing the transition have it;
|
|
261
|
+
# if not, fail with a clear message instead of a NameError.
|
|
262
|
+
def self.default_v1(cli)
|
|
263
|
+
unless defined?(Terminalwire::Rails::Thor)
|
|
264
|
+
raise "terminalwire/v2/rails: the v1 handler (Terminalwire::Rails::Thor) " \
|
|
265
|
+
"isn't loaded. Add the v1 `terminalwire-rails` gem, or pass v2:-only " \
|
|
266
|
+
"and mount Terminalwire::V2::Server::Rack directly for a v2-only app."
|
|
267
|
+
end
|
|
268
|
+
Terminalwire::Rails::Thor.new(cli)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Routes a WebSocket upgrade to the v1 or v2 handler by inspecting the
|
|
272
|
+
# advertised subprotocol on the Rack env — no socket is accepted until the
|
|
273
|
+
# branch is chosen, so a connection only ever reaches one handler.
|
|
274
|
+
class Dispatcher
|
|
275
|
+
def initialize(v1:, v2:)
|
|
276
|
+
@v1 = v1
|
|
277
|
+
@v2 = v2
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def call(env)
|
|
281
|
+
protos = env["HTTP_SEC_WEBSOCKET_PROTOCOL"].to_s.split(/,\s*/)
|
|
282
|
+
(protos.include?(SUBPROTOCOL) ? @v2 : @v1).call(env)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Routes a WebSocket upgrade to a handler by the version subprotocol it advertises,
|
|
287
|
+
# falling back to `default` (v2) when none matches — the forward-compatible seam for
|
|
288
|
+
# registering future protocol versions. Same Rack-endpoint shape as Dispatcher, so
|
|
289
|
+
# Rails `match to:` hands the connection off correctly in production.
|
|
290
|
+
class VersionEndpoint
|
|
291
|
+
def initialize(default:, by_subprotocol: {})
|
|
292
|
+
@default = default
|
|
293
|
+
@by_subprotocol = by_subprotocol
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def call(env)
|
|
297
|
+
protos = env["HTTP_SEC_WEBSOCKET_PROTOCOL"].to_s.split(/,\s*/)
|
|
298
|
+
handler = protos.lazy.filter_map { |proto| @by_subprotocol[proto] }.first || @default
|
|
299
|
+
handler.call(env)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# --- Drop-in v1 API -----------------------------------------------------------
|
|
307
|
+
# A 1.x/0.x app upgrades to v2 by bumping the gem version and redeploying — nothing
|
|
308
|
+
# else. Its unchanged `include Terminalwire::Thor` and
|
|
309
|
+
# `match "/terminal", to: Terminalwire::Rails::Thor.new(MainTerminal)` keep working,
|
|
310
|
+
# now serving v2, because these names resolve to the v2 implementations.
|
|
311
|
+
#
|
|
312
|
+
# Guarded with `defined?` so a transitional app that still loads the v1 gems keeps
|
|
313
|
+
# v1's classes (and drives both wires with `dual_terminal` explicitly); only a
|
|
314
|
+
# v2-only app (no v1 gems) picks up these.
|
|
315
|
+
module Terminalwire
|
|
316
|
+
# `include Terminalwire::Thor` -> the v2 Rails terminal mixin.
|
|
317
|
+
Thor = V2::Rails::Thor unless defined?(Terminalwire::Thor)
|
|
318
|
+
|
|
319
|
+
module Rails
|
|
320
|
+
# `Terminalwire::Rails::Thor.new(cli)` -> a Rack endpoint serving `cli` over v2,
|
|
321
|
+
# mounted exactly like the v1 handler was.
|
|
322
|
+
unless defined?(Terminalwire::Rails::Thor)
|
|
323
|
+
class Thor
|
|
324
|
+
def initialize(cli)
|
|
325
|
+
@app = Terminalwire::V2::Rails.terminal(cli)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def call(env) = @app.call(env)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# `Terminalwire::Rails::Session` -> the v2-native client session.
|
|
333
|
+
Session = V2::Rails::Session unless defined?(Terminalwire::Rails::Session)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
module Server
|
|
5
|
+
# The server-role protocol state machine. Sans-IO: the application feeds it
|
|
6
|
+
# incoming frames via #receive (getting back directives) and asks it to build
|
|
7
|
+
# outgoing frames via the helper methods. No sockets, threads, or clock.
|
|
8
|
+
#
|
|
9
|
+
# A "directive" returned from #receive is one of:
|
|
10
|
+
# [:send, frame_hash] — write this frame to the transport
|
|
11
|
+
# [:event, name_symbol, payload] — a domain event for the application
|
|
12
|
+
class Connection
|
|
13
|
+
attr_reader :state, :protocol, :capabilities
|
|
14
|
+
|
|
15
|
+
def initialize(server_min: Protocol::MIN_VERSION,
|
|
16
|
+
server_max: Protocol::MAX_VERSION,
|
|
17
|
+
server_capabilities: Protocol::CAPABILITIES,
|
|
18
|
+
mux: Mux.new)
|
|
19
|
+
@server_min = server_min
|
|
20
|
+
@server_max = server_max
|
|
21
|
+
@server_capabilities = server_capabilities
|
|
22
|
+
@mux = mux
|
|
23
|
+
@state = :awaiting_hello
|
|
24
|
+
@protocol = nil
|
|
25
|
+
@capabilities = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ready?
|
|
29
|
+
@state == :ready
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Feed one incoming frame. Returns an Array of directives (see class docs).
|
|
33
|
+
def receive(frame)
|
|
34
|
+
case @state
|
|
35
|
+
when :awaiting_hello then on_hello(frame)
|
|
36
|
+
when :ready then on_ready(frame)
|
|
37
|
+
else
|
|
38
|
+
raise ProtocolError, "received #{frame["t"].inspect} while #{@state}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- application-driven outgoing helpers ---------------------------------
|
|
43
|
+
|
|
44
|
+
# Open a stream (:stdout/:stderr output, or a stdin-raw input stream with a
|
|
45
|
+
# line-discipline mode). Returns [sid, frame].
|
|
46
|
+
def open_stream(stream, mode: nil)
|
|
47
|
+
require_ready!
|
|
48
|
+
sid = @mux.allocate
|
|
49
|
+
[sid, Frames.open(sid: sid, stream: stream.to_s, mode: mode)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Build a data frame for an open output stream.
|
|
53
|
+
def write(sid, bytes)
|
|
54
|
+
require_ready!
|
|
55
|
+
Frames.data(sid: sid, bytes: bytes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close_stream(sid)
|
|
59
|
+
require_ready!
|
|
60
|
+
Frames.close(sid: sid)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Issue a resource request. Returns [sid, frame]; the response will arrive
|
|
64
|
+
# later via #receive as [:event, :response, ...].
|
|
65
|
+
def call(resource, method, params = {})
|
|
66
|
+
require_ready!
|
|
67
|
+
sid = @mux.allocate
|
|
68
|
+
@mux.register(sid, { resource: resource, method: method })
|
|
69
|
+
[sid, Frames.request(sid: sid, resource: resource.to_s, method: method.to_s, params: params)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def exit(status = 0)
|
|
73
|
+
@state = :closed
|
|
74
|
+
Frames.exit(status: status)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def on_hello(frame)
|
|
80
|
+
unless frame["t"] == Protocol::Type::HELLO
|
|
81
|
+
raise ProtocolError, "expected hello, got #{frame["t"].inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
protocol = frame["protocol"]
|
|
85
|
+
capabilities = frame["capabilities"]
|
|
86
|
+
# Validate hello-specific fields up front so a malformed hello is a clean
|
|
87
|
+
# ProtocolError rather than a NoMethodError deep in the negotiator.
|
|
88
|
+
raise ProtocolError, "hello protocol must be an integer" unless protocol.is_a?(Integer)
|
|
89
|
+
raise ProtocolError, "hello capabilities must be an array" unless capabilities.is_a?(Array)
|
|
90
|
+
|
|
91
|
+
result = Negotiator.negotiate(
|
|
92
|
+
client_protocol: protocol,
|
|
93
|
+
client_capabilities: capabilities,
|
|
94
|
+
server_min: @server_min,
|
|
95
|
+
server_max: @server_max,
|
|
96
|
+
server_capabilities: @server_capabilities
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if result[:decision] == "welcome"
|
|
100
|
+
@state = :ready
|
|
101
|
+
@protocol = result[:protocol]
|
|
102
|
+
@capabilities = result[:capabilities]
|
|
103
|
+
[
|
|
104
|
+
[:send, Frames.welcome(protocol: @protocol, capabilities: @capabilities)],
|
|
105
|
+
[:event, :ready, { protocol: @protocol, capabilities: @capabilities,
|
|
106
|
+
program: frame["program"], entitlement: frame["entitlement"],
|
|
107
|
+
terminal: frame["terminal"], flow: frame["flow"] }]
|
|
108
|
+
]
|
|
109
|
+
else
|
|
110
|
+
@state = :closed
|
|
111
|
+
message = "client speaks #{frame["protocol"]}; " \
|
|
112
|
+
"server supports #{@server_min}..#{@server_max}"
|
|
113
|
+
[
|
|
114
|
+
[:send, Frames.incompatible(supported: result[:supported], message: message)],
|
|
115
|
+
[:event, :incompatible, { supported: result[:supported] }]
|
|
116
|
+
]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Single uniform dispatch over the inbound frame type (mirrors the Go
|
|
121
|
+
# client's Process switch). Every client->server-while-ready frame is one
|
|
122
|
+
# case here; an unrecognized type is a protocol violation.
|
|
123
|
+
def on_ready(frame)
|
|
124
|
+
case frame["t"]
|
|
125
|
+
when Protocol::Type::SIGNAL then on_signal(frame)
|
|
126
|
+
when Protocol::Type::WINDOW_ADJUST
|
|
127
|
+
sid = frame["sid"]
|
|
128
|
+
bytes = frame["bytes"]
|
|
129
|
+
# Ignore a malformed grant rather than letting it through: a negative
|
|
130
|
+
# `bytes` would drive the window negative and permanently stall the
|
|
131
|
+
# stream, and a non-integer would crash the pump thread (TypeError).
|
|
132
|
+
if sid.is_a?(Integer) && bytes.is_a?(Integer) && bytes >= 0
|
|
133
|
+
[[:event, :window_adjust, { sid: sid, bytes: bytes }]]
|
|
134
|
+
else
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
when Protocol::Type::DATA then on_input(frame)
|
|
138
|
+
when Protocol::Type::RESPONSE then on_response(frame)
|
|
139
|
+
else
|
|
140
|
+
raise ProtocolError, "unexpected #{frame["t"].inspect} while ready"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Unsolicited terminal signals (resize/interrupt). Unknown names are ignored
|
|
145
|
+
# for forward compatibility — a newer client can send signals we don't know.
|
|
146
|
+
def on_signal(frame)
|
|
147
|
+
case frame["name"]
|
|
148
|
+
when Protocol::Signal::RESIZE
|
|
149
|
+
[[:event, :resize, { cols: frame["cols"], rows: frame["rows"] }]]
|
|
150
|
+
when Protocol::Signal::INTERRUPT
|
|
151
|
+
[[:event, :interrupt, {}]]
|
|
152
|
+
else
|
|
153
|
+
[]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Client -> server data: keystrokes on a raw input stream the server opened.
|
|
158
|
+
def on_input(frame)
|
|
159
|
+
[[:event, :input, { sid: frame["sid"], bytes: frame["bytes"] }]]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def on_response(frame)
|
|
163
|
+
# A response for an unknown/already-resolved stream (duplicate, late, or
|
|
164
|
+
# hostile) is ignored rather than crashing the session.
|
|
165
|
+
return [] unless @mux.pending?(frame["sid"])
|
|
166
|
+
|
|
167
|
+
context = @mux.resolve(frame["sid"])
|
|
168
|
+
[[:event, :response, {
|
|
169
|
+
sid: frame["sid"], ok: frame["ok"], value: frame["value"],
|
|
170
|
+
error: frame["error"], context: context
|
|
171
|
+
}]]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def require_ready!
|
|
175
|
+
raise ProtocolError, "connection not ready (state: #{@state})" unless ready?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|