ferrum-mcp 1.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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +90 -0
  3. data/CHANGELOG.md +229 -0
  4. data/CONTRIBUTING.md +469 -0
  5. data/LICENSE +21 -0
  6. data/README.md +334 -0
  7. data/SECURITY.md +286 -0
  8. data/bin/ferrum-mcp +66 -0
  9. data/bin/lint +10 -0
  10. data/bin/serve +3 -0
  11. data/bin/test +4 -0
  12. data/docs/API_REFERENCE.md +1410 -0
  13. data/docs/CONFIGURATION.md +254 -0
  14. data/docs/DEPLOYMENT.md +846 -0
  15. data/docs/DOCKER.md +836 -0
  16. data/docs/DOCKER_BOTBROWSER.md +455 -0
  17. data/docs/GETTING_STARTED.md +249 -0
  18. data/docs/TROUBLESHOOTING.md +677 -0
  19. data/lib/ferrum_mcp/browser_manager.rb +101 -0
  20. data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
  21. data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
  22. data/lib/ferrum_mcp/configuration.rb +229 -0
  23. data/lib/ferrum_mcp/resource_manager.rb +223 -0
  24. data/lib/ferrum_mcp/server.rb +254 -0
  25. data/lib/ferrum_mcp/session.rb +227 -0
  26. data/lib/ferrum_mcp/session_manager.rb +183 -0
  27. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
  28. data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
  29. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
  30. data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
  31. data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
  32. data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
  33. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
  34. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
  35. data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
  36. data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
  37. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
  38. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
  39. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
  40. data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
  41. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
  42. data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
  43. data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
  44. data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
  45. data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
  46. data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
  47. data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
  48. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
  49. data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
  50. data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
  51. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
  52. data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
  53. data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
  54. data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
  55. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
  56. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
  57. data/lib/ferrum_mcp/transport/http_server.rb +93 -0
  58. data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
  59. data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
  60. data/lib/ferrum_mcp/version.rb +5 -0
  61. data/lib/ferrum_mcp/whisper_service.rb +222 -0
  62. data/lib/ferrum_mcp.rb +35 -0
  63. metadata +248 -0
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ # Manages MCP resources for browser configurations, profiles, and capabilities
5
+ class ResourceManager
6
+ attr_reader :config, :logger
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ @logger = config.logger
11
+ end
12
+
13
+ # Get all available resources
14
+ def resources
15
+ @resources ||= build_resources
16
+ end
17
+
18
+ # Read a specific resource by URI
19
+ def read_resource(uri)
20
+ case uri
21
+ when 'ferrum://browsers'
22
+ read_browsers_resource
23
+ when 'ferrum://user-profiles'
24
+ read_user_profiles_resource
25
+ when 'ferrum://bot-profiles'
26
+ read_bot_profiles_resource
27
+ when 'ferrum://capabilities'
28
+ read_capabilities_resource
29
+ when %r{^ferrum://browsers/(.+)$}
30
+ read_browser_detail(::Regexp.last_match(1))
31
+ when %r{^ferrum://user-profiles/(.+)$}
32
+ read_user_profile_detail(::Regexp.last_match(1))
33
+ when %r{^ferrum://bot-profiles/(.+)$}
34
+ read_bot_profile_detail(::Regexp.last_match(1))
35
+ else
36
+ logger.error "Unknown resource URI: #{uri}"
37
+ nil
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def build_resources
44
+ resources = []
45
+ resources.concat(build_browser_resources)
46
+ resources.concat(build_user_profile_resources)
47
+ resources.concat(build_bot_profile_resources)
48
+ resources << build_capabilities_resource
49
+ resources
50
+ end
51
+
52
+ def build_browser_resources
53
+ resources = [create_resource('ferrum://browsers', 'available-browsers',
54
+ 'List of all available browser configurations')]
55
+
56
+ config.browsers.each do |browser|
57
+ resources << create_resource("ferrum://browsers/#{browser.id}", "browser-#{browser.id}",
58
+ "Configuration for #{browser.name}")
59
+ end
60
+
61
+ resources
62
+ end
63
+
64
+ def build_user_profile_resources
65
+ resources = [create_resource('ferrum://user-profiles', 'user-profiles',
66
+ 'List of all available Chrome user profiles')]
67
+
68
+ config.user_profiles.each do |profile|
69
+ resources << create_resource("ferrum://user-profiles/#{profile.id}", "user-profile-#{profile.id}",
70
+ "Details for user profile: #{profile.name}")
71
+ end
72
+
73
+ resources
74
+ end
75
+
76
+ def build_bot_profile_resources
77
+ resources = [create_resource('ferrum://bot-profiles', 'bot-profiles',
78
+ 'List of all available BotBrowser profiles')]
79
+
80
+ config.bot_profiles.each do |profile|
81
+ resources << create_resource("ferrum://bot-profiles/#{profile.id}", "bot-profile-#{profile.id}",
82
+ "Details for BotBrowser profile: #{profile.name}")
83
+ end
84
+
85
+ resources
86
+ end
87
+
88
+ def build_capabilities_resource
89
+ create_resource('ferrum://capabilities', 'server-capabilities',
90
+ 'Server capabilities and feature flags')
91
+ end
92
+
93
+ def create_resource(uri, name, description)
94
+ MCP::Resource.new(
95
+ uri: uri,
96
+ name: name,
97
+ description: description,
98
+ mime_type: 'application/json'
99
+ )
100
+ end
101
+
102
+ def read_browsers_resource
103
+ {
104
+ uri: 'ferrum://browsers',
105
+ mimeType: 'application/json',
106
+ text: JSON.pretty_generate({
107
+ browsers: config.browsers.map(&:to_h),
108
+ default: config.default_browser&.id,
109
+ total: config.browsers.count
110
+ })
111
+ }
112
+ end
113
+
114
+ def read_browser_detail(browser_id)
115
+ browser = config.find_browser(browser_id)
116
+ return nil unless browser
117
+
118
+ {
119
+ uri: "ferrum://browsers/#{browser_id}",
120
+ mimeType: 'application/json',
121
+ text: JSON.pretty_generate(browser.to_h.merge(
122
+ is_default: browser == config.default_browser,
123
+ exists: browser.path.nil? || File.exist?(browser.path),
124
+ usage: {
125
+ session_param: 'browser_id',
126
+ example: "create_session(browser_id: '#{browser.id}')"
127
+ }
128
+ ))
129
+ }
130
+ end
131
+
132
+ def read_user_profiles_resource
133
+ {
134
+ uri: 'ferrum://user-profiles',
135
+ mimeType: 'application/json',
136
+ text: JSON.pretty_generate({
137
+ profiles: config.user_profiles.map(&:to_h),
138
+ total: config.user_profiles.count,
139
+ note: 'User profiles are standard Chrome user data directories'
140
+ })
141
+ }
142
+ end
143
+
144
+ def read_user_profile_detail(profile_id)
145
+ profile = config.find_user_profile(profile_id)
146
+ return nil unless profile
147
+
148
+ {
149
+ uri: "ferrum://user-profiles/#{profile_id}",
150
+ mimeType: 'application/json',
151
+ text: JSON.pretty_generate(profile.to_h.merge(
152
+ exists: File.directory?(profile.path),
153
+ usage: {
154
+ session_param: 'user_profile_id',
155
+ example: "create_session(user_profile_id: '#{profile.id}')"
156
+ }
157
+ ))
158
+ }
159
+ end
160
+
161
+ def read_bot_profiles_resource
162
+ {
163
+ uri: 'ferrum://bot-profiles',
164
+ mimeType: 'application/json',
165
+ text: JSON.pretty_generate({
166
+ profiles: config.bot_profiles.map(&:to_h),
167
+ total: config.bot_profiles.count,
168
+ note: 'BotBrowser profiles contain anti-detection fingerprints',
169
+ using_botbrowser: config.using_botbrowser?
170
+ })
171
+ }
172
+ end
173
+
174
+ def read_bot_profile_detail(profile_id)
175
+ profile = config.find_bot_profile(profile_id)
176
+ return nil unless profile
177
+
178
+ {
179
+ uri: "ferrum://bot-profiles/#{profile_id}",
180
+ mimeType: 'application/json',
181
+ text: JSON.pretty_generate(profile.to_h.merge(
182
+ exists: File.exist?(profile.path),
183
+ usage: {
184
+ session_param: 'bot_profile_id',
185
+ example: "create_session(bot_profile_id: '#{profile.id}')"
186
+ },
187
+ features: %w[
188
+ canvas_fingerprinting
189
+ webgl_protection
190
+ audio_context_hardening
191
+ webrtc_leak_prevention
192
+ ]
193
+ ))
194
+ }
195
+ end
196
+
197
+ def read_capabilities_resource
198
+ {
199
+ uri: 'ferrum://capabilities',
200
+ mimeType: 'application/json',
201
+ text: JSON.pretty_generate({
202
+ version: FerrumMCP::VERSION,
203
+ features: {
204
+ multi_browser: config.browsers.count > 1,
205
+ user_profiles: config.user_profiles.any?,
206
+ bot_profiles: config.bot_profiles.any?,
207
+ botbrowser_integration: config.using_botbrowser?,
208
+ session_management: true,
209
+ screenshot: true,
210
+ javascript_execution: true,
211
+ cookie_management: true,
212
+ form_interaction: true,
213
+ captcha_solving: true
214
+ },
215
+ transport: config.transport,
216
+ browsers_count: config.browsers.count,
217
+ user_profiles_count: config.user_profiles.count,
218
+ bot_profiles_count: config.bot_profiles.count
219
+ })
220
+ }
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ # Main MCP Server implementation
5
+ class Server
6
+ attr_reader :mcp_server, :session_manager, :resource_manager, :config, :logger
7
+
8
+ TOOL_CLASSES = [
9
+ # Session Management
10
+ Tools::CreateSessionTool,
11
+ Tools::ListSessionsTool,
12
+ Tools::CloseSessionTool,
13
+ Tools::GetSessionInfoTool,
14
+ # Navigation
15
+ Tools::NavigateTool,
16
+ Tools::GoBackTool,
17
+ Tools::GoForwardTool,
18
+ Tools::RefreshTool,
19
+ # Interaction
20
+ Tools::ClickTool,
21
+ Tools::FillFormTool,
22
+ Tools::PressKeyTool,
23
+ Tools::HoverTool,
24
+ Tools::DragAndDropTool,
25
+ Tools::AcceptCookiesTool,
26
+ Tools::SolveCaptchaTool,
27
+ # Extraction
28
+ Tools::GetTextTool,
29
+ Tools::GetHTMLTool,
30
+ Tools::ScreenshotTool,
31
+ Tools::GetTitleTool,
32
+ Tools::GetURLTool,
33
+ Tools::FindByTextTool,
34
+ # Advanced
35
+ Tools::ExecuteScriptTool,
36
+ Tools::EvaluateJSTool,
37
+ Tools::GetCookiesTool,
38
+ Tools::SetCookieTool,
39
+ Tools::ClearCookiesTool,
40
+ Tools::GetAttributeTool,
41
+ Tools::QueryShadowDOMTool
42
+ ].freeze
43
+
44
+ def initialize(config = Configuration.new)
45
+ @config = config
46
+ @logger = config.logger
47
+ @session_manager = SessionManager.new(config)
48
+ @resource_manager = ResourceManager.new(config)
49
+ @tool_instances = {}
50
+ @mcp_server = create_mcp_server
51
+
52
+ setup_tools
53
+ setup_resources
54
+ setup_error_handling
55
+ end
56
+
57
+ # Deprecated: For backward compatibility
58
+ # Sessions must be created explicitly using create_session tool
59
+ def start_browser
60
+ raise NotImplementedError, 'start_browser is deprecated. Use create_session tool to create a session.'
61
+ end
62
+
63
+ # Deprecated: For backward compatibility
64
+ def stop_browser
65
+ logger.warn 'stop_browser is deprecated, use session_manager.close_all_sessions'
66
+ session_manager.close_all_sessions
67
+ @tool_instances = {}
68
+ logger.info 'All sessions stopped'
69
+ end
70
+
71
+ # Shutdown server and cleanup all sessions
72
+ def shutdown
73
+ logger.info 'Shutting down server...'
74
+ session_manager.shutdown
75
+ logger.info 'Server shutdown complete'
76
+ end
77
+
78
+ def handle_request(json_request)
79
+ request = JSON.parse(json_request)
80
+ logger.debug "Received request: #{request['method']}"
81
+
82
+ mcp_server.handle_request(request)
83
+ rescue JSON::ParserError => e
84
+ logger.error "Invalid JSON request: #{e.message}"
85
+ error_response('Invalid JSON request')
86
+ rescue StandardError => e
87
+ logger.error "Request handling error: #{e.message}"
88
+ logger.error e.backtrace.join("\n")
89
+ error_response(e.message)
90
+ end
91
+
92
+ private
93
+
94
+ def create_mcp_server
95
+ MCP::Server.new(
96
+ name: 'ferrum-browser',
97
+ version: FerrumMCP::VERSION,
98
+ instructions: 'A browser automation server using Ferrum and BotBrowser for web scraping and testing',
99
+ resources: resource_manager.resources
100
+ )
101
+ end
102
+
103
+ def setup_tools
104
+ # Capture references to instance variables for use in the block
105
+ server_instance = self
106
+
107
+ TOOL_CLASSES.each do |tool_class|
108
+ mcp_server.define_tool(
109
+ name: tool_class.tool_name,
110
+ description: tool_class.description,
111
+ input_schema: tool_class.input_schema
112
+ ) do |**params|
113
+ # Call execute_tool on the server instance
114
+ server_instance.send(:execute_tool, tool_class, params)
115
+ end
116
+ end
117
+
118
+ logger.info "Registered #{TOOL_CLASSES.length} tools"
119
+ end
120
+
121
+ def setup_resources
122
+ # Capture references to instance variables for use in the block
123
+ manager = resource_manager
124
+
125
+ # Define the resources_read handler
126
+ mcp_server.resources_read_handler do |params|
127
+ uri = params[:uri]
128
+ logger.debug "Reading resource: #{uri}"
129
+
130
+ result = manager.read_resource(uri)
131
+ if result
132
+ [result]
133
+ else
134
+ logger.error "Resource not found: #{uri}"
135
+ []
136
+ end
137
+ end
138
+
139
+ logger.info "Registered #{resource_manager.resources.length} resources"
140
+ end
141
+
142
+ def execute_tool(tool_class, params)
143
+ logger.debug "Executing tool: #{tool_class.tool_name} with params: #{params.inspect}"
144
+
145
+ # Session management tools don't need a browser session
146
+ if session_management_tool?(tool_class)
147
+ logger.debug "Executing session management tool: #{tool_class.tool_name}"
148
+ tool = tool_class.new(session_manager)
149
+ result = tool.execute(params)
150
+ else
151
+ # Extract session_id from params (required)
152
+ session_id = params[:session_id] || params['session_id']
153
+
154
+ unless session_id
155
+ logger.error "session_id is required for #{tool_class.tool_name}"
156
+ return error_tool_response('session_id is required. Create a session first using create_session tool.')
157
+ end
158
+
159
+ logger.debug "Using session_id: #{session_id}"
160
+
161
+ # Execute tool within session context
162
+ result = session_manager.with_session(session_id) do |browser_manager|
163
+ logger.debug "Creating tool instance for #{tool_class.tool_name}"
164
+ tool = tool_class.new(browser_manager)
165
+ logger.debug "Calling execute on #{tool_class.tool_name}"
166
+ tool.execute(params)
167
+ end
168
+ end
169
+
170
+ logger.debug "Tool #{tool_class.tool_name} result: #{result.inspect}"
171
+
172
+ # MCP expects a Tool::Response object
173
+ # Convert our tool result to MCP format
174
+ if result[:success]
175
+ logger.debug "Tool succeeded, creating MCP::Tool::Response with data: #{result[:data].inspect}"
176
+
177
+ # Check if this is an image response
178
+ if result[:type] == 'image'
179
+ logger.debug "Creating image response with mime_type: #{result[:mime_type]}"
180
+ # Return MCP Tool::Response with image content
181
+ MCP::Tool::Response.new([{
182
+ type: 'image',
183
+ data: result[:data],
184
+ mimeType: result[:mime_type]
185
+ }])
186
+ else
187
+ # Return a proper MCP Tool::Response with the data as text content
188
+ MCP::Tool::Response.new([{ type: 'text', text: result[:data].to_json }])
189
+ end
190
+ else
191
+ logger.error "Tool failed with error: #{result[:error]}"
192
+ # Return an error response
193
+ MCP::Tool::Response.new([{ type: 'text', text: result[:error] }], error: true)
194
+ end
195
+ rescue StandardError => e
196
+ logger.error "Tool execution error (#{tool_class.tool_name}): #{e.class} - #{e.message}"
197
+ logger.error 'Backtrace:'
198
+ logger.error e.backtrace.first(10).join("\n")
199
+ # Return an error response for unexpected exceptions
200
+ MCP::Tool::Response.new([{ type: 'text', text: "#{e.class}: #{e.message}" }], error: true)
201
+ end
202
+
203
+ # Check if tool is a session management tool
204
+ def session_management_tool?(tool_class)
205
+ [
206
+ Tools::CreateSessionTool,
207
+ Tools::ListSessionsTool,
208
+ Tools::CloseSessionTool,
209
+ Tools::GetSessionInfoTool
210
+ ].include?(tool_class)
211
+ end
212
+
213
+ def setup_error_handling
214
+ MCP.configure do |mcp_config|
215
+ mcp_config.exception_reporter = lambda { |exception, context|
216
+ logger.error '=' * 80
217
+ logger.error "MCP Exception: #{exception.class} - #{exception.message}"
218
+ logger.error "Context: #{context.inspect}"
219
+
220
+ # Log the original error if there is one
221
+ if exception.respond_to?(:original_error) && exception.original_error
222
+ logger.error "ORIGINAL ERROR: #{exception.original_error.class} - #{exception.original_error.message}"
223
+ logger.error 'ORIGINAL BACKTRACE:'
224
+ logger.error exception.original_error.backtrace.first(15).join("\n")
225
+ end
226
+
227
+ logger.error 'Exception backtrace:'
228
+ logger.error exception.backtrace.join("\n")
229
+ logger.error '=' * 80
230
+ }
231
+
232
+ mcp_config.instrumentation_callback = lambda { |data|
233
+ logger.debug "MCP Method: #{data[:method]}, Duration: #{data[:duration]}s"
234
+ }
235
+ end
236
+ end
237
+
238
+ def error_response(message)
239
+ {
240
+ jsonrpc: '2.0',
241
+ error: {
242
+ code: -32_603,
243
+ message: message
244
+ },
245
+ id: nil
246
+ }
247
+ end
248
+
249
+ # Helper to create error response for tool execution
250
+ def error_tool_response(message)
251
+ MCP::Tool::Response.new([{ type: 'text', text: message }], error: true)
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module FerrumMCP
6
+ # Represents a browser session with its own BrowserManager and configuration
7
+ class Session
8
+ attr_reader :id, :browser_manager, :config, :session_config, :created_at, :last_used_at, :metadata, :options
9
+
10
+ def initialize(config:, options: {})
11
+ @id = SecureRandom.uuid
12
+ @config = config
13
+ @options = normalize_options(options)
14
+ @created_at = Time.now
15
+ @last_used_at = Time.now
16
+ @metadata = options[:metadata] || {}
17
+ @mutex = Mutex.new
18
+ @session_config, @browser_manager = create_browser_manager
19
+ end
20
+
21
+ # Execute a block with thread-safe access to the browser
22
+ def with_browser
23
+ @mutex.synchronize do
24
+ @last_used_at = Time.now
25
+ yield @browser_manager
26
+ end
27
+ end
28
+
29
+ # Check if session is active (browser is running)
30
+ def active?
31
+ @browser_manager.active?
32
+ end
33
+
34
+ # Start the browser for this session
35
+ def start
36
+ @mutex.synchronize do
37
+ @browser_manager.start unless @browser_manager.active?
38
+ @last_used_at = Time.now
39
+ end
40
+ end
41
+
42
+ # Stop the browser for this session
43
+ def stop
44
+ @mutex.synchronize do
45
+ @browser_manager.stop if @browser_manager.active?
46
+ end
47
+ end
48
+
49
+ # Check if session is idle (not used for a while)
50
+ def idle?(timeout_seconds)
51
+ Time.now - @last_used_at > timeout_seconds
52
+ end
53
+
54
+ # Get session information
55
+ def info
56
+ {
57
+ id: @id,
58
+ active: active?,
59
+ created_at: @created_at.iso8601,
60
+ last_used_at: @last_used_at.iso8601,
61
+ idle_seconds: (Time.now - @last_used_at).to_i,
62
+ metadata: @metadata,
63
+ browser_type: browser_type,
64
+ options: sanitized_options
65
+ }
66
+ end
67
+
68
+ # Get browser type (public method for logging and info)
69
+ def browser_type
70
+ if @session_config.bot_profile
71
+ "BotBrowser (#{@session_config.bot_profile.name})"
72
+ elsif @session_config.browser
73
+ # Check ID first (for system browser), then type
74
+ if @session_config.browser.id == 'system'
75
+ 'System Chrome/Chromium'
76
+ elsif @session_config.browser.type == 'botbrowser'
77
+ "BotBrowser (#{@session_config.browser.name})"
78
+ else
79
+ @session_config.browser.name
80
+ end
81
+ else
82
+ 'System Chrome/Chromium'
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def normalize_options(options)
89
+ {
90
+ browser_id: options[:browser_id] || options['browser_id'],
91
+ browser_path: options[:browser_path] || options['browser_path'],
92
+ user_profile_id: options[:user_profile_id] || options['user_profile_id'],
93
+ bot_profile_id: options[:bot_profile_id] || options['bot_profile_id'],
94
+ botbrowser_profile: options[:botbrowser_profile] || options['botbrowser_profile'],
95
+ headless: options.fetch(:headless, options.fetch('headless', @config.headless)),
96
+ timeout: options.fetch(:timeout, options.fetch('timeout', @config.timeout)),
97
+ browser_options: options[:browser_options] || options['browser_options'] || {},
98
+ metadata: options[:metadata] || options['metadata'] || {}
99
+ }
100
+ end
101
+
102
+ def create_browser_manager
103
+ # Create a custom configuration for this session
104
+ session_config = SessionConfiguration.new(
105
+ base_config: @config,
106
+ overrides: @options
107
+ )
108
+ [session_config, BrowserManager.new(session_config)]
109
+ end
110
+
111
+ # Return sanitized options (without sensitive data)
112
+ def sanitized_options
113
+ @options.except(:metadata)
114
+ end
115
+ end
116
+
117
+ # Session-specific configuration that overrides base configuration
118
+ class SessionConfiguration
119
+ attr_reader :browser, :user_profile, :bot_profile, :headless, :timeout,
120
+ :server_host, :server_port, :log_level, :transport,
121
+ :browser_options
122
+
123
+ def initialize(base_config:, overrides:)
124
+ @base_config = base_config
125
+ @headless = overrides[:headless]
126
+ @timeout = overrides[:timeout]
127
+ @browser_options = overrides[:browser_options] || {}
128
+ @server_host = base_config.server_host
129
+ @server_port = base_config.server_port
130
+ @log_level = base_config.log_level
131
+ @transport = base_config.transport
132
+
133
+ # Resolve browser configuration
134
+ @browser = resolve_browser(overrides, base_config)
135
+ @user_profile = resolve_user_profile(overrides, base_config)
136
+ @bot_profile = resolve_bot_profile(overrides, base_config)
137
+ end
138
+
139
+ def valid?
140
+ browser&.path.nil? || File.exist?(browser.path)
141
+ end
142
+
143
+ def using_botbrowser?
144
+ browser&.type == 'botbrowser' || !bot_profile.nil?
145
+ end
146
+
147
+ # Legacy compatibility methods
148
+ def browser_path
149
+ browser&.path
150
+ end
151
+
152
+ def botbrowser_profile
153
+ bot_profile&.path
154
+ end
155
+
156
+ def logger
157
+ @base_config.logger
158
+ end
159
+
160
+ private
161
+
162
+ def resolve_browser(overrides, base_config)
163
+ # Priority: browser_id > browser_path (legacy) > default browser
164
+ if overrides[:browser_id]
165
+ base_config.find_browser(overrides[:browser_id])
166
+ elsif overrides[:browser_path]
167
+ # Legacy: create a temporary browser config
168
+ Configuration::BrowserConfig.new(
169
+ id: 'custom',
170
+ name: 'Custom Browser',
171
+ path: overrides[:browser_path],
172
+ type: overrides[:botbrowser_profile] ? 'botbrowser' : 'chrome',
173
+ description: 'Session-specific browser'
174
+ )
175
+ else
176
+ base_config.default_browser
177
+ end
178
+ end
179
+
180
+ def resolve_user_profile(overrides, base_config)
181
+ # Priority: user_profile_id > nil
182
+ return nil unless overrides[:user_profile_id]
183
+
184
+ base_config.find_user_profile(overrides[:user_profile_id])
185
+ end
186
+
187
+ def resolve_bot_profile(overrides, base_config)
188
+ # Priority: bot_profile_id > botbrowser_profile (legacy) > nil
189
+ if overrides[:bot_profile_id]
190
+ base_config.find_bot_profile(overrides[:bot_profile_id])
191
+ elsif overrides[:botbrowser_profile]
192
+ # Legacy: create a temporary bot profile config
193
+ Configuration::BotProfileConfig.new(
194
+ id: 'custom',
195
+ name: 'Custom Profile',
196
+ path: overrides[:botbrowser_profile],
197
+ encrypted: overrides[:botbrowser_profile].end_with?('.enc'),
198
+ description: 'Session-specific profile'
199
+ )
200
+ end
201
+ end
202
+
203
+ # Merge session-specific browser options with base options
204
+ def merged_browser_options
205
+ base_options = default_browser_options
206
+ base_options.merge(@browser_options)
207
+ end
208
+
209
+ def default_browser_options
210
+ options = {
211
+ 'no-sandbox' => nil,
212
+ 'disable-dev-shm-usage' => nil,
213
+ 'disable-blink-features' => 'AutomationControlled',
214
+ 'disable-gpu' => nil
215
+ }
216
+
217
+ options['disable-setuid-sandbox'] = nil if ENV['CI']
218
+
219
+ # Add BotBrowser profile if configured
220
+ if using_botbrowser? && botbrowser_profile && File.exist?(botbrowser_profile)
221
+ options['bot-profile'] = botbrowser_profile
222
+ end
223
+
224
+ options
225
+ end
226
+ end
227
+ end