gemlings-browser 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cef53274076111e67c881d45ebeb7805e97bd3dafc99cee1a8b6e0a1379eb414
4
+ data.tar.gz: b876597b34cde7a45c0c6ace5f90efe489d125478794d6d94fd20cebe8ac58c5
5
+ SHA512:
6
+ metadata.gz: 9e623e7c85927529955ac58eb5ae89a5f4f11bf162e566db501de1d82a800d0d2d0755233a1255b8241566112f713d376292fdb82e9fdbc812886c43c3a79520
7
+ data.tar.gz: 81037dcbcfad32530b4f6e5cb6833e8e75b14d98d2d968467fbfacdd511c9dce8ce5f72d32d0ccd7a9f133bea9448b16d5d84f6edee6cd1344a80a13c32f291f
data/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, Apache 2.0 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-Apache-2.0
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Abhishek Parolkar
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ Abhishek Parolkar and his affiliated entities, the party offering the Software and Content under these Terms and Conditions.
16
+
17
+ ### The Software and Content
18
+
19
+ The "Software and Content" refers to each version of the software, documentation, approaches, strategies, methodologies, and all other materials that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software and Content.
20
+
21
+ ### License Grant
22
+
23
+ Subject to your compliance with this License Grant and the Patents,
24
+ Redistribution and Trademark clauses below, we hereby grant you the right to
25
+ use, copy, modify, create derivative works, publicly perform, publicly display
26
+ and redistribute the Software and Content for any Permitted Purpose identified below.
27
+
28
+ ### Permitted Purpose
29
+
30
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
31
+ means making the Software and Content available to others in a commercial product or
32
+ service that:
33
+
34
+ 1. substitutes for the Software and Content;
35
+
36
+ 2. substitutes for any other product or service we offer using the Software and Content
37
+ that exists as of the date we make the Software and Content available; or
38
+
39
+ 3. offers the same or substantially similar functionality as the Software and Content.
40
+
41
+ Permitted Purposes specifically include using the Software and Content:
42
+
43
+ 1. for your internal use and access;
44
+
45
+ 2. for non-commercial education;
46
+
47
+ 3. for non-commercial research; and
48
+
49
+ 4. in connection with professional services that you provide to a licensee
50
+ using the Software and Content in accordance with these Terms and Conditions.
51
+
52
+ ### Patents
53
+
54
+ To the extent your use for a Permitted Purpose would necessarily infringe our
55
+ patents, the license grant above includes a license under our patents. If you
56
+ make a claim against any party that the Software and Content infringes or contributes to
57
+ the infringement of any patent, then your patent license to the Software and Content ends
58
+ immediately.
59
+
60
+ ### Redistribution
61
+
62
+ The Terms and Conditions apply to all copies, modifications and derivatives of
63
+ the Software and Content.
64
+
65
+ If you redistribute any copies, modifications or derivatives of the Software and Content,
66
+ you must include a copy of or a link to these Terms and Conditions and not
67
+ remove any copyright notices provided in or with the Software and Content.
68
+
69
+ ### Disclaimer
70
+
71
+ THE SOFTWARE AND CONTENT IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
72
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
73
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
74
+
75
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
76
+ SOFTWARE AND CONTENT, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
77
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
78
+
79
+ ### Trademarks and Domain Names
80
+
81
+ Except for displaying the License Details and identifying us as the origin of
82
+ the Software and Content, you have no right under these Terms and Conditions to use our
83
+ trademarks, trade names, service marks or product names.
84
+
85
+ The fully qualified name "https://github.com/parolkar" and the domain names associated with it are the exclusive property of Abhishek Parolkar. No right or license is granted to use these names or domains in any manner whatsoever without the express written permission of Abhishek Parolkar. Any unauthorized use of these names or domains is strictly prohibited.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software and Content under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software and Content available. On or after that date, you may use the
92
+ Software and Content under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # gemlings-browser
2
+
3
+ Browser automation for [Gemlings](https://github.com/parolkar/jrubyagents) agents, powered by Playwright.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # 1. Install the gem
9
+ gem install gemlings-browser
10
+
11
+ # 2. Install Playwright + Chromium
12
+ gemlings-browser install
13
+
14
+ # 3. Use with an agent
15
+ ```
16
+
17
+ ```ruby
18
+ require "gemlings"
19
+ require "gemlings/browser"
20
+
21
+ agent = Gemlings::CodeAgent.new(
22
+ model: "anthropic/claude-sonnet-4-20250514",
23
+ tools: [Gemlings::Browser::BrowseTool]
24
+ )
25
+
26
+ agent.run("Go to example.com and get the page title")
27
+ ```
28
+
29
+ ## Tools
30
+
31
+ ### `browser_action`
32
+
33
+ The primary tool. Executes Ruby code with browser access.
34
+
35
+ ```ruby
36
+ # The agent writes code like this:
37
+ page = browser.get_page("main")
38
+ page.goto("https://news.ycombinator.com")
39
+ titles = page.query_selector_all(".titleline a").map(&:text_content)
40
+ puts titles.first(5).join("\n")
41
+ ```
42
+
43
+ Available in scripts:
44
+ - `browser.get_page("name")` — Named page (persists across calls)
45
+ - `browser.new_page` — Anonymous page
46
+ - `browser.list_pages` — List all tabs
47
+ - `browser.close_page("name")` — Close a tab
48
+ - `save_screenshot(page, "file.png")` — Save screenshot
49
+ - `write_file("name", data)` / `read_file("name")` — Sandboxed file I/O
50
+
51
+ ### `browser_screenshot`
52
+
53
+ Takes a screenshot of a named page.
54
+
55
+ ```ruby
56
+ tools: [Gemlings::Browser::BrowseTool, Gemlings::Browser::ScreenshotTool]
57
+ ```
58
+
59
+ ### `browser_list_pages`
60
+
61
+ Lists all open pages with names, URLs, and titles.
62
+
63
+ ### `browser_close_page`
64
+
65
+ Closes a named page.
66
+
67
+ ## Configuration
68
+
69
+ ```ruby
70
+ GemlingsBrowser.configure do |c|
71
+ c.headless = true # default: false
72
+ c.timeout = 60 # default: 30 seconds
73
+ c.browser_type = :chromium # default: :chromium
74
+ c.base_dir = "~/.my-dir" # default: ~/.gemlings-browser
75
+ end
76
+ ```
77
+
78
+ ## Connecting to Existing Chrome
79
+
80
+ Launch Chrome with remote debugging:
81
+
82
+ ```bash
83
+ google-chrome --remote-debugging-port=9222
84
+ ```
85
+
86
+ Use `BrowseTool` with auto-connect:
87
+
88
+ ```ruby
89
+ tool = Gemlings::Browser::BrowseTool.new(connect: true)
90
+ agent = Gemlings::CodeAgent.new(
91
+ model: "anthropic/claude-sonnet-4-20250514",
92
+ tools: [tool]
93
+ )
94
+ ```
95
+
96
+ The gem auto-discovers Chrome via DevToolsActivePort files and port probing (9222–9229).
97
+
98
+ ## Privacy
99
+
100
+ gemlings-browser makes **zero outbound network requests** on its own. All browser activity is local and under your control.
101
+
102
+ - Playwright telemetry is disabled (`PLAYWRIGHT_TELEMETRY_DISABLED=1`)
103
+ - No analytics, tracking, or phone-home code
104
+ - Temp files are sandboxed to `~/.gemlings-browser/tmp/`
105
+ - No cloud dependencies or API keys required
106
+
107
+ ## Requirements
108
+
109
+ - Ruby 3.2+ or JRuby 10+
110
+ - Node.js (for Playwright driver)
111
+
112
+ ## License
113
+
114
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gemlings_browser/version"
5
+
6
+ case ARGV[0]
7
+ when "install"
8
+ require_relative "../lib/gemlings/browser"
9
+ GemlingsBrowser::Installer.run
10
+ when "status"
11
+ require_relative "../lib/gemlings/browser"
12
+ GemlingsBrowser::Installer.status
13
+ when "version", "-v", "--version"
14
+ puts "gemlings-browser #{GemlingsBrowser::VERSION}"
15
+ else
16
+ puts "gemlings-browser v#{GemlingsBrowser::VERSION}"
17
+ puts ""
18
+ puts "Usage:"
19
+ puts " gemlings-browser install Install Playwright + Chromium"
20
+ puts " gemlings-browser status Show installation status"
21
+ puts " gemlings-browser version Show gem version"
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main entry point for gemlings-browser.
4
+ # Usage:
5
+ # require "gemlings/browser"
6
+
7
+ # Disable Playwright telemetry — no data leaves the machine.
8
+ ENV["PLAYWRIGHT_TELEMETRY_DISABLED"] = "1"
9
+
10
+ require "gemlings"
11
+ require_relative "../gemlings_browser/version"
12
+ require_relative "../gemlings_browser/configuration"
13
+ require_relative "../gemlings_browser/page_registry"
14
+ require_relative "../gemlings_browser/chrome_discovery"
15
+ require_relative "../gemlings_browser/temp_files"
16
+ require_relative "../gemlings_browser/browser_manager"
17
+ require_relative "../gemlings_browser/installer"
18
+ require_relative "../gemlings_browser/tools/browse_tool"
19
+ require_relative "../gemlings_browser/tools/screenshot_tool"
20
+ require_relative "../gemlings_browser/tools/list_pages_tool"
21
+ require_relative "../gemlings_browser/tools/close_page_tool"
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "playwright"
4
+
5
+ module GemlingsBrowser
6
+ class BrowserManager
7
+ # Holds a Playwright connection, browser, context, and its page registry.
8
+ BrowserEntry = Struct.new(:playwright, :browser, :context, :registry, keyword_init: true)
9
+
10
+ def initialize
11
+ @browsers = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Launch or reuse a managed Chromium instance.
16
+ def ensure_browser(name, headless: nil, ignore_https_errors: false)
17
+ @mutex.synchronize do
18
+ entry = @browsers[name]
19
+ return entry if entry && browser_alive?(entry)
20
+
21
+ cleanup_entry(entry) if entry
22
+ @browsers[name] = launch_browser(headless: headless, ignore_https_errors: ignore_https_errors)
23
+ end
24
+ end
25
+
26
+ # Connect to a running browser via CDP WebSocket endpoint.
27
+ def connect_browser(name, endpoint)
28
+ @mutex.synchronize do
29
+ entry = @browsers[name]
30
+ return entry if entry && browser_alive?(entry)
31
+
32
+ cleanup_entry(entry) if entry
33
+ @browsers[name] = connect_cdp(endpoint)
34
+ end
35
+ end
36
+
37
+ # Auto-discover a running Chrome and connect to it.
38
+ # Returns the BrowserEntry, or nil if nothing found.
39
+ def auto_connect(name)
40
+ endpoint = ChromeDiscovery.new.discover
41
+ return nil unless endpoint
42
+
43
+ connect_browser(name, endpoint)
44
+ end
45
+
46
+ # Get or create a named page for the given browser.
47
+ # The page persists across calls until explicitly closed.
48
+ def get_page(browser_name, page_name)
49
+ entry = fetch_entry!(browser_name)
50
+ page = entry.registry.get(page_name)
51
+ return page if page
52
+
53
+ page = entry.context.new_page
54
+ entry.registry.set(page_name, page)
55
+ page
56
+ end
57
+
58
+ # Create an anonymous page (caller is responsible for closing it).
59
+ def new_page(browser_name)
60
+ entry = fetch_entry!(browser_name)
61
+ entry.context.new_page
62
+ end
63
+
64
+ # List all tracked pages for a browser.
65
+ def list_pages(browser_name)
66
+ entry = fetch_entry!(browser_name)
67
+ entry.registry.all
68
+ end
69
+
70
+ # Close a named page.
71
+ def close_page(browser_name, page_name)
72
+ entry = fetch_entry!(browser_name)
73
+ entry.registry.remove(page_name)
74
+ end
75
+
76
+ # Stop a specific browser and clean up its resources.
77
+ def stop_browser(name)
78
+ @mutex.synchronize do
79
+ entry = @browsers.delete(name)
80
+ cleanup_entry(entry) if entry
81
+ end
82
+ end
83
+
84
+ # Shutdown all browsers and Playwright connections.
85
+ def stop_all
86
+ @mutex.synchronize do
87
+ @browsers.each_value { |entry| cleanup_entry(entry) }
88
+ @browsers.clear
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def fetch_entry!(browser_name)
95
+ entry = @mutex.synchronize { @browsers[browser_name] }
96
+ raise "No browser named '#{browser_name}'. Call ensure_browser or auto_connect first." unless entry
97
+
98
+ entry
99
+ end
100
+
101
+ def launch_browser(headless:, ignore_https_errors:)
102
+ config = GemlingsBrowser.configuration
103
+ headless = config.headless if headless.nil?
104
+
105
+ playwright = Playwright.create(
106
+ playwright_cli_executable_path: config.playwright_cli_path
107
+ )
108
+
109
+ browser = playwright.playwright.chromium.launch(headless: headless)
110
+ context = browser.new_context(ignoreHTTPSErrors: ignore_https_errors)
111
+
112
+ BrowserEntry.new(
113
+ playwright: playwright,
114
+ browser: browser,
115
+ context: context,
116
+ registry: PageRegistry.new
117
+ )
118
+ end
119
+
120
+ def connect_cdp(endpoint)
121
+ config = GemlingsBrowser.configuration
122
+
123
+ playwright = Playwright.create(
124
+ playwright_cli_executable_path: config.playwright_cli_path
125
+ )
126
+
127
+ browser = playwright.playwright.chromium.connect_over_cdp(endpoint)
128
+ context = browser.contexts.first || browser.new_context
129
+
130
+ BrowserEntry.new(
131
+ playwright: playwright,
132
+ browser: browser,
133
+ context: context,
134
+ registry: PageRegistry.new
135
+ )
136
+ end
137
+
138
+ def browser_alive?(entry)
139
+ entry.browser.connected?
140
+ rescue StandardError
141
+ false
142
+ end
143
+
144
+ def cleanup_entry(entry)
145
+ return unless entry
146
+
147
+ entry.registry.clear
148
+ entry.context&.close rescue nil
149
+ entry.browser&.close rescue nil
150
+ entry.playwright&.stop rescue nil
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module GemlingsBrowser
7
+ class ChromeDiscovery
8
+ DISCOVERY_PORTS = (9222..9229).to_a.freeze
9
+ PROBE_TIMEOUT = 0.75 # seconds
10
+
11
+ # Attempt to discover a running Chrome instance.
12
+ # Returns a CDP WebSocket URL string, or nil.
13
+ def discover
14
+ endpoint = read_devtools_active_port
15
+ return endpoint if endpoint
16
+
17
+ DISCOVERY_PORTS.each do |port|
18
+ endpoint = probe_port(port)
19
+ return endpoint if endpoint
20
+ end
21
+
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ # --- DevToolsActivePort file parsing ---
28
+
29
+ def read_devtools_active_port
30
+ devtools_active_port_candidates.each do |path|
31
+ next unless File.exist?(path)
32
+
33
+ lines = File.read(path).strip.split("\n")
34
+ next if lines.size < 2
35
+
36
+ port = lines[0].strip.to_i
37
+ ws_path = lines[1].strip
38
+ next if port.zero? || ws_path.empty?
39
+
40
+ return "ws://127.0.0.1:#{port}#{ws_path}"
41
+ end
42
+
43
+ nil
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def devtools_active_port_candidates
49
+ home = Dir.home
50
+ case host_os
51
+ when /darwin/i
52
+ [
53
+ File.join(home, "Library", "Application Support", "Google", "Chrome", "DevToolsActivePort"),
54
+ File.join(home, "Library", "Application Support", "Google", "Chrome Canary", "DevToolsActivePort"),
55
+ File.join(home, "Library", "Application Support", "Chromium", "DevToolsActivePort"),
56
+ File.join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser", "DevToolsActivePort")
57
+ ]
58
+ when /linux/i
59
+ [
60
+ File.join(home, ".config", "google-chrome", "DevToolsActivePort"),
61
+ File.join(home, ".config", "google-chrome-unstable", "DevToolsActivePort"),
62
+ File.join(home, ".config", "chromium", "DevToolsActivePort"),
63
+ File.join(home, ".config", "BraveSoftware", "Brave-Browser", "DevToolsActivePort")
64
+ ]
65
+ when /mingw|mswin|cygwin/i
66
+ local_app_data = ENV["LOCALAPPDATA"] || File.join(home, "AppData", "Local")
67
+ [
68
+ File.join(local_app_data, "Google", "Chrome", "User Data", "DevToolsActivePort"),
69
+ File.join(local_app_data, "Chromium", "User Data", "DevToolsActivePort"),
70
+ File.join(local_app_data, "BraveSoftware", "Brave-Browser", "User Data", "DevToolsActivePort")
71
+ ]
72
+ else
73
+ []
74
+ end
75
+ end
76
+
77
+ # --- Port probing ---
78
+
79
+ def probe_port(port)
80
+ uri = URI("http://127.0.0.1:#{port}/json/version")
81
+ http = Net::HTTP.new(uri.host, uri.port)
82
+ http.open_timeout = PROBE_TIMEOUT
83
+ http.read_timeout = PROBE_TIMEOUT
84
+
85
+ response = http.get(uri.path)
86
+ return nil unless response.is_a?(Net::HTTPSuccess)
87
+
88
+ data = JSON.parse(response.body)
89
+ data["webSocketDebuggerUrl"]
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ def host_os
95
+ RbConfig::CONFIG["host_os"]
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemlingsBrowser
4
+ class Configuration
5
+ attr_accessor :headless, :timeout, :base_dir, :browser_type, :playwright_cli_path
6
+
7
+ def initialize
8
+ @headless = false
9
+ @timeout = 30
10
+ @base_dir = File.join(Dir.home, ".gemlings-browser")
11
+ @browser_type = :chromium
12
+ @playwright_cli_path = detect_playwright_cli
13
+ end
14
+
15
+ private
16
+
17
+ def detect_playwright_cli
18
+ candidates = %w[playwright]
19
+ candidates.each do |cmd|
20
+ return cmd if command_exists?(cmd)
21
+ end
22
+ "npx playwright"
23
+ end
24
+
25
+ def command_exists?(cmd)
26
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
27
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
28
+ exts.each do |ext|
29
+ path = File.join(dir, "#{cmd}#{ext}")
30
+ return true if File.executable?(path) && !File.directory?(path)
31
+ end
32
+ end
33
+ false
34
+ end
35
+ end
36
+
37
+ class << self
38
+ def configuration
39
+ @configuration ||= Configuration.new
40
+ end
41
+
42
+ def configure
43
+ yield(configuration)
44
+ end
45
+
46
+ def reset_configuration!
47
+ @configuration = Configuration.new
48
+ end
49
+
50
+ def manager
51
+ @manager ||= BrowserManager.new
52
+ end
53
+
54
+ def reset_manager!
55
+ @manager&.stop_all
56
+ @manager = nil
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemlingsBrowser
4
+ module Installer
5
+ class << self
6
+ # Install Playwright and Chromium browser.
7
+ def run
8
+ puts "Installing Playwright browsers..."
9
+ cli = GemlingsBrowser.configuration.playwright_cli_path
10
+
11
+ success = system("#{cli} install chromium")
12
+ if success
13
+ puts "Chromium installed successfully."
14
+ else
15
+ warn "Failed to install Chromium. Ensure Node.js is installed and 'npx' is available."
16
+ exit 1
17
+ end
18
+ end
19
+
20
+ # Print installation status.
21
+ def status
22
+ cli = GemlingsBrowser.configuration.playwright_cli_path
23
+ puts "gemlings-browser v#{GemlingsBrowser::VERSION}"
24
+ puts "Playwright CLI: #{cli}"
25
+ puts ""
26
+ puts "Checking installed browsers..."
27
+ system("#{cli} install --dry-run chromium 2>&1") || puts("(Could not determine browser status)")
28
+ puts ""
29
+ puts "Base directory: #{GemlingsBrowser.configuration.base_dir}"
30
+ puts "Headless: #{GemlingsBrowser.configuration.headless}"
31
+ puts "Timeout: #{GemlingsBrowser.configuration.timeout}s"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemlingsBrowser
4
+ class PageRegistry
5
+ def initialize
6
+ @pages = {}
7
+ @mutex = Mutex.new
8
+ end
9
+
10
+ # Retrieve a named page, or nil if not found / closed.
11
+ def get(name)
12
+ @mutex.synchronize do
13
+ page = @pages[name]
14
+ return nil unless page
15
+
16
+ if page_closed?(page)
17
+ @pages.delete(name)
18
+ return nil
19
+ end
20
+
21
+ page
22
+ end
23
+ end
24
+
25
+ # Store a named page.
26
+ def set(name, page)
27
+ @mutex.synchronize do
28
+ @pages[name] = page
29
+ end
30
+ end
31
+
32
+ # Return metadata for all live pages.
33
+ def all
34
+ @mutex.synchronize do
35
+ prune_closed_internal
36
+ @pages.map do |name, page|
37
+ {
38
+ name: name,
39
+ url: safe_page_attr(page, :url),
40
+ title: safe_page_attr(page, :title)
41
+ }
42
+ end
43
+ end
44
+ end
45
+
46
+ # Close and remove a named page. Returns true if found.
47
+ def remove(name)
48
+ @mutex.synchronize do
49
+ page = @pages.delete(name)
50
+ return false unless page
51
+
52
+ safe_close(page)
53
+ true
54
+ end
55
+ end
56
+
57
+ # Remove entries whose pages have been closed externally.
58
+ def prune_closed
59
+ @mutex.synchronize { prune_closed_internal }
60
+ end
61
+
62
+ # Number of tracked pages.
63
+ def size
64
+ @mutex.synchronize { @pages.size }
65
+ end
66
+
67
+ # Close all pages and clear the registry.
68
+ def clear
69
+ @mutex.synchronize do
70
+ @pages.each_value { |page| safe_close(page) }
71
+ @pages.clear
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def prune_closed_internal
78
+ @pages.delete_if { |_name, page| page_closed?(page) }
79
+ end
80
+
81
+ def page_closed?(page)
82
+ page.closed?
83
+ rescue StandardError
84
+ true
85
+ end
86
+
87
+ def safe_close(page)
88
+ page.close unless page_closed?(page)
89
+ rescue StandardError
90
+ # Page may already be gone — ignore.
91
+ end
92
+
93
+ def safe_page_attr(page, attr)
94
+ page.public_send(attr)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module GemlingsBrowser
6
+ module TempFiles
7
+ class << self
8
+ # Resolve a safe path within the sandboxed temp directory.
9
+ # Uses File.basename to prevent directory traversal attacks.
10
+ def path_for(name)
11
+ safe_name = File.basename(name.to_s)
12
+ raise ArgumentError, "Invalid filename" if safe_name.empty? || safe_name == "."
13
+
14
+ FileUtils.mkdir_p(temp_dir)
15
+ File.join(temp_dir, safe_name)
16
+ end
17
+
18
+ # Write data to a sandboxed file. Returns the full path.
19
+ def write(name, data)
20
+ path = path_for(name)
21
+ File.binwrite(path, data)
22
+ path
23
+ end
24
+
25
+ # Read a sandboxed file's contents.
26
+ def read(name)
27
+ path = path_for(name)
28
+ raise Errno::ENOENT, "File not found: #{name}" unless File.exist?(path)
29
+
30
+ File.binread(path)
31
+ end
32
+
33
+ # Convenience: return a path suitable for a screenshot.
34
+ def screenshot_path(name = nil)
35
+ name ||= "screenshot_#{Time.now.to_i}.png"
36
+ name = "#{name}.png" unless name.end_with?(".png", ".jpg", ".jpeg", ".webp")
37
+ path_for(name)
38
+ end
39
+
40
+ # Remove all temp files.
41
+ def cleanup!
42
+ FileUtils.rm_rf(temp_dir)
43
+ end
44
+
45
+ private
46
+
47
+ def temp_dir
48
+ File.join(GemlingsBrowser.configuration.base_dir, "tmp")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "timeout"
5
+
6
+ module Gemlings
7
+ module Browser
8
+ class BrowseTool < Gemlings::Tool
9
+ tool_name "browser_action"
10
+ description <<~DESC
11
+ Controls a browser using Playwright. Execute Ruby code with access to:
12
+ browser.get_page("name") - Get/create a named page (persists across calls)
13
+ browser.new_page - Create an anonymous page
14
+ browser.list_pages - List all tabs: [{name:, url:, title:}]
15
+ browser.close_page("name") - Close a named page
16
+ save_screenshot(page, "name.png") - Save screenshot, returns path
17
+ write_file("name", data) - Write to sandboxed temp dir, returns path
18
+ read_file("name") - Read from sandboxed temp dir
19
+
20
+ Pages are Playwright Page objects with full API:
21
+ page.goto(url), page.click(selector), page.fill(selector, value),
22
+ page.title, page.url, page.text_content(selector),
23
+ page.screenshot(path: "..."), page.evaluate(js),
24
+ page.query_selector_all(selector), page.wait_for_selector(selector), etc.
25
+
26
+ Named pages persist across calls — no need to re-navigate.
27
+ DESC
28
+ input :script, type: :string, description: "Ruby code to execute with browser access"
29
+ output_type :string
30
+
31
+ def initialize(browser_name: "default", headless: nil, connect: nil)
32
+ @browser_name = browser_name
33
+ @headless = headless
34
+ @connect = connect
35
+ end
36
+
37
+ def call(script:)
38
+ manager = GemlingsBrowser.manager
39
+
40
+ if @connect
41
+ unless manager.auto_connect(@browser_name)
42
+ manager.ensure_browser(@browser_name, headless: @headless)
43
+ end
44
+ else
45
+ manager.ensure_browser(@browser_name, headless: @headless)
46
+ end
47
+
48
+ context = build_context(manager)
49
+ execute_script(context, script)
50
+ end
51
+
52
+ private
53
+
54
+ def execute_script(context, script)
55
+ output = StringIO.new
56
+ old_stdout = $stdout
57
+
58
+ begin
59
+ $stdout = output
60
+ result = Timeout.timeout(GemlingsBrowser.configuration.timeout) do
61
+ context.instance_eval(script, "(browser-script)", 1)
62
+ end
63
+ $stdout = old_stdout
64
+
65
+ parts = []
66
+ parts << output.string unless output.string.empty?
67
+ parts << result.inspect unless result.nil?
68
+ parts.empty? ? "" : parts.join("\n")
69
+ rescue Timeout::Error
70
+ $stdout = old_stdout
71
+ "Error: Script timed out after #{GemlingsBrowser.configuration.timeout}s"
72
+ rescue => e
73
+ $stdout = old_stdout
74
+ "Error: #{e.class}: #{e.message}"
75
+ end
76
+ end
77
+
78
+ def build_context(manager)
79
+ ctx = Object.new
80
+ browser_name = @browser_name
81
+
82
+ # Build the browser API object
83
+ browser_api = Object.new
84
+ browser_api.define_singleton_method(:get_page) do |name|
85
+ manager.get_page(browser_name, name)
86
+ end
87
+ browser_api.define_singleton_method(:new_page) do
88
+ manager.new_page(browser_name)
89
+ end
90
+ browser_api.define_singleton_method(:list_pages) do
91
+ manager.list_pages(browser_name)
92
+ end
93
+ browser_api.define_singleton_method(:close_page) do |name|
94
+ manager.close_page(browser_name, name)
95
+ end
96
+
97
+ ctx.define_singleton_method(:browser) { browser_api }
98
+
99
+ # File I/O helpers
100
+ ctx.define_singleton_method(:save_screenshot) do |page, name|
101
+ path = GemlingsBrowser::TempFiles.screenshot_path(name)
102
+ page.screenshot(path: path)
103
+ path
104
+ end
105
+
106
+ ctx.define_singleton_method(:write_file) do |name, data|
107
+ GemlingsBrowser::TempFiles.write(name, data)
108
+ end
109
+
110
+ ctx.define_singleton_method(:read_file) do |name|
111
+ GemlingsBrowser::TempFiles.read(name)
112
+ end
113
+
114
+ ctx
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemlings
4
+ module Browser
5
+ class ClosePageTool < Gemlings::Tool
6
+ tool_name "browser_close_page"
7
+ description "Closes a named browser page."
8
+ input :page_name, type: :string, description: "Name of the page to close"
9
+ output_type :string
10
+
11
+ def call(page_name:)
12
+ manager = GemlingsBrowser.manager
13
+ if manager.close_page("default", page_name)
14
+ "Page '#{page_name}' closed."
15
+ else
16
+ "No page named '#{page_name}' found."
17
+ end
18
+ rescue => e
19
+ "Error: #{e.class}: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemlings
4
+ module Browser
5
+ class ListPagesTool < Gemlings::Tool
6
+ tool_name "browser_list_pages"
7
+ description "Lists all open browser pages/tabs with their names, URLs, and titles."
8
+ output_type :string
9
+
10
+ def call(**_kwargs)
11
+ manager = GemlingsBrowser.manager
12
+ pages = manager.list_pages("default")
13
+
14
+ if pages.empty?
15
+ "No open pages."
16
+ else
17
+ lines = pages.map do |info|
18
+ "#{info[:name]}: #{info[:url] || '(blank)'} - #{info[:title] || '(untitled)'}"
19
+ end
20
+ lines.join("\n")
21
+ end
22
+ rescue => e
23
+ "Error: #{e.class}: #{e.message}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemlings
4
+ module Browser
5
+ class ScreenshotTool < Gemlings::Tool
6
+ tool_name "browser_screenshot"
7
+ description "Takes a screenshot of a named browser page. Returns the file path."
8
+ input :page_name, type: :string, description: "Name of the page to screenshot"
9
+ input :filename, type: :string, description: "Output filename (e.g. 'debug.png')", required: false
10
+ output_type :string
11
+
12
+ def call(page_name:, filename: nil)
13
+ manager = GemlingsBrowser.manager
14
+ page = manager.get_page("default", page_name)
15
+
16
+ path = GemlingsBrowser::TempFiles.screenshot_path(filename || "#{page_name}.png")
17
+ page.screenshot(path: path)
18
+ path
19
+ rescue => e
20
+ "Error: #{e.class}: #{e.message}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemlingsBrowser
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemlings-browser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Abhishek Parolkar
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: gemlings
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: playwright-ruby-client
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.50'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.50'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: Gives Gemlings agents browser control via Playwright. Named persistent
69
+ pages, auto-connect to Chrome, screenshots, and sandboxed file I/O. Zero telemetry.
70
+ email:
71
+ - abhishek@parolkar.com
72
+ executables:
73
+ - gemlings-browser
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - exe/gemlings-browser
80
+ - lib/gemlings/browser.rb
81
+ - lib/gemlings_browser/browser_manager.rb
82
+ - lib/gemlings_browser/chrome_discovery.rb
83
+ - lib/gemlings_browser/configuration.rb
84
+ - lib/gemlings_browser/installer.rb
85
+ - lib/gemlings_browser/page_registry.rb
86
+ - lib/gemlings_browser/temp_files.rb
87
+ - lib/gemlings_browser/tools/browse_tool.rb
88
+ - lib/gemlings_browser/tools/close_page_tool.rb
89
+ - lib/gemlings_browser/tools/list_pages_tool.rb
90
+ - lib/gemlings_browser/tools/screenshot_tool.rb
91
+ - lib/gemlings_browser/version.rb
92
+ homepage: https://github.com/parolkar/gemlings-browser
93
+ licenses:
94
+ - nonstandard
95
+ metadata:
96
+ homepage_uri: https://github.com/parolkar/gemlings-browser
97
+ source_code_uri: https://github.com/parolkar/gemlings-browser
98
+ changelog_uri: https://github.com/parolkar/gemlings-browser/blob/main/CHANGELOG.md
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 3.2.0
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.7.2
114
+ specification_version: 4
115
+ summary: Browser automation tool for Gemlings agents
116
+ test_files: []