browserctl 0.3.1 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +120 -214
  4. data/bin/browserctl +35 -13
  5. data/bin/browserd +7 -1
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +1 -1
  8. data/examples/smoke/params_file.rb +35 -0
  9. data/examples/smoke/store_fetch.rb +39 -0
  10. data/examples/the_internet/add_remove_elements.rb +3 -3
  11. data/examples/the_internet/checkboxes.rb +3 -3
  12. data/examples/the_internet/dropdown.rb +3 -3
  13. data/examples/the_internet/dynamic_loading.rb +3 -3
  14. data/examples/the_internet/login.rb +5 -5
  15. data/lib/browserctl/client.rb +38 -2
  16. data/lib/browserctl/commands/export_cookies.rb +18 -0
  17. data/lib/browserctl/commands/import_cookies.rb +23 -0
  18. data/lib/browserctl/commands/init.rb +11 -0
  19. data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
  20. data/lib/browserctl/commands/record.rb +2 -0
  21. data/lib/browserctl/commands/resume.rb +21 -0
  22. data/lib/browserctl/commands/snapshot.rb +5 -5
  23. data/lib/browserctl/commands/status.rb +30 -0
  24. data/lib/browserctl/constants.rb +9 -2
  25. data/lib/browserctl/detectors.rb +23 -0
  26. data/lib/browserctl/errors.rb +25 -0
  27. data/lib/browserctl/logger.rb +40 -5
  28. data/lib/browserctl/policy.rb +36 -0
  29. data/lib/browserctl/recording.rb +81 -15
  30. data/lib/browserctl/runner.rb +23 -4
  31. data/lib/browserctl/server/command_dispatcher.rb +31 -234
  32. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  33. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  34. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  35. data/lib/browserctl/server/handlers/hitl.rb +30 -0
  36. data/lib/browserctl/server/handlers/navigation.rb +72 -0
  37. data/lib/browserctl/server/handlers/observation.rb +113 -0
  38. data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
  39. data/lib/browserctl/server.rb +18 -2
  40. data/lib/browserctl/version.rb +1 -1
  41. data/lib/browserctl/workflow.rb +41 -3
  42. data/lib/browserctl.rb +12 -2
  43. metadata +48 -4
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Smoke test for --params file loading (Task 7.5).
5
+ #
6
+ # Run with:
7
+ # browserctl run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
8
+ #
9
+ # The workflow logs in using credentials from the params file and asserts
10
+ # the secure area is reached — proving the params were loaded and available.
11
+
12
+ Browserctl.workflow "smoke/params_file" do
13
+ desc "Smoke: load credentials from a --params file and use them in a workflow"
14
+
15
+ param :username, required: true
16
+ param :password, required: true, secret: true
17
+ param :base_url, default: "https://the-internet.herokuapp.com"
18
+
19
+ step "open login page" do
20
+ client.open_page("main", url: "#{base_url}/login")
21
+ end
22
+
23
+ step "fill credentials from params file" do
24
+ puts " [params] username = #{username.inspect}"
25
+ puts " [params] password = (#{password.length} chars, secret)"
26
+ page(:main).fill("input#username", username)
27
+ page(:main).fill("input#password", password)
28
+ page(:main).click("button[type=submit]")
29
+ end
30
+
31
+ step "assert login succeeded" do
32
+ assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
33
+ puts " [ok] reached secure area — params file loaded correctly"
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Smoke test for WorkflowContext#store / #fetch (Task 7.3).
5
+ #
6
+ # Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
7
+ # capture the text in step 1, assert it is still accessible in step 2 via fetch.
8
+
9
+ Browserctl.workflow "smoke/store_fetch" do
10
+ desc "Smoke: store a value in one step and retrieve it in a later step"
11
+
12
+ param :base_url, default: "https://the-internet.herokuapp.com"
13
+
14
+ step "open dynamic loading page" do
15
+ client.open_page("main", url: "#{base_url}/dynamic_loading/1")
16
+ end
17
+
18
+ step "click start and capture loaded text" do
19
+ page(:main).click("div#start button")
20
+ page(:main).wait_for("div#finish", timeout: 10)
21
+ text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
22
+ assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
23
+ store(:loaded_text, text)
24
+ puts " [store] loaded_text = #{text.inspect}"
25
+ end
26
+
27
+ step "fetch value from previous step and assert" do
28
+ text = fetch(:loaded_text)
29
+ puts " [fetch] loaded_text = #{text.inspect}"
30
+ assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
31
+ end
32
+
33
+ step "confirm fetch raises for unknown key" do
34
+ fetch(:nonexistent_key)
35
+ assert false, "expected KeyError was not raised"
36
+ rescue KeyError => e
37
+ puts " [ok] KeyError raised as expected: #{e.message}"
38
+ end
39
+ end
@@ -3,7 +3,8 @@
3
3
  Browserctl.workflow "the_internet/add_remove_elements" do
4
4
  desc "Add/Remove Elements: add several elements, remove some, assert final count"
5
5
 
6
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :base_url, default: "https://the-internet.herokuapp.com"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_add_remove_elements.png")
7
8
 
8
9
  step "open add/remove elements page" do
9
10
  client.open_page("main", url: "#{base_url}/add_remove_elements/")
@@ -13,8 +14,7 @@ Browserctl.workflow "the_internet/add_remove_elements" do
13
14
  3.times { page(:main).click("button[onclick]") }
14
15
  count = client.evaluate("main", "document.querySelectorAll('#elements button').length")[:result]
15
16
  assert count == 3, "expected 3 elements, got: #{count}"
16
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
17
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_add_remove_elements.png")
17
+ page(:main).screenshot(path: screenshot_path)
18
18
  end
19
19
 
20
20
  step "remove one element" do
@@ -3,7 +3,8 @@
3
3
  Browserctl.workflow "the_internet/checkboxes" do
4
4
  desc "Checkboxes: read state, toggle each, verify both checked"
5
5
 
6
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :base_url, default: "https://the-internet.herokuapp.com"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_checkboxes.png")
7
8
 
8
9
  step "open checkboxes page" do
9
10
  client.open_page("main", url: "#{base_url}/checkboxes")
@@ -29,7 +30,6 @@ Browserctl.workflow "the_internet/checkboxes" do
29
30
  end
30
31
 
31
32
  step "capture screenshot" do
32
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
33
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_checkboxes.png")
33
+ page(:main).screenshot(path: screenshot_path)
34
34
  end
35
35
  end
@@ -3,7 +3,8 @@
3
3
  Browserctl.workflow "the_internet/dropdown" do
4
4
  desc "Dropdown: select each option via JS, assert selected value"
5
5
 
6
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :base_url, default: "https://the-internet.herokuapp.com"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dropdown.png")
7
8
 
8
9
  step "open dropdown page" do
9
10
  client.open_page("main", url: "#{base_url}/dropdown")
@@ -27,7 +28,6 @@ Browserctl.workflow "the_internet/dropdown" do
27
28
  end
28
29
 
29
30
  step "capture screenshot" do
30
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
31
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dropdown.png")
31
+ page(:main).screenshot(path: screenshot_path)
32
32
  end
33
33
  end
@@ -3,7 +3,8 @@
3
3
  Browserctl.workflow "the_internet/dynamic_loading" do
4
4
  desc "Dynamic loading: click Start, wait for hidden element to appear"
5
5
 
6
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :base_url, default: "https://the-internet.herokuapp.com"
7
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_dynamic_loading.png")
7
8
 
8
9
  step "open dynamic loading page" do
9
10
  client.open_page("main", url: "#{base_url}/dynamic_loading/1")
@@ -27,7 +28,6 @@ Browserctl.workflow "the_internet/dynamic_loading" do
27
28
  sleep 0.2 until client.evaluate("main",
28
29
  "document.querySelector('#loading')?.style?.display")[:result] == "none" ||
29
30
  Time.now > deadline
30
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
31
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_dynamic_loading.png")
31
+ page(:main).screenshot(path: screenshot_path)
32
32
  end
33
33
  end
@@ -3,9 +3,10 @@
3
3
  Browserctl.workflow "the_internet/login" do
4
4
  desc "Form authentication: fill credentials, submit, assert secure area"
5
5
 
6
- param :username, default: "tomsmith"
7
- param :password, default: "SuperSecretPassword!", secret: true
8
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :username, default: "tomsmith"
7
+ param :password, default: "SuperSecretPassword!", secret: true
8
+ param :base_url, default: "https://the-internet.herokuapp.com"
9
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_login.png")
9
10
 
10
11
  step "open login page" do
11
12
  client.open_page("main", url: "#{base_url}/login")
@@ -21,8 +22,7 @@ Browserctl.workflow "the_internet/login" do
21
22
  assert page(:main).url.include?("/secure"), "expected redirect to /secure"
22
23
  flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
23
24
  assert flash&.include?("You logged into a secure area!"), "expected success flash, got: #{flash.inspect}"
24
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
25
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_login.png")
25
+ page(:main).screenshot(path: screenshot_path)
26
26
  end
27
27
 
28
28
  step "logout and verify" do
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "socket"
4
5
  require "json"
5
6
  require_relative "constants"
@@ -73,10 +74,10 @@ module Browserctl
73
74
 
74
75
  # Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
75
76
  # @param name [String] logical page name
76
- # @param format [String] "ai" (token-efficient JSON) or "html" (raw HTML)
77
+ # @param format [String] "elements" (interactable elements JSON) or "html" (raw HTML)
77
78
  # @param diff [Boolean] return only elements changed since last snapshot
78
79
  # @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
79
- def snapshot(name, format: "ai", diff: false)
80
+ def snapshot(name, format: "elements", diff: false)
80
81
  call("snapshot", name: name, format: format, diff: diff)
81
82
  end
82
83
 
@@ -130,6 +131,17 @@ module Browserctl
130
131
  # @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
131
132
  def inspect_page(name) = call("inspect", name: name)
132
133
 
134
+ # Stores a value in the daemon-scoped key-value store.
135
+ # @param key [String] storage key
136
+ # @param value [Object] value to store (must be JSON-serialisable)
137
+ # @return [Hash] `{ ok: true }` or `{ error: }`
138
+ def store(key, value) = call("store", key: key, value: value)
139
+
140
+ # Retrieves a value from the daemon-scoped key-value store.
141
+ # @param key [String] storage key
142
+ # @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
143
+ def fetch(key) = call("fetch", key: key)
144
+
133
145
  # Returns all cookies for a named page.
134
146
  # @param name [String] logical page name
135
147
  # @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
@@ -152,6 +164,30 @@ module Browserctl
152
164
  # @return [Hash] `{ ok: true }` or `{ error: }`
153
165
  def clear_cookies(name) = call("clear_cookies", name: name)
154
166
 
167
+ # Exports all cookies for a named page to a JSON file.
168
+ # @param name [String] logical page name
169
+ # @param path [String] file path to write cookies to
170
+ # @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
171
+ def export_cookies(name, path)
172
+ result = call("cookies", name: name)
173
+ return result unless result[:ok]
174
+
175
+ FileUtils.mkdir_p(File.dirname(path))
176
+ File.open(path, "w", 0o600) { |f| f.write(JSON.generate(result[:cookies])) }
177
+ { ok: true, path: path, count: result[:cookies].length }
178
+ end
179
+
180
+ # Imports cookies from a JSON file into a named page.
181
+ # @param name [String] logical page name
182
+ # @param path [String] file path to read cookies from
183
+ # @return [Hash] `{ ok: true, count: }` or `{ error: }`
184
+ def import_cookies(name, path)
185
+ raise "cookie file not found: #{path}" unless File.exist?(path)
186
+
187
+ cookies = JSON.parse(File.read(path), symbolize_names: true)
188
+ call("import_cookies", name: name, cookies: cookies)
189
+ end
190
+
155
191
  private
156
192
 
157
193
  def communicate(payload)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ class ExportCookies
6
+ def self.run(client, args)
7
+ page = args.shift or abort "usage: browserctl export-cookies <page> <path>"
8
+ path = args.shift or abort "usage: browserctl export-cookies <page> <path>"
9
+ result = client.export_cookies(page, path)
10
+ if result[:error]
11
+ warn "Error: #{result[:error]}"
12
+ exit 1
13
+ end
14
+ puts result.to_json
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ class ImportCookies
6
+ def self.run(client, args)
7
+ page = args.shift or abort "usage: browserctl import-cookies <page> <path>"
8
+ path = args.shift or abort "usage: browserctl import-cookies <page> <path>"
9
+ begin
10
+ result = client.import_cookies(page, path)
11
+ rescue StandardError => e
12
+ warn "Error: #{e.message}"
13
+ exit 1
14
+ end
15
+ if result[:error]
16
+ warn "Error: #{result[:error]}"
17
+ exit 1
18
+ end
19
+ puts result.to_json
20
+ end
21
+ end
22
+ end
23
+ end
@@ -13,15 +13,26 @@ module Browserctl
13
13
  # workflows_dir: .browserctl/workflows
14
14
  YAML
15
15
 
16
+ GITIGNORE_CONTENT = <<~GITIGNORE
17
+ # Cookie session exports — contain credentials, never commit
18
+ sessions/
19
+ GITIGNORE
20
+
16
21
  def self.run(_args)
17
22
  FileUtils.mkdir_p(".browserctl/workflows")
18
23
  FileUtils.touch(".browserctl/workflows/.keep")
19
24
 
25
+ FileUtils.mkdir_p(".browserctl/sessions")
26
+
27
+ gitignore_path = ".browserctl/.gitignore"
28
+ File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
29
+
20
30
  config_path = ".browserctl/config.yml"
21
31
  File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
22
32
 
23
33
  puts "Initialised browserctl project:"
24
34
  puts " .browserctl/workflows/ (place workflow .rb files here)"
35
+ puts " .browserctl/sessions/ (cookie exports — git-ignored)"
25
36
  puts " .browserctl/config.yml (project settings)"
26
37
  end
27
38
  end
@@ -4,10 +4,10 @@ require_relative "cli_output"
4
4
 
5
5
  module Browserctl
6
6
  module Commands
7
- module PauseResume
7
+ module Pause
8
8
  extend CliOutput
9
9
 
10
- def self.pause(client, args)
10
+ def self.run(client, args)
11
11
  name = args.shift or abort "usage: browserctl pause <page>"
12
12
  res = client.pause(name)
13
13
  if res[:error]
@@ -17,16 +17,6 @@ module Browserctl
17
17
  puts "Page '#{name}' paused. Browser is live — interact freely."
18
18
  puts "When done: browserctl resume #{name}"
19
19
  end
20
-
21
- def self.resume(client, args)
22
- name = args.shift or abort "usage: browserctl resume <page>"
23
- res = client.resume(name)
24
- if res[:error]
25
- warn "Error: #{res[:error]}"
26
- exit 1
27
- end
28
- puts "Page '#{name}' resumed."
29
- end
30
20
  end
31
21
  end
32
22
  end
@@ -26,6 +26,8 @@ module Browserctl
26
26
  def run_start(args)
27
27
  Optimist.options(args) { banner "Usage: browserctl record start <name>" }
28
28
  name = args.shift or abort "usage: browserctl record start <name>"
29
+ abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
30
+ unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
29
31
  Recording.start(name)
30
32
  puts "Recording started: #{name}"
31
33
  puts "Run browser commands, then: browserctl record stop"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Resume
8
+ extend CliOutput
9
+
10
+ def self.run(client, args)
11
+ name = args.shift or abort "usage: browserctl resume <page>"
12
+ res = client.resume(name)
13
+ if res[:error]
14
+ warn "Error: #{res[:error]}"
15
+ exit 1
16
+ end
17
+ puts "Page '#{name}' resumed."
18
+ end
19
+ end
20
+ end
21
+ end
@@ -6,15 +6,15 @@ require "optimist"
6
6
  module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
- VALID_FORMATS = %w[ai html].freeze
9
+ VALID_FORMATS = %w[elements html].freeze
10
10
 
11
11
  def self.run(client, args)
12
12
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
14
- opt :format, "Output format: ai or html", default: "ai", short: "-f"
13
+ banner "Usage: browserctl snap <page> [--format elements|html] [--diff]"
14
+ opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
15
15
  opt :diff, "Return only changed elements", default: false, short: "-d"
16
16
  end
17
- name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
17
+ name = args.shift or abort "usage: browserctl snap <page> [--format elements|html] [--diff]"
18
18
  unless VALID_FORMATS.include?(opts[:format])
19
19
  warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
20
  exit 1
@@ -31,7 +31,7 @@ module Browserctl
31
31
  warn "Error: #{res[:error]}"
32
32
  exit 1
33
33
  end
34
- puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
34
+ puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Status
8
+ def self.run(client)
9
+ ping = client.ping
10
+ pages = client.list_pages[:pages] || []
11
+ page_info = pages.map do |name|
12
+ url_res = client.url(name)
13
+ { name: name, url: url_res[:url] || url_res[:error] }
14
+ end
15
+
16
+ puts JSON.pretty_generate(
17
+ daemon: "online",
18
+ pid: ping[:pid],
19
+ protocol_version: ping[:protocol_version],
20
+ pages: page_info
21
+ )
22
+ rescue RuntimeError => e
23
+ raise unless e.message.include?("browserd is not running")
24
+
25
+ puts JSON.pretty_generate(daemon: "offline", error: e.message)
26
+ exit 1
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
- IDLE_TTL = 30 * 60
4
+ BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
+ IDLE_TTL = 30 * 60
6
+ # Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
7
+ # Clients read this from `ping` to verify compatibility before sending commands.
8
+ PROTOCOL_VERSION = "1"
6
9
 
7
10
  def self.socket_path(name = nil)
8
11
  File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
@@ -12,6 +15,10 @@ module Browserctl
12
15
  File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
13
16
  end
14
17
 
18
+ def self.log_path(name = nil)
19
+ File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
20
+ end
21
+
15
22
  # Backward-compatible constants
16
23
  SOCKET_PATH = socket_path
17
24
  PID_PATH = pid_path
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Detectors
5
+ CLOUDFLARE_SIGNALS = [
6
+ "cf-challenge-running",
7
+ "cf_chl_opt",
8
+ "__cf_chl_f_tk",
9
+ "Just a moment..."
10
+ ].freeze
11
+
12
+ # Returns true if the page appears to be showing a Cloudflare challenge.
13
+ # Checks both the current URL and the page body for known Cloudflare signals.
14
+ # @param page [Ferrum::Page] the browser page to inspect
15
+ # @return [Boolean]
16
+ def self.cloudflare?(page)
17
+ url = page.current_url.to_s
18
+ body = page.body.to_s
19
+ url.include?("challenge-platform") ||
20
+ CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ # Base error class for all browserctl daemon errors.
5
+ # Subclasses carry a machine-readable `code` that appears in wire responses.
6
+ # @attr_reader code [String] machine-readable error code
7
+ class Error < StandardError
8
+ def self.default_code = "error"
9
+
10
+ attr_reader :code
11
+
12
+ def initialize(msg = nil, code: self.class.default_code)
13
+ @code = code
14
+ super(msg)
15
+ end
16
+ end
17
+
18
+ class PageNotFound < Error; def self.default_code = "page_not_found" end
19
+ class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
+ class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
+ class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
+ class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
+ class TimeoutError < Error; def self.default_code = "timeout" end
24
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "fileutils"
4
5
 
5
6
  module Browserctl
6
7
  LEVEL_MAP = {
@@ -10,6 +11,25 @@ module Browserctl
10
11
  "error" => ::Logger::ERROR
11
12
  }.freeze
12
13
 
14
+ class MultiLogger
15
+ def initialize(*loggers)
16
+ @loggers = loggers
17
+ end
18
+
19
+ # Delegate to each logger; swallow individual write failures so a broken file
20
+ # logger never crashes the daemon or drops a client response.
21
+ def debug(msg = nil, &) = @loggers.each { |l| l.debug(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
22
+ def info(msg = nil, &) = @loggers.each { |l| l.info(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
23
+ def warn(msg = nil, &) = @loggers.each { |l| l.warn(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
24
+ def error(msg = nil, &) = @loggers.each { |l| l.error(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
25
+
26
+ def level = @loggers.first&.level
27
+
28
+ def level=(lvl)
29
+ @loggers.each { |l| l.level = lvl }
30
+ end
31
+ end
32
+
13
33
  def self.logger
14
34
  @logger ||= build_logger("info")
15
35
  end
@@ -18,11 +38,26 @@ module Browserctl
18
38
  @logger = instance
19
39
  end
20
40
 
21
- def self.build_logger(level_name)
22
- log = ::Logger.new($stderr)
23
- log.level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
24
- log.progname = "browserd"
25
- log.formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
41
+ def self.build_logger(level_name, log_path: nil)
42
+ level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
43
+ formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
44
+
45
+ stderr_log = make_logger($stderr, level, formatter)
46
+ return stderr_log unless log_path
47
+
48
+ FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
49
+ FileUtils.touch(log_path)
50
+ File.chmod(0o600, log_path)
51
+ file_log = make_logger(log_path, level, formatter)
52
+ MultiLogger.new(stderr_log, file_log)
53
+ end
54
+
55
+ def self.make_logger(device, level, formatter)
56
+ log = ::Logger.new(device)
57
+ log.level = level
58
+ log.progname = "browserd"
59
+ log.formatter = formatter
26
60
  log
27
61
  end
62
+ private_class_method :make_logger
28
63
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Browserctl
6
+ module Policy
7
+ # Returns true if the URL is permitted by the domain policy.
8
+ # When BROWSERCTL_ALLOWED_DOMAINS is unset, all URLs are allowed.
9
+ # @param url [String] the URL to check
10
+ # @return [Boolean]
11
+ def self.allowed_navigation?(url)
12
+ domains = allowed_domains
13
+ return true if domains.empty?
14
+
15
+ host_matches?(URI.parse(url).host, domains)
16
+ rescue URI::InvalidURIError
17
+ false
18
+ end
19
+
20
+ def self.allowed_domains
21
+ raw = ENV.fetch("BROWSERCTL_ALLOWED_DOMAINS", nil)
22
+ return [] unless raw&.match?(/\S/)
23
+
24
+ raw.split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ def self.host_matches?(host, domains)
28
+ return false unless host
29
+
30
+ normalised = host.downcase
31
+ domains.any? { |d| normalised == d.downcase || normalised.end_with?(".#{d.downcase}") }
32
+ end
33
+
34
+ private_class_method :allowed_domains, :host_matches?
35
+ end
36
+ end