headless_browser_tool 0.1.1

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +21 -0
  3. data/.rubocop.yml +56 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CLAUDE.md +298 -0
  7. data/LICENSE.md +7 -0
  8. data/README.md +522 -0
  9. data/Rakefile +12 -0
  10. data/config.ru +8 -0
  11. data/exe/hbt +7 -0
  12. data/lib/headless_browser_tool/browser.rb +374 -0
  13. data/lib/headless_browser_tool/browser_adapter.rb +320 -0
  14. data/lib/headless_browser_tool/cli.rb +34 -0
  15. data/lib/headless_browser_tool/directory_setup.rb +25 -0
  16. data/lib/headless_browser_tool/logger.rb +31 -0
  17. data/lib/headless_browser_tool/server.rb +150 -0
  18. data/lib/headless_browser_tool/session_manager.rb +199 -0
  19. data/lib/headless_browser_tool/session_middleware.rb +158 -0
  20. data/lib/headless_browser_tool/session_persistence.rb +146 -0
  21. data/lib/headless_browser_tool/stdio_server.rb +73 -0
  22. data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
  23. data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
  24. data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
  25. data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
  26. data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
  27. data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
  28. data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
  29. data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
  30. data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
  31. data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
  32. data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
  33. data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
  34. data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
  35. data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
  36. data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
  37. data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
  38. data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
  39. data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
  40. data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
  41. data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
  42. data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
  43. data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
  44. data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
  45. data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
  46. data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
  47. data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
  48. data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
  49. data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
  50. data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
  51. data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
  52. data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
  53. data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
  54. data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
  55. data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
  56. data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
  57. data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
  58. data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
  59. data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
  60. data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
  61. data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
  62. data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
  63. data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
  64. data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
  65. data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
  66. data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
  67. data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
  68. data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
  69. data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
  70. data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
  71. data/lib/headless_browser_tool/tools.rb +104 -0
  72. data/lib/headless_browser_tool/version.rb +5 -0
  73. data/lib/headless_browser_tool.rb +8 -0
  74. metadata +256 -0
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module HeadlessBrowserTool
6
+ module DirectorySetup
7
+ HBT_DIR = "./.hbt"
8
+ SCREENSHOTS_DIR = File.join(HBT_DIR, "screenshots").freeze
9
+ SESSIONS_DIR = File.join(HBT_DIR, "sessions").freeze
10
+ LOGS_DIR = File.join(HBT_DIR, "logs").freeze
11
+
12
+ module_function
13
+
14
+ def setup_directories(include_logs: false)
15
+ # Create all necessary directories
16
+ FileUtils.mkdir_p(SCREENSHOTS_DIR)
17
+ FileUtils.mkdir_p(SESSIONS_DIR)
18
+ FileUtils.mkdir_p(LOGS_DIR) if include_logs
19
+
20
+ # Create .gitignore in .hbt directory
21
+ gitignore_path = File.join(HBT_DIR, ".gitignore")
22
+ File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "fileutils"
5
+ require_relative "directory_setup"
6
+
7
+ module HeadlessBrowserTool
8
+ class Logger
9
+ class << self
10
+ attr_accessor :instance
11
+
12
+ def initialize_logger(mode: :http)
13
+ @instance = if mode == :stdio
14
+ # In stdio mode, write to log file
15
+ log_file = File.join(DirectorySetup::LOGS_DIR, "#{Process.pid}.log")
16
+ ::Logger.new(log_file, progname: "HBT")
17
+ else
18
+ # In HTTP mode, write to stdout
19
+ ::Logger.new($stdout, progname: "HBT")
20
+ end
21
+
22
+ @instance.level = ::Logger::INFO
23
+ @instance
24
+ end
25
+
26
+ def log
27
+ @instance ||= initialize_logger
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "fast_mcp"
5
+ require "rack"
6
+ require "fileutils"
7
+ require "securerandom"
8
+ require "json"
9
+ require_relative "browser"
10
+ require_relative "tools"
11
+ require_relative "session_manager"
12
+ require_relative "session_middleware"
13
+ require_relative "strict_session_middleware"
14
+ require_relative "browser_adapter"
15
+ require_relative "logger"
16
+ require_relative "session_persistence"
17
+ require_relative "directory_setup"
18
+
19
+ module HeadlessBrowserTool
20
+ class Server < Sinatra::Base
21
+ class << self
22
+ attr_accessor :browser_instance, :session_manager, :single_session_mode, :show_headers, :session_id
23
+
24
+ def start_server(options = {})
25
+ # Initialize logger for HTTP mode
26
+ HeadlessBrowserTool::Logger.initialize_logger(mode: :http)
27
+
28
+ # Check if we should use single session mode
29
+ @single_session_mode = options[:single_session] || ENV["HBT_SINGLE_SESSION"] == "true"
30
+ @show_headers = options[:show_headers] || ENV["HBT_SHOW_HEADERS"] == "true"
31
+
32
+ # Validate session_id option
33
+ if options[:session_id] && !@single_session_mode
34
+ puts "Error: --session-id can only be used with --single-session"
35
+ exit 1
36
+ end
37
+
38
+ if @single_session_mode
39
+ puts "Running in single session mode"
40
+ if options[:session_id]
41
+ puts "Session ID: #{options[:session_id]}"
42
+ @session_id = options[:session_id]
43
+ end
44
+ @browser_instance = Browser.new(headless: options[:headless])
45
+
46
+ # Restore session if session_id provided
47
+ restore_single_session if @session_id
48
+ else
49
+ puts "Running in multi-session mode"
50
+ @session_manager = SessionManager.new(headless: options[:headless])
51
+ end
52
+
53
+ # Setup directory structure
54
+ DirectorySetup.setup_directories
55
+
56
+ puts "Starting HeadlessBrowserTool MCP server on port #{options[:port]}"
57
+ puts "Using fast-mcp for MCP protocol support"
58
+
59
+ # Register shutdown hook for single session persistence
60
+ at_exit { save_single_session } if @single_session_mode && @session_id
61
+
62
+ # Configure and run with Puma directly
63
+ require "puma"
64
+ require "puma/configuration"
65
+ require "puma/launcher"
66
+
67
+ puma_config = Puma::Configuration.new do |config|
68
+ config.bind "tcp://0.0.0.0:#{options[:port]}"
69
+ config.environment "production"
70
+ config.quiet false
71
+ config.app Server
72
+ end
73
+
74
+ launcher = Puma::Launcher.new(puma_config)
75
+ launcher.run
76
+ end
77
+
78
+ private
79
+
80
+ def restore_single_session
81
+ SessionPersistence.restore_session(@session_id, @browser_instance.session)
82
+ end
83
+
84
+ def save_single_session
85
+ return unless @session_id && @browser_instance
86
+
87
+ SessionPersistence.save_session(@session_id, @browser_instance.session)
88
+ end
89
+ end
90
+
91
+ # Use strict session middleware if in multi-session mode
92
+ use StrictSessionMiddleware unless Server.single_session_mode
93
+
94
+ # Create MCP server instance with custom context handler
95
+ mcp_server = FastMcp::Server.new(name: "headless-browser-tool", version: HeadlessBrowserTool::VERSION)
96
+
97
+ # Register all browser tools
98
+ HeadlessBrowserTool::Tools::ALL_TOOLS.each do |tool_class|
99
+ mcp_server.register_tool(tool_class)
100
+ end
101
+
102
+ # Create custom transport that passes session context
103
+ class SessionAwareTransport < FastMcp::Transports::RackTransport
104
+ def call(env)
105
+ # Get or create session ID for this connection
106
+ session_id = env["hbt.session_id"]
107
+
108
+ # Use the session ID from middleware (which handles persistence)
109
+ # Don't generate new ones here - let the middleware handle it
110
+
111
+ # Store session ID in thread local for tools to access
112
+ Thread.current[:hbt_session_id] = session_id
113
+
114
+ # Call parent
115
+ super
116
+ ensure
117
+ Thread.current[:hbt_session_id] = nil
118
+ end
119
+ end
120
+
121
+ # Use our custom transport
122
+ use SessionAwareTransport, mcp_server
123
+
124
+ # Session management endpoints
125
+ get "/sessions" do
126
+ if Server.single_session_mode
127
+ { mode: "single", message: "Server is running in single session mode" }.to_json
128
+ else
129
+ content_type :json
130
+ Server.session_manager.session_info.to_json
131
+ end
132
+ end
133
+
134
+ delete "/sessions/:id" do
135
+ if Server.single_session_mode
136
+ status 400
137
+ { error: "Cannot manage sessions in single session mode" }.to_json
138
+ else
139
+ Server.session_manager.close_session(params[:id])
140
+ { message: "Session closed", session_id: params[:id] }.to_json
141
+ end
142
+ end
143
+
144
+ # Default route
145
+ get "/" do
146
+ mode = Server.single_session_mode ? "single session" : "multi-session"
147
+ "HeadlessBrowserTool MCP Server is running in #{mode} mode"
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+ require "json"
5
+ require "fileutils"
6
+ require_relative "logger"
7
+ require_relative "session_persistence"
8
+ require_relative "directory_setup"
9
+
10
+ module HeadlessBrowserTool
11
+ class SessionManager
12
+ SESSION_TIMEOUT = 30 * 60 # 30 minutes
13
+ CLEANUP_INTERVAL = 60 # 1 minute
14
+ MAX_SESSIONS = 10 # Maximum concurrent sessions
15
+
16
+ attr_reader :sessions_dir
17
+
18
+ def initialize(headless: true)
19
+ @sessions = {}
20
+ @session_data = {}
21
+ @mutex = Mutex.new
22
+ @headless = headless
23
+
24
+ # Enable Capybara threadsafe mode for per-session configuration
25
+ Capybara.threadsafe = true
26
+
27
+ # Use common sessions directory
28
+ @sessions_dir = DirectorySetup::SESSIONS_DIR
29
+
30
+ # Load existing session data
31
+ load_persisted_sessions
32
+
33
+ # Start cleanup thread
34
+ start_cleanup_thread
35
+
36
+ # Register shutdown hook
37
+ at_exit { shutdown_all_sessions }
38
+ end
39
+
40
+ def get_or_create_session(session_id)
41
+ @mutex.synchronize do
42
+ # Validate session_id
43
+ raise ArgumentError, "Invalid session ID: #{session_id}" unless valid_session_id?(session_id)
44
+
45
+ # Update last activity
46
+ session_data = @session_data[session_id] ||= {
47
+ created_at: Time.now,
48
+ last_activity: Time.now
49
+ }
50
+ session_data[:last_activity] = Time.now
51
+
52
+ # Check if we're at capacity
53
+ cleanup_least_recently_used if !@sessions[session_id] && @sessions.size >= MAX_SESSIONS
54
+
55
+ # Get or create the Capybara session
56
+ @sessions[session_id] ||= create_session(session_id)
57
+ end
58
+ end
59
+
60
+ def close_session(session_id)
61
+ @mutex.synchronize do
62
+ if (session = @sessions.delete(session_id))
63
+ begin
64
+ save_session_state(session_id, session)
65
+ session.quit
66
+ rescue StandardError => e
67
+ HeadlessBrowserTool::Logger.log.info "Error closing session #{session_id}: #{e.message}"
68
+ end
69
+ end
70
+ @session_data.delete(session_id)
71
+ end
72
+ end
73
+
74
+ def save_all_sessions
75
+ @mutex.synchronize do
76
+ @sessions.each do |session_id, session|
77
+ save_session_state(session_id, session)
78
+ end
79
+ end
80
+ end
81
+
82
+ def session_info
83
+ @mutex.synchronize do
84
+ {
85
+ active_sessions: @sessions.keys,
86
+ session_count: @sessions.size,
87
+ session_data: @session_data.transform_values do |data|
88
+ {
89
+ created_at: data[:created_at],
90
+ last_activity: data[:last_activity],
91
+ idle_time: Time.now - data[:last_activity]
92
+ }
93
+ end
94
+ }
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def create_session(session_id)
101
+ HeadlessBrowserTool::Logger.log.info "Creating new Capybara session: #{session_id}"
102
+
103
+ # Create a new Capybara session
104
+ # With threadsafe mode enabled, each session is isolated
105
+ session = Capybara::Session.new(:selenium_chrome_headless)
106
+
107
+ # Try to restore previous state
108
+ restore_session_state(session_id, session)
109
+
110
+ session
111
+ rescue StandardError => e
112
+ HeadlessBrowserTool::Logger.log.info "Error creating session #{session_id}: #{e.message}"
113
+ raise
114
+ end
115
+
116
+ def save_session_state(session_id, session)
117
+ SessionPersistence.save_session(session_id, session)
118
+ end
119
+
120
+ def restore_session_state(session_id, session)
121
+ SessionPersistence.restore_session(session_id, session)
122
+ end
123
+
124
+ def load_persisted_sessions
125
+ Dir.glob(File.join(@sessions_dir, "*.json")).each do |file|
126
+ state = JSON.parse(File.read(file))
127
+ session_id = state["session_id"]
128
+
129
+ # Initialize session data for persisted sessions
130
+ @session_data[session_id] = {
131
+ created_at: Time.parse(state["saved_at"]),
132
+ last_activity: Time.now,
133
+ persisted: true
134
+ }
135
+
136
+ HeadlessBrowserTool::Logger.log.info "Found persisted session: #{session_id}"
137
+ rescue StandardError => e
138
+ HeadlessBrowserTool::Logger.log.info "Error loading persisted session from #{file}: #{e.message}"
139
+ end
140
+ end
141
+
142
+ def start_cleanup_thread
143
+ Thread.new do
144
+ loop do
145
+ sleep CLEANUP_INTERVAL
146
+ cleanup_idle_sessions
147
+ end
148
+ end
149
+ end
150
+
151
+ def cleanup_idle_sessions
152
+ @mutex.synchronize do
153
+ now = Time.now
154
+ sessions_to_close = []
155
+
156
+ @session_data.each do |session_id, data|
157
+ sessions_to_close << session_id if now - data[:last_activity] > SESSION_TIMEOUT
158
+ end
159
+
160
+ sessions_to_close.each do |session_id|
161
+ HeadlessBrowserTool::Logger.log.info "Cleaning up idle session: #{session_id} (idle for #{(now - @session_data[session_id][:last_activity]).to_i}s)" # rubocop:disable Layout/LineLength
162
+ close_session(session_id)
163
+ end
164
+ end
165
+ rescue StandardError => e
166
+ HeadlessBrowserTool::Logger.log.info "Error during cleanup: #{e.message}"
167
+ end
168
+
169
+ def cleanup_least_recently_used
170
+ # Find the least recently used session
171
+ lru_session = @session_data.min_by { |_, data| data[:last_activity] }&.first
172
+
173
+ return unless lru_session
174
+
175
+ HeadlessBrowserTool::Logger.log.info "Closing LRU session to make room: #{lru_session}"
176
+ close_session(lru_session)
177
+ end
178
+
179
+ def shutdown_all_sessions
180
+ HeadlessBrowserTool::Logger.log.info "Shutting down all sessions..."
181
+ @mutex.synchronize do
182
+ @sessions.each do |session_id, session|
183
+ save_session_state(session_id, session)
184
+ session.quit
185
+ rescue StandardError => e
186
+ HeadlessBrowserTool::Logger.log.info "Error shutting down session #{session_id}: #{e.message}"
187
+ end
188
+ @sessions.clear
189
+ end
190
+ end
191
+
192
+ def valid_session_id?(session_id)
193
+ session_id.is_a?(String) &&
194
+ session_id.length >= 1 &&
195
+ session_id.length <= 64 &&
196
+ session_id.match?(/\A[a-zA-Z0-9_\-]+\z/)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "logger"
5
+
6
+ module HeadlessBrowserTool
7
+ class SessionMiddleware
8
+ # Headers to check for session ID (in priority order)
9
+ SESSION_HEADERS = %w[
10
+ HTTP_X_SESSION_ID
11
+ HTTP_X_CLIENT_ID
12
+ ].freeze
13
+
14
+ DEFAULT_SESSION = "default"
15
+
16
+ # Class-level session storage for MCP connections
17
+ @mcp_sessions = {}
18
+ @session_mutex = Mutex.new
19
+
20
+ class << self
21
+ attr_accessor :mcp_sessions, :session_mutex
22
+ end
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ # Log request headers if enabled
30
+ log_request_headers(env) if HeadlessBrowserTool::Server.show_headers
31
+
32
+ # Extract session ID from headers first
33
+ session_id = extract_session_id(env)
34
+
35
+ # For MCP connections, try to maintain session continuity
36
+ session_id = get_or_create_mcp_session(env) if session_id == DEFAULT_SESSION && mcp_request?(env)
37
+
38
+ # Store in environment for downstream use
39
+ env["hbt.session_id"] = session_id
40
+
41
+ # Call the app
42
+ status, headers, response = @app.call(env)
43
+
44
+ # Add session ID to response headers
45
+ headers["X-Session-ID"] = session_id
46
+
47
+ # For MCP SSE connections, send session info
48
+ if env["PATH_INFO"] == "/mcp/sse" && session_id != DEFAULT_SESSION
49
+ # Store for future requests from this client
50
+ store_mcp_session(env, session_id)
51
+ end
52
+
53
+ [status, headers, response]
54
+ end
55
+
56
+ private
57
+
58
+ def extract_session_id(env)
59
+ # Check for explicit session headers
60
+ SESSION_HEADERS.each do |header|
61
+ if (value = env[header])
62
+ sanitized = sanitize_session_id(value)
63
+ return sanitized if sanitized
64
+ end
65
+ end
66
+
67
+ # Default session if none provided
68
+ DEFAULT_SESSION
69
+ end
70
+
71
+ def sanitize_session_id(value)
72
+ return nil if value.nil? || value.empty?
73
+
74
+ # Ensure it's alphanumeric with underscores/hyphens only
75
+ sanitized = value.gsub(/[^a-zA-Z0-9_\-]/, "")
76
+
77
+ # Limit length
78
+ sanitized = sanitized[0..64]
79
+
80
+ # Return nil if invalid
81
+ return nil if sanitized.empty?
82
+
83
+ sanitized
84
+ end
85
+
86
+ def mcp_request?(env)
87
+ env["PATH_INFO"]&.start_with?("/mcp")
88
+ end
89
+
90
+ def get_or_create_mcp_session(env)
91
+ client_key = generate_client_key(env)
92
+
93
+ self.class.session_mutex.synchronize do
94
+ # Check if we have a session for this client
95
+ if self.class.mcp_sessions[client_key]
96
+ self.class.mcp_sessions[client_key][:last_seen] = Time.now
97
+ return self.class.mcp_sessions[client_key][:session_id]
98
+ end
99
+
100
+ # Create new session
101
+ session_id = "mcp_#{SecureRandom.hex(8)}"
102
+ self.class.mcp_sessions[client_key] = {
103
+ session_id: session_id,
104
+ created_at: Time.now,
105
+ last_seen: Time.now
106
+ }
107
+
108
+ # Clean old sessions
109
+ cleanup_old_sessions
110
+
111
+ session_id
112
+ end
113
+ end
114
+
115
+ def store_mcp_session(env, session_id)
116
+ client_key = generate_client_key(env)
117
+
118
+ self.class.session_mutex.synchronize do
119
+ self.class.mcp_sessions[client_key] = {
120
+ session_id: session_id,
121
+ created_at: Time.now,
122
+ last_seen: Time.now
123
+ }
124
+ end
125
+ end
126
+
127
+ def generate_client_key(env)
128
+ # Use IP + User-Agent as a simple client identifier
129
+ # This isn't perfect but works for most cases
130
+ ip = env["REMOTE_ADDR"] || "unknown"
131
+ user_agent = env["HTTP_USER_AGENT"] || "unknown"
132
+ "#{ip}:#{user_agent.hash}"
133
+ end
134
+
135
+ def cleanup_old_sessions
136
+ # Remove sessions older than 1 hour
137
+ cutoff = Time.now - 3600
138
+ self.class.mcp_sessions.delete_if { |_, data| data[:last_seen] < cutoff }
139
+ end
140
+
141
+ def log_request_headers(env)
142
+ HeadlessBrowserTool::Logger.log.info "\n=== REQUEST HEADERS ==="
143
+ HeadlessBrowserTool::Logger.log.info "Method: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
144
+ HeadlessBrowserTool::Logger.log.info "Remote IP: #{env["REMOTE_ADDR"]}"
145
+
146
+ # Log all HTTP headers
147
+ env.select { |k, _v| k.start_with?("HTTP_") }.each do |key, value|
148
+ header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
149
+ HeadlessBrowserTool::Logger.log.info "#{header_name}: #{value}"
150
+ end
151
+
152
+ # Log specific session-related info
153
+ HeadlessBrowserTool::Logger.log.info "Session ID from headers: #{extract_session_id(env)}"
154
+ HeadlessBrowserTool::Logger.log.info "Client Key: #{generate_client_key(env)}"
155
+ HeadlessBrowserTool::Logger.log.info "=====================\n"
156
+ end
157
+ end
158
+ end