daytona 0.211.2 → 0.214.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ae7269e4ef21322f6ac905fd214310b46842edf2bdd45291a1c5eee1e78f6e0
4
- data.tar.gz: 1cc443a01e292980d47b0092cb435593b8def426a88f334cae20a556e6497130
3
+ metadata.gz: 868afc4f077432e39b074d0249d2a20277206a1e430f64954b42663b74496118
4
+ data.tar.gz: 5e8dc41efd1f588f325f8fd117d8ad985382d5c7a43a73740d58ded6d5def73e
5
5
  SHA512:
6
- metadata.gz: 8011684fcd9fe5fc324eeafe02cffadb2e71311741d23cb6e08f8a52926dda8ea32feea4c47275bb496ef82112eb6db954367c8fa527c09a4b28e95ef50229cf
7
- data.tar.gz: 7bb8084c0981db0281b0c6264cc8e20365a38a52012e54bc36a0a4a00aec807c39867f7b39df12eda8d3c8e4546fe646b0b8f4f41f6f4a27644d621acdba9bd7
6
+ metadata.gz: c5fca0242291118c5000ffba802fb450aeab89c8f4b05c2f01cde1192e9c898dff8480edf6d9c12332a317c6282656a77b8b3fc9d75b0172a6bb0a16f1bb3dbc
7
+ data.tar.gz: 11fb9e1f412128cdb6651852bd5af7a7441a49ba1dcd3dab1ae3ce2479ac3769ba5968c3a85e10218b6e6f2ee45bd818adaebd9f3481fdb989e884294def4899
@@ -108,7 +108,7 @@ module Daytona
108
108
 
109
109
  puts "[DEBUG] Connecting to WebSocket: #{ws_url}" if ENV['DEBUG']
110
110
 
111
- ws = WebSocket::Client::Simple.connect(ws_url, headers:) do |client|
111
+ ws = Common::WebSocketDialer.connect(ws_url, headers:) do |client|
112
112
  client.on :open do
113
113
  puts '[DEBUG] WebSocket opened, sending request' if ENV['DEBUG']
114
114
  client.send(JSON.dump(request))
@@ -5,6 +5,7 @@
5
5
 
6
6
  require 'digest'
7
7
  require 'fileutils'
8
+ require 'json'
8
9
  require 'pathname'
9
10
  require 'shellwords'
10
11
 
@@ -362,7 +363,7 @@ module Daytona
362
363
  # @return [Array<Array<String>>] The list of the actual file path and its corresponding COPY-command source path
363
364
  def extract_copy_sources(dockerfile_content, path_prefix = '') # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
364
365
  sources = []
365
- lines = dockerfile_content.split("\n")
366
+ lines = dockerfile_logical_lines(dockerfile_content)
366
367
 
367
368
  lines.each do |line|
368
369
  # Skip empty lines and comments
@@ -404,47 +405,65 @@ module Daytona
404
405
  sources
405
406
  end
406
407
 
408
+ # Joins backslash-continued physical lines into logical Dockerfile instruction lines
409
+ #
410
+ # @param dockerfile_content [String] The content of the Dockerfile
411
+ # @return [Array<String>] The logical instruction lines
412
+ def dockerfile_logical_lines(dockerfile_content) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
413
+ logical_lines = []
414
+ current = nil
415
+
416
+ dockerfile_content.each_line(chomp: true) do |physical_line|
417
+ is_comment = physical_line.lstrip.start_with?('#')
418
+ # Docker drops empty and comment lines that appear inside a continued instruction
419
+ next if current && (physical_line.strip.empty? || is_comment)
420
+
421
+ stripped = physical_line.rstrip
422
+ # A trailing backslash on a comment line is literal; comments never continue onto the next line
423
+ continued = !is_comment && stripped.end_with?('\\')
424
+ segment = continued ? stripped[0..-2] : physical_line
425
+ current = current ? current + segment : segment
426
+ next if continued
427
+
428
+ logical_lines << current
429
+ current = nil
430
+ end
431
+
432
+ logical_lines << current if current
433
+ logical_lines
434
+ end
435
+
407
436
  # Parses a COPY command to extract sources and destination
408
437
  #
409
438
  # @param line [String] The line to parse
410
439
  # @return [Hash, nil] A hash containing the sources and destination, or nil if parsing fails
411
- def parse_copy_command(line) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
440
+ def parse_copy_command(line)
412
441
  # Remove initial "COPY" and strip whitespace
413
442
  parts = line.strip[4..].strip
414
443
 
415
- # Handle JSON array format: COPY ["src1", "src2", "dest"]
416
- if parts.start_with?('[')
417
- begin
418
- # Parse the JSON-like array format
419
- elements = Shellwords.split(parts.delete('[]'))
420
- return nil if elements.length < 2
421
-
422
- { 'sources' => elements[0..-2], 'dest' => elements[-1] }
423
- rescue StandardError
424
- nil
425
- end
426
- end
444
+ # Skip leading flags. Value-taking flags use the --flag=value form (--chown=..., --chmod=...)
445
+ # and boolean flags stand alone (--link), so a flag never consumes the token that follows it.
446
+ parts = parts.sub(/\A\S+\s*/, '') while parts.start_with?('--')
427
447
 
428
- # Handle regular format with possible flags
429
- parts = Shellwords.split(parts)
448
+ # Handle JSON array format: COPY ["src1", "src2", "dest"]
449
+ return parse_json_copy_command(parts) if parts.start_with?('[')
430
450
 
431
- # Extract flags like --chown, --chmod, --from
432
- sources_start_idx = 0
433
- parts.each_with_index do |part, i|
434
- break unless part.start_with?('--')
451
+ # Handle the whitespace-separated format
452
+ elements = Shellwords.split(parts)
453
+ return nil if elements.length < 2
435
454
 
436
- # Skip the flag and its value if it has one
437
- sources_start_idx = if !part.include?('=') && i + 1 < parts.length && !parts[i + 1].start_with?('--')
438
- i + 2
439
- else
440
- i + 1
441
- end
442
- end
455
+ { 'sources' => elements[0..-2], 'dest' => elements[-1] }
456
+ rescue ArgumentError
457
+ nil
458
+ end
443
459
 
444
- # After skipping flags, we need at least one source and one destination
445
- return nil if parts.length - sources_start_idx < 2
460
+ def parse_json_copy_command(parts)
461
+ elements = JSON.parse(parts)
462
+ return nil unless elements.is_a?(Array) && elements.all?(String) && elements.length >= 2
446
463
 
447
- { 'sources' => parts[sources_start_idx..-2], 'dest' => parts[-1] }
464
+ { 'sources' => elements[0..-2], 'dest' => elements[-1] }
465
+ rescue JSON::ParserError
466
+ nil
448
467
  end
449
468
  end
450
469
 
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'openssl'
7
+ require 'socket'
8
+ require 'uri'
9
+ require 'websocket'
10
+ require 'websocket-client-simple'
11
+
12
+ module Daytona
13
+ module Common
14
+ # Establishes WebSocket connections with the peer certificate fully verified
15
+ # before any request bytes are written.
16
+ #
17
+ # `websocket-client-simple` builds its own `SSLContext` and never calls
18
+ # `OpenSSL::SSL::SSLContext#set_params`, so it does not pick up Ruby's own
19
+ # defaults (`verify_mode: VERIFY_PEER`, `verify_hostname: true`). It applies
20
+ # `verify_mode` only when the caller passes it, and offers no way to enable
21
+ # hostname verification at all. Because the context is frozen by
22
+ # `SSLSocket.new` and the handshake is written before `connect` returns,
23
+ # there is no caller-side hook to correct this - so the dial itself has to
24
+ # be owned here.
25
+ #
26
+ # Chain verification alone is not sufficient: without hostname verification
27
+ # a peer holding any valid certificate can terminate the connection and
28
+ # receive the request headers.
29
+ module WebSocketDialer
30
+ # Opens a verified WebSocket connection.
31
+ #
32
+ # Mirrors `WebSocket::Client::Simple.connect`: the block, if given, receives
33
+ # the client before the connection is established so handlers can be
34
+ # registered, and the client is returned.
35
+ #
36
+ # @param url [String] The `ws://` or `wss://` URL to dial.
37
+ # @param options [Hash] Passed through to the client; `:headers`, `:ssl_version` and `:cert_store` are honoured.
38
+ # @return [VerifyingClient] The connected client.
39
+ # @raise [OpenSSL::SSL::SSLError] If the peer certificate fails chain or hostname verification.
40
+ def self.connect(url, options = {})
41
+ client = VerifyingClient.new
42
+ yield client if block_given?
43
+ client.connect(url, options)
44
+ client
45
+ end
46
+
47
+ # A `websocket-client-simple` client that verifies the peer before writing.
48
+ #
49
+ # `#connect` is reimplemented rather than extended because the upstream
50
+ # method builds the socket, performs the TLS handshake and writes the
51
+ # request in one pass, and returns early when `@socket` is already set.
52
+ #
53
+ # Everything after TLS setup mirrors upstream so that framing, the reader
54
+ # thread and event semantics stay identical. That couples to the instance
55
+ # variables the inherited `#send`, `#close`, `#open?` and `#closed?` read,
56
+ # so the gemspec pins the gem to `~> 0.9.0` - a 0.x minor bump could rename
57
+ # them. `websocket_dialer_spec.rb` drives a full send/close round trip
58
+ # against a real listener, which fails if that contract breaks.
59
+ class VerifyingClient < WebSocket::Client::Simple::Client
60
+ # @param url [String] The `ws://` or `wss://` URL to dial.
61
+ # @param options [Hash] Connection options.
62
+ # @return [void]
63
+ def connect(url, options = {})
64
+ return if @socket
65
+
66
+ @url = url
67
+ uri = URI.parse(url)
68
+ @socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == 'wss' ? 443 : 80))
69
+ @socket = verified_ssl_socket(@socket, uri, options) if %w[https wss].include?(uri.scheme)
70
+
71
+ start_websocket(url, options)
72
+ end
73
+
74
+ private
75
+
76
+ # Wraps a socket in TLS with the peer certificate verified.
77
+ #
78
+ # @param socket [TCPSocket] The connected plaintext socket.
79
+ # @param uri [URI] The dialed URI, whose host the certificate must match.
80
+ # @param options [Hash] Connection options.
81
+ # @return [OpenSSL::SSL::SSLSocket] The connected, verified socket.
82
+ # @raise [OpenSSL::SSL::SSLError] If chain or hostname verification fails.
83
+ def verified_ssl_socket(socket, uri, options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
84
+ ctx = OpenSSL::SSL::SSLContext.new
85
+ ctx.ssl_version = options[:ssl_version] if options[:ssl_version]
86
+
87
+ # Only seed system roots into a store we own. Adding them to a
88
+ # caller-supplied store would silently widen their trust policy.
89
+ cert_store = options[:cert_store]
90
+ unless cert_store
91
+ cert_store = OpenSSL::X509::Store.new
92
+ cert_store.set_default_paths
93
+ end
94
+ ctx.cert_store = cert_store
95
+
96
+ # Must precede SSLSocket.new, which freezes the context.
97
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
98
+ ctx.verify_hostname = true
99
+
100
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
101
+ ssl.sync_close = true
102
+ ssl.hostname = uri.host
103
+ begin
104
+ ssl.connect
105
+ # Redundant while verify_hostname holds, but keeps the check explicit
106
+ # and independent of the context surviving future changes.
107
+ ssl.post_connection_check(uri.host)
108
+ rescue StandardError
109
+ ssl.close
110
+ raise
111
+ end
112
+ ssl
113
+ end
114
+
115
+ # Performs the WebSocket handshake and starts the reader thread.
116
+ #
117
+ # Mirrors `WebSocket::Client::Simple::Client#connect` from the request
118
+ # onward. Only reached once the socket is verified.
119
+ #
120
+ # @param url [String] The dialed URL.
121
+ # @param options [Hash] Connection options.
122
+ # @return [void]
123
+ def start_websocket(url, options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
124
+ WebSocket.should_raise = true
125
+ @handshake = WebSocket::Handshake::Client.new(url: url, headers: options[:headers])
126
+ @handshaked = false
127
+ @pipe_broken = false
128
+ @closed = false
129
+ frame = WebSocket::Frame::Incoming::Client.new
130
+
131
+ once :__close do |err|
132
+ close
133
+ emit :close, err
134
+ end
135
+
136
+ @thread = Thread.new do
137
+ until @closed
138
+ begin
139
+ unless (recv_data = @socket.getc)
140
+ sleep 1
141
+ next
142
+ end
143
+ if @handshaked
144
+ frame << recv_data
145
+ while (msg = frame.next)
146
+ emit :message, msg
147
+ end
148
+ else
149
+ @handshake << recv_data
150
+ if @handshake.finished?
151
+ @handshaked = true
152
+ emit :open
153
+ end
154
+ end
155
+ rescue StandardError => e
156
+ emit :error, e
157
+ end
158
+ end
159
+ end
160
+
161
+ @socket.write @handshake.to_s
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -6,6 +6,45 @@
6
6
  require 'dotenv'
7
7
 
8
8
  module Daytona
9
+ # Dotenv runs `$(...)` in a value through the shell while parsing, and the working
10
+ # directory is frequently a cloned repository, so parsing `.env` there would execute
11
+ # whatever its author put in it.
12
+ module EnvFile
13
+ # Dotenv::Parser picks its substitutions up from `self.class.substitutions`, and Ruby
14
+ # does not inherit class-level instance variables, so declaring the list here drops
15
+ # command substitution while leaving Dotenv::Parser alone — a host application's own
16
+ # Dotenv.load keeps the behaviour its author chose. `${VAR}` is kept: it reads only
17
+ # keys already parsed or the process environment.
18
+ class Parser < Dotenv::Parser
19
+ @substitutions = [Dotenv::Substitutions::Variable].freeze
20
+ end
21
+
22
+ # Read with the mode dotenv itself uses, so the accepted format does not narrow: `bom`
23
+ # skips a byte-order mark an editor on Windows may have written, and pinning `utf-8`
24
+ # keeps the file readable under a POSIX locale, where the default external encoding
25
+ # would make one accented byte anywhere — a comment included — raise while it is scanned.
26
+ def self.parse(path)
27
+ verify_suppression!
28
+ Parser.call(File.read(path, mode: 'rb:bom|utf-8'))
29
+ end
30
+
31
+ # The subclass reaches into dotenv's internals rather than a public API, and dotenv has
32
+ # already reorganised them once inside the range the gemspec allows, so confirm the
33
+ # suppression actually holds in the process that relies on it rather than trusting the
34
+ # version pinned in CI. Checked here rather than on load: this is the operation the
35
+ # guard protects, so a lapse stops it, and a gem that never reads a .env still loads.
36
+ # A failure is not memoised, so it is raised again on the next attempt.
37
+ def self.verify_suppression!
38
+ return if @verified
39
+
40
+ probe = Parser.call('DAYTONA_PROBE=$(echo substituted)')['DAYTONA_PROBE']
41
+ raise "dotenv command substitution is not suppressed (got #{probe.inspect})" unless
42
+ probe == '$(echo substituted)'
43
+
44
+ @verified = true
45
+ end
46
+ end
47
+
9
48
  class Config
10
49
  API_URL = 'https://app.daytona.io/api'
11
50
 
@@ -131,9 +170,9 @@ module Daytona
131
170
  def parse_dotenv_files
132
171
  file_vars = {}
133
172
  env_file = File.join(Dir.pwd, '.env')
134
- file_vars.merge!(daytona_filter(Dotenv.parse(env_file))) if File.exist?(env_file)
173
+ file_vars.merge!(daytona_filter(EnvFile.parse(env_file))) if File.exist?(env_file)
135
174
  env_local_file = File.join(Dir.pwd, '.env.local')
136
- file_vars.merge!(daytona_filter(Dotenv.parse(env_local_file))) if File.exist?(env_local_file)
175
+ file_vars.merge!(daytona_filter(EnvFile.parse(env_local_file))) if File.exist?(env_local_file)
137
176
  file_vars
138
177
  end
139
178
 
@@ -257,7 +257,7 @@ module Daytona
257
257
 
258
258
  completion_queue = Queue.new
259
259
 
260
- WebSocket::Client::Simple.connect(
260
+ Common::WebSocketDialer.connect(
261
261
  url.to_s,
262
262
  headers: toolbox_api.api_client.default_headers.dup.merge(
263
263
  'X-Daytona-Preview-Token' => preview_link.token,
@@ -326,7 +326,7 @@ module Daytona
326
326
 
327
327
  completion_queue = Queue.new
328
328
 
329
- WebSocket::Client::Simple.connect(
329
+ Common::WebSocketDialer.connect(
330
330
  url.to_s,
331
331
  headers: toolbox_api.api_client.default_headers.dup.merge(
332
332
  'X-Daytona-Preview-Token' => preview_link.token,
@@ -465,7 +465,7 @@ module Daytona
465
465
  headers['Sec-WebSocket-Protocol'] = protocols.join(', ')
466
466
 
467
467
  PtyHandle.new(
468
- WebSocket::Client::Simple.connect(url.to_s, headers:),
468
+ Common::WebSocketDialer.connect(url.to_s, headers:),
469
469
  session_id: id,
470
470
  handle_resize: ->(pty_size_arg) { resize_pty_session(id, pty_size_arg) },
471
471
  handle_kill: -> { delete_pty_session(id) }
@@ -502,7 +502,7 @@ module Daytona
502
502
  [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact.join(', ')
503
503
 
504
504
  handle = nil
505
- WebSocket::Client::Simple.connect(url.to_s, headers:) do |client|
505
+ Common::WebSocketDialer.connect(url.to_s, headers:) do |client|
506
506
  handle = PtyHandle.new(
507
507
  client,
508
508
  session_id:,
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Daytona
7
7
  module Sdk
8
- VERSION = '0.211.2'
8
+ VERSION = '0.214.0'
9
9
  end
10
10
  end
data/lib/daytona/sdk.rb CHANGED
@@ -33,6 +33,7 @@ require_relative 'common/snapshot'
33
33
  require_relative 'code_interpreter'
34
34
  require_relative 'computer_use'
35
35
  require_relative 'common/socketio_client'
36
+ require_relative 'common/websocket_dialer'
36
37
  require_relative 'common/event_dispatcher'
37
38
  require_relative 'common/event_subscription_manager'
38
39
  require_relative 'daytona'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daytona
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.211.2
4
+ version: 0.214.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daytona Platforms Inc.
@@ -91,42 +91,42 @@ dependencies:
91
91
  requirements:
92
92
  - - '='
93
93
  - !ruby/object:Gem::Version
94
- version: 0.211.2
94
+ version: 0.214.0
95
95
  type: :runtime
96
96
  prerelease: false
97
97
  version_requirements: !ruby/object:Gem::Requirement
98
98
  requirements:
99
99
  - - '='
100
100
  - !ruby/object:Gem::Version
101
- version: 0.211.2
101
+ version: 0.214.0
102
102
  - !ruby/object:Gem::Dependency
103
103
  name: daytona_api_client
104
104
  requirement: !ruby/object:Gem::Requirement
105
105
  requirements:
106
106
  - - '='
107
107
  - !ruby/object:Gem::Version
108
- version: 0.211.2
108
+ version: 0.214.0
109
109
  type: :runtime
110
110
  prerelease: false
111
111
  version_requirements: !ruby/object:Gem::Requirement
112
112
  requirements:
113
113
  - - '='
114
114
  - !ruby/object:Gem::Version
115
- version: 0.211.2
115
+ version: 0.214.0
116
116
  - !ruby/object:Gem::Dependency
117
117
  name: daytona_toolbox_api_client
118
118
  requirement: !ruby/object:Gem::Requirement
119
119
  requirements:
120
120
  - - '='
121
121
  - !ruby/object:Gem::Version
122
- version: 0.211.2
122
+ version: 0.214.0
123
123
  type: :runtime
124
124
  prerelease: false
125
125
  version_requirements: !ruby/object:Gem::Requirement
126
126
  requirements:
127
127
  - - '='
128
128
  - !ruby/object:Gem::Version
129
- version: 0.211.2
129
+ version: 0.214.0
130
130
  - !ruby/object:Gem::Dependency
131
131
  name: dotenv
132
132
  requirement: !ruby/object:Gem::Requirement
@@ -181,14 +181,14 @@ dependencies:
181
181
  requirements:
182
182
  - - "~>"
183
183
  - !ruby/object:Gem::Version
184
- version: '0.6'
184
+ version: 0.9.0
185
185
  type: :runtime
186
186
  prerelease: false
187
187
  version_requirements: !ruby/object:Gem::Requirement
188
188
  requirements:
189
189
  - - "~>"
190
190
  - !ruby/object:Gem::Version
191
- version: '0.6'
191
+ version: 0.9.0
192
192
  description: 'High-level Ruby SDK for Daytona: sandboxes, git, filesystem, LSP, process,
193
193
  and object storage. Requires Ruby >= 3.2.'
194
194
  email:
@@ -221,6 +221,7 @@ files:
221
221
  - lib/daytona/common/response.rb
222
222
  - lib/daytona/common/snapshot.rb
223
223
  - lib/daytona/common/socketio_client.rb
224
+ - lib/daytona/common/websocket_dialer.rb
224
225
  - lib/daytona/computer_use.rb
225
226
  - lib/daytona/config.rb
226
227
  - lib/daytona/daytona.rb