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