daytona 0.196.0 → 0.198.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,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'timeout'
7
+
8
+ require 'websocket-client-simple'
9
+ require 'json'
10
+ require 'uri'
11
+
12
+ module Daytona
13
+ # Minimal Engine.IO/Socket.IO v4 client over raw WebSocket.
14
+ # Supports connect with auth, heartbeat, and event reception.
15
+ #
16
+ # Engine.IO v4 heartbeat protocol (WebSocket transport):
17
+ # - Server sends PING (type 2) every pingInterval ms
18
+ # - Client must respond with PONG (type 3) within pingTimeout ms
19
+ # - Client monitors for missing server PINGs to detect dead connections
20
+ class SocketIOClient
21
+ # Engine.IO v4 packet types
22
+ EIO_OPEN = '0'
23
+ EIO_CLOSE = '1'
24
+ EIO_PING = '2'
25
+ EIO_PONG = '3'
26
+ EIO_MESSAGE = '4'
27
+
28
+ # Socket.IO v4 packet types (inside Engine.IO messages)
29
+ SIO_CONNECT = '0'
30
+ SIO_DISCONNECT = '1'
31
+ SIO_EVENT = '2'
32
+ SIO_CONNECT_ERROR = '4'
33
+
34
+ attr_reader :connected
35
+
36
+ # @param api_url [String] The API URL (e.g., "https://app.daytona.io/api")
37
+ # @param token [String] Auth token (API key or JWT)
38
+ # @param organization_id [String, nil] Organization ID for room joining
39
+ # @param on_event [Proc] Called with (event_name, data_hash) for each Socket.IO event
40
+ # @param on_disconnect [Proc] Called when the connection is lost
41
+ # @param connect_timeout [Numeric] Connection timeout in seconds
42
+ def initialize(api_url:, token:, organization_id: nil, source: nil, sdk_version: nil, on_event: nil,
43
+ on_disconnect: nil, connect_timeout: 5)
44
+ @api_url = api_url
45
+ @token = token
46
+ @organization_id = organization_id
47
+ @source = source
48
+ @sdk_version = sdk_version
49
+ @on_event = on_event
50
+ @on_disconnect = on_disconnect
51
+ @connect_timeout = connect_timeout
52
+ @connected = false
53
+ @mutex = Mutex.new
54
+ @write_mutex = Mutex.new
55
+ @health_thread = nil
56
+ @ping_interval = 25
57
+ @ping_timeout = 20
58
+ @last_server_activity = Time.now
59
+ @ws = nil
60
+ @close_requested = false
61
+ end
62
+
63
+ # Establish the WebSocket connection and perform Socket.IO handshake.
64
+ # @return [Boolean] true if connection succeeded
65
+ # @raise [StandardError] on connection failure
66
+ def connect
67
+ ws_url = build_ws_url
68
+ connected_queue = Queue.new
69
+
70
+ # Capture self because websocket-client-simple uses instance_exec for callbacks
71
+ client = self
72
+
73
+ # Register callbacks inside the connect block so they are installed before the
74
+ # socket dials — otherwise the Engine.IO handshake can arrive before :message
75
+ # is bound, stalling the connection into a polling fallback/timeout.
76
+ @ws = WebSocket::Client::Simple.connect(ws_url) do |ws|
77
+ ws.on :message do |msg|
78
+ client.send(:handle_raw_message, msg.data.to_s, connected_queue)
79
+ end
80
+
81
+ ws.on :error do |_e|
82
+ was_connected = client.instance_variable_get(:@mutex).synchronize do
83
+ prev = client.instance_variable_get(:@connected)
84
+ client.instance_variable_set(:@connected, false)
85
+ prev
86
+ end
87
+ connected_queue.push(:error) unless was_connected
88
+ end
89
+
90
+ ws.on :close do
91
+ mutex = client.instance_variable_get(:@mutex)
92
+ was_connected = mutex.synchronize do
93
+ prev = client.instance_variable_get(:@connected)
94
+ client.instance_variable_set(:@connected, false)
95
+ prev
96
+ end
97
+ on_disconnect = client.instance_variable_get(:@on_disconnect)
98
+ close_requested = client.instance_variable_get(:@close_requested)
99
+ begin
100
+ on_disconnect&.call if was_connected && !close_requested
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ end
105
+ end
106
+
107
+ # Wait for connection with timeout
108
+ result = nil
109
+ begin
110
+ Timeout.timeout(@connect_timeout) { result = connected_queue.pop }
111
+ rescue Timeout::Error
112
+ close
113
+ raise "WebSocket connection timed out after #{@connect_timeout}s"
114
+ end
115
+
116
+ if result != :connected
117
+ close
118
+ raise "WebSocket connection failed: #{result}"
119
+ end
120
+
121
+ @mutex.synchronize { @connected }
122
+ end
123
+
124
+ # @return [Boolean]
125
+ def connected?
126
+ @mutex.synchronize { @connected }
127
+ end
128
+
129
+ # Gracefully close the connection.
130
+ def close
131
+ @close_requested = true
132
+ @health_thread&.kill
133
+ @health_thread = nil
134
+
135
+ send_raw(EIO_CLOSE) if @ws
136
+ @ws&.close
137
+ @mutex.synchronize { @connected = false }
138
+ rescue StandardError
139
+ # Ignore errors during close
140
+ end
141
+
142
+ private
143
+
144
+ def build_ws_url
145
+ parsed = URI.parse(@api_url)
146
+ ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
147
+ host = parsed.host
148
+ port = parsed.port
149
+
150
+ query_parts = ['EIO=4', 'transport=websocket']
151
+ query_parts << "organizationId=#{URI.encode_www_form_component(@organization_id)}" if @organization_id
152
+ query_parts << "source=#{URI.encode_www_form_component(@source)}" if @source
153
+ query_parts << "sdkVersion=#{URI.encode_www_form_component(@sdk_version)}" if @sdk_version
154
+
155
+ port_str = (parsed.scheme == 'https' && port == 443) || (parsed.scheme == 'http' && port == 80) ? '' : ":#{port}"
156
+ base_path = parsed.path.to_s.chomp('/')
157
+ "#{ws_scheme}://#{host}#{port_str}#{base_path}/socket.io/?#{query_parts.join('&')}"
158
+ end
159
+
160
+ def handle_raw_message(raw, connected_queue)
161
+ return if raw.nil? || raw.empty?
162
+
163
+ # Track all server activity for health monitoring.
164
+ # If the server stops sending ANY data (pings, events, etc.)
165
+ # the health monitor will detect the dead connection.
166
+ @last_server_activity = Time.now
167
+
168
+ case raw[0]
169
+ when EIO_OPEN
170
+ # Parse open payload for ping interval
171
+ begin
172
+ payload = JSON.parse(raw[1..])
173
+ @ping_interval = (payload['pingInterval'] || 25_000) / 1000.0
174
+ @ping_timeout = (payload['pingTimeout'] || 20_000) / 1000.0
175
+ rescue JSON::ParserError
176
+ # Use default ping interval
177
+ end
178
+ # Send Socket.IO CONNECT with auth
179
+ auth = JSON.generate({ token: @token })
180
+ send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
181
+
182
+ when EIO_PING
183
+ # Server heartbeat — respond immediately with PONG
184
+ send_raw(EIO_PONG)
185
+
186
+ when EIO_PONG
187
+ # Unexpected in EIO v4 (server doesn't respond to client pings),
188
+ # but handle gracefully — activity already tracked above.
189
+ nil
190
+
191
+ when EIO_MESSAGE
192
+ handle_socketio_packet(raw[1..], connected_queue)
193
+
194
+ when EIO_CLOSE
195
+ @mutex.synchronize { @connected = false }
196
+ end
197
+ end
198
+
199
+ def handle_socketio_packet(data, connected_queue)
200
+ return if data.nil? || data.empty?
201
+
202
+ case data[0]
203
+ when SIO_CONNECT
204
+ # Connection acknowledged
205
+ @mutex.synchronize { @connected = true }
206
+ start_health_monitor
207
+ connected_queue&.push(:connected)
208
+
209
+ when SIO_CONNECT_ERROR
210
+ # Connection rejected
211
+ error_msg = begin
212
+ payload = JSON.parse(data[1..])
213
+ payload['message'] || 'Unknown error'
214
+ rescue JSON::ParserError
215
+ data[1..]
216
+ end
217
+ @mutex.synchronize { @connected = false }
218
+ connected_queue&.push("Auth rejected: #{error_msg}")
219
+
220
+ when SIO_EVENT
221
+ handle_event(data[1..])
222
+
223
+ when SIO_DISCONNECT
224
+ was_connected = @mutex.synchronize do
225
+ prev = @connected
226
+ @connected = false
227
+ prev
228
+ end
229
+ @on_disconnect&.call if was_connected && !@close_requested
230
+ end
231
+ end
232
+
233
+ def handle_event(json_str)
234
+ return unless @on_event
235
+
236
+ # Skip namespace prefix if present (e.g., "/ns,")
237
+ if json_str&.start_with?('/')
238
+ comma_idx = json_str.index(',')
239
+ json_str = json_str[(comma_idx + 1)..] if comma_idx
240
+ end
241
+
242
+ event_array = JSON.parse(json_str)
243
+ return unless event_array.is_a?(Array) && event_array.length >= 1
244
+
245
+ event_name = event_array[0]
246
+ event_data = event_array[1]
247
+
248
+ @on_event.call(event_name, event_data)
249
+ rescue JSON::ParserError
250
+ # Malformed event, ignore
251
+ end
252
+
253
+ # Monitors connection health by checking for server activity.
254
+ # In Engine.IO v4, the server sends PING every pingInterval ms.
255
+ # If no server activity (pings, events, any data) is seen within
256
+ # pingInterval + pingTimeout, the connection is considered dead.
257
+ HEALTH_CHECK_INTERVAL = 5 # seconds — check frequently for fast detection
258
+
259
+ def start_health_monitor
260
+ @health_thread&.kill
261
+ @last_server_activity = Time.now
262
+ @health_thread = Thread.new do
263
+ loop do
264
+ sleep(HEALTH_CHECK_INTERVAL)
265
+ break unless connected?
266
+
267
+ if Time.now - @last_server_activity > @ping_interval + @ping_timeout
268
+ # No server activity within expected window — connection is dead
269
+ @mutex.synchronize { @connected = false }
270
+ @on_disconnect&.call unless @close_requested
271
+ # Force-close the dead WebSocket to ensure cleanup
272
+ @ws&.close rescue nil # rubocop:disable Style/RescueModifier
273
+ break
274
+ end
275
+ rescue StandardError
276
+ break
277
+ end
278
+ end
279
+ end
280
+
281
+ def send_raw(msg)
282
+ @write_mutex.synchronize do
283
+ @ws&.send(msg)
284
+ end
285
+ rescue StandardError
286
+ # Ignore write errors
287
+ end
288
+ end
289
+ end
@@ -39,6 +39,16 @@ module Daytona
39
39
  # @return [Boolean, nil]
40
40
  attr_accessor :otel_enabled
41
41
 
42
+ # Observe sandbox state by legacy polling instead of WebSocket event streaming.
43
+ # Defaults to false (event streaming). Can also be enabled via the
44
+ # DAYTONA_USE_DEPRECATED_POLLING environment variable.
45
+ #
46
+ # @deprecated Polling-only mode will be removed in a future release;
47
+ # event streaming is the default and falls back to polling automatically
48
+ # when WebSockets are unavailable.
49
+ # @return [Boolean]
50
+ attr_accessor :use_deprecated_polling
51
+
42
52
  # Experimental configuration options
43
53
  #
44
54
  # @return [Hash, nil] Experimental configuration hash
@@ -52,6 +62,9 @@ module Daytona
52
62
  # @param organization_id [String, nil] Daytona organization ID. Defaults to ENV['DAYTONA_ORGANIZATION_ID'].
53
63
  # @param target [String, nil] Daytona target. Defaults to ENV['DAYTONA_TARGET'].
54
64
  # @param otel_enabled [Boolean, nil] Enable OpenTelemetry tracing for SDK operations.
65
+ # @param use_deprecated_polling [Boolean, nil] Observe sandbox state by legacy polling instead of
66
+ # WebSocket event streaming. Defaults to false (event streaming). Can also be enabled via the
67
+ # DAYTONA_USE_DEPRECATED_POLLING environment variable.
55
68
  # @param _experimental [Hash, nil] Experimental configuration options.
56
69
  def initialize( # rubocop:disable Metrics/ParameterLists
57
70
  api_key: nil,
@@ -60,6 +73,7 @@ module Daytona
60
73
  organization_id: nil,
61
74
  target: nil,
62
75
  otel_enabled: nil,
76
+ use_deprecated_polling: nil,
63
77
  _experimental: nil
64
78
  )
65
79
  @env_reader = daytona_env_reader
@@ -71,6 +85,7 @@ module Daytona
71
85
  @organization_id = organization_id || @env_reader.call('DAYTONA_ORGANIZATION_ID')
72
86
  @otel_enabled = otel_enabled
73
87
  @_experimental = _experimental
88
+ @use_deprecated_polling = resolve_use_deprecated_polling(use_deprecated_polling)
74
89
  end
75
90
 
76
91
  # Reads a DAYTONA_-prefixed environment variable using the same precedence
@@ -105,5 +120,11 @@ module Daytona
105
120
  def daytona_filter(env_hash)
106
121
  env_hash.select { |k, _| k.start_with?('DAYTONA_') }
107
122
  end
123
+
124
+ def resolve_use_deprecated_polling(use_deprecated_polling)
125
+ return use_deprecated_polling unless use_deprecated_polling.nil?
126
+
127
+ @env_reader.call('DAYTONA_USE_DEPRECATED_POLLING') == 'true'
128
+ end
108
129
  end
109
130
  end
@@ -48,18 +48,49 @@ module Daytona
48
48
  @api_client = build_api_client
49
49
  @sandbox_api = DaytonaApiClient::SandboxApi.new(api_client)
50
50
  @config_api = DaytonaApiClient::ConfigApi.new(api_client)
51
+ @analytics_api_url = nil
52
+ @analytics_api_url_fetched = false
53
+ @analytics_api_url_mutex = Mutex.new
51
54
  @volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client), otel_state:)
52
55
  @secret = SecretService.new(DaytonaApiClient::SecretApi.new(api_client), otel_state:)
53
56
  @object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
54
57
  @snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
55
- @snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
56
- otel_state:)
58
+ @snapshot = SnapshotService.new(
59
+ snapshots_api:,
60
+ object_storage_api:,
61
+ default_region_id: config.target,
62
+ otel_state:
63
+ )
64
+ token = config.api_key || config.jwt_token
65
+
66
+ if config.use_deprecated_polling
67
+ warn(
68
+ '[DEPRECATION] Polling-only mode (use_deprecated_polling / DAYTONA_USE_DEPRECATED_POLLING) ' \
69
+ 'is deprecated and will be removed in a future release.',
70
+ uplevel: 1
71
+ )
72
+ else
73
+ @event_dispatcher = EventDispatcher.new(
74
+ api_url: config.api_url,
75
+ token: token,
76
+ organization_id: config.organization_id,
77
+ source: SOURCE_RUBY,
78
+ sdk_version: Sdk::VERSION
79
+ )
80
+ @event_dispatcher.ensure_connected
81
+ end
82
+
83
+ @subscription_manager = EventSubscriptionManager.new(@event_dispatcher)
57
84
  end
58
85
 
59
86
  # Shuts down OTel providers, flushing any pending telemetry data.
60
87
  #
61
88
  # @return [void]
62
89
  def close
90
+ @subscription_manager&.shutdown
91
+ @subscription_manager = nil
92
+ @event_dispatcher&.disconnect
93
+ @event_dispatcher = nil
63
94
  ::Daytona.shutdown_otel(@otel_state)
64
95
  @otel_state = nil
65
96
  end
@@ -68,7 +99,8 @@ module Daytona
68
99
  #
69
100
  # @param params [Daytona::CreateSandboxFromSnapshotParams, Daytona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
70
101
  # @return [Daytona::Sandbox] The created sandbox
71
- # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
102
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
103
+ # or if auto_stop_interval and auto_pause_interval are both non-zero
72
104
  def create(params = nil, on_snapshot_create_logs: nil)
73
105
  if params.nil?
74
106
  params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
@@ -87,8 +119,9 @@ module Daytona
87
119
  # Deletes a Sandbox.
88
120
  #
89
121
  # @param sandbox [Daytona::Sandbox]
122
+ # @param wait [Boolean] When +true+, block until the Sandbox is destroyed.
90
123
  # @return [void]
91
- def delete(sandbox) = sandbox.delete
124
+ def delete(sandbox, wait: false) = sandbox.delete(wait:)
92
125
 
93
126
  # Gets a Sandbox by its ID.
94
127
  #
@@ -148,6 +181,18 @@ module Daytona
148
181
  # @return [Daytona::OtelState, nil]
149
182
  attr_reader :otel_state
150
183
 
184
+ # Resolves the deployment's Analytics API URL via /config, cached (including a nil
185
+ # result) for the client's lifetime. Errors are not cached, so failed lookups retry.
186
+ def analytics_api_url
187
+ @analytics_api_url_mutex.synchronize do
188
+ unless @analytics_api_url_fetched
189
+ @analytics_api_url = @config_api.config_controller_get_config.analytics_api_url
190
+ @analytics_api_url_fetched = true
191
+ end
192
+ @analytics_api_url
193
+ end
194
+ end
195
+
151
196
  # Fetches a single page of sandboxes. Each call produces one OTel span
152
197
  # ("Daytona.list_fetch_page") so that paginated iteration emits N spans
153
198
  # for N pages.
@@ -192,7 +237,8 @@ module Daytona
192
237
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
193
238
  # @param on_snapshot_create_logs [Proc]
194
239
  # @return [Daytona::Sandbox] The created sandbox
195
- # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
240
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
241
+ # or if auto_stop_interval and auto_pause_interval are both non-zero
196
242
  def _create(params, timeout: 60, on_snapshot_create_logs: nil)
197
243
  raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
198
244
 
@@ -200,6 +246,17 @@ module Daytona
200
246
 
201
247
  raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
202
248
 
249
+ raise Sdk::Error, 'auto_pause_interval must be a non-negative integer' if params.auto_pause_interval&.negative?
250
+
251
+ if params.auto_stop_interval&.positive? && params.auto_pause_interval&.positive?
252
+ raise Sdk::Error,
253
+ 'auto_stop_interval and auto_pause_interval are mutually exclusive. Set at most one of them to a non-zero value'
254
+ end
255
+
256
+ if params.auto_pause_interval&.positive? && params.auto_delete_interval&.zero?
257
+ raise Sdk::Error, 'Ephemeral sandboxes cannot have auto-pause enabled. Set auto_pause_interval to 0'
258
+ end
259
+
203
260
  if params.auto_archive_interval&.negative?
204
261
  raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
205
262
  end
@@ -214,6 +271,7 @@ module Daytona
214
271
  public: params.public,
215
272
  target: config.target,
216
273
  auto_stop_interval: params.auto_stop_interval,
274
+ auto_pause_interval: params.auto_pause_interval,
217
275
  auto_archive_interval: params.auto_archive_interval,
218
276
  auto_delete_interval: params.auto_delete_interval,
219
277
  volumes: params.volumes,
@@ -322,7 +380,9 @@ module Daytona
322
380
  sandbox_dto:,
323
381
  config:,
324
382
  sandbox_api:,
325
- otel_state: @otel_state
383
+ otel_state: @otel_state,
384
+ analytics_api_url_provider: method(:analytics_api_url),
385
+ subscription_manager: @subscription_manager
326
386
  )
327
387
  end
328
388
 
@@ -402,7 +402,7 @@ module Daytona
402
402
  # end
403
403
  # end
404
404
  def replace_in_files(files:, pattern:, new_value:)
405
- replace_request = DaytonaApiClient::ReplaceRequest.new(
405
+ replace_request = DaytonaToolboxApiClient::ReplaceRequest.new(
406
406
  files: files,
407
407
  pattern: pattern,
408
408
  new_value: new_value
@@ -56,7 +56,7 @@ module Daytona
56
56
  language_id:,
57
57
  path_to_project:,
58
58
  uri: uri(path),
59
- position: DaytonaApiClient::Position.new(line: position.line, character: position.character)
59
+ position: DaytonaToolboxApiClient::LspPosition.new(line: position.line, character: position.character)
60
60
  )
61
61
  )
62
62
  end
@@ -3,6 +3,8 @@
3
3
 
4
4
  # frozen_string_literal: true
5
5
 
6
+ require 'base64'
7
+ require 'json'
6
8
  require 'uri'
7
9
 
8
10
  module Daytona
@@ -410,19 +412,32 @@ module Daytona
410
412
  # pty_handle.disconnect
411
413
  #
412
414
  # @raise [Daytona::Sdk::Error] If the PTY session creation fails or the session ID is already in use.
413
- def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength
414
- response = toolbox_api.create_pty_session(
415
- DaytonaToolboxApiClient::PtyCreateRequest.new(
416
- id:,
417
- cwd:,
418
- envs:,
419
- cols: pty_size&.cols,
420
- rows: pty_size&.rows,
421
- lazy_start: true
422
- )
423
- )
415
+ def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
416
+ cols = pty_size&.cols || 80
417
+ rows = pty_size&.rows || 24
418
+
419
+ preview_link = get_preview_link.call(WS_PORT)
420
+ url = URI.parse(preview_link.url)
421
+ url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
422
+ url.path = '/process/pty/create-connect'
424
423
 
425
- connect_pty_session(response.session_id)
424
+ params = { id:, cols:, rows: }
425
+ params[:cwd] = cwd if cwd
426
+ url.query = URI.encode_www_form(params)
427
+
428
+ headers = toolbox_api.api_client.default_headers.dup.merge(
429
+ 'X-Daytona-Preview-Token' => preview_link.token
430
+ )
431
+ protocols = [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact
432
+ protocols << "X-Daytona-Pty-Envs~#{Base64.urlsafe_encode64(envs.to_json, padding: false)}" if envs
433
+ headers['Sec-WebSocket-Protocol'] = protocols.join(', ')
434
+
435
+ PtyHandle.new(
436
+ WebSocket::Client::Simple.connect(url.to_s, headers:),
437
+ session_id: id,
438
+ handle_resize: ->(pty_size_arg) { resize_pty_session(id, pty_size_arg) },
439
+ handle_kill: -> { delete_pty_session(id) }
440
+ ).tap(&:wait_for_connection)
426
441
  end
427
442
 
428
443
  # Connects to an existing PTY session in the Sandbox.
@@ -448,13 +463,14 @@ module Daytona
448
463
  url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
449
464
  url.path = "/process/pty/#{session_id}/connect"
450
465
 
466
+ headers = toolbox_api.api_client.default_headers.dup.merge(
467
+ 'X-Daytona-Preview-Token' => preview_link.token
468
+ )
469
+ headers['Sec-WebSocket-Protocol'] =
470
+ [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact.join(', ')
471
+
451
472
  handle = nil
452
- WebSocket::Client::Simple.connect(
453
- url.to_s,
454
- headers: toolbox_api.api_client.default_headers.dup.merge(
455
- 'X-Daytona-Preview-Token' => preview_link.token
456
- )
457
- ) do |client|
473
+ WebSocket::Client::Simple.connect(url.to_s, headers:) do |client|
458
474
  handle = PtyHandle.new(
459
475
  client,
460
476
  session_id:,