browserctl 0.10.0 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +1 -1
  4. data/bin/browserctl +45 -4
  5. data/lib/browserctl/client.rb +47 -3
  6. data/lib/browserctl/commands/cli_output.rb +16 -3
  7. data/lib/browserctl/commands/flow.rb +123 -0
  8. data/lib/browserctl/commands/state.rb +193 -0
  9. data/lib/browserctl/commands/workflow.rb +62 -4
  10. data/lib/browserctl/constants.rb +1 -1
  11. data/lib/browserctl/detectors/auth_required.rb +128 -0
  12. data/lib/browserctl/detectors.rb +2 -0
  13. data/lib/browserctl/errors.rb +30 -0
  14. data/lib/browserctl/flow.rb +22 -1
  15. data/lib/browserctl/flow_registry.rb +66 -0
  16. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  17. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  18. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  19. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  20. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  21. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  22. data/lib/browserctl/recording.rb +212 -26
  23. data/lib/browserctl/replay/context.rb +40 -0
  24. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  25. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  26. data/lib/browserctl/replay/telemetry.rb +60 -0
  27. data/lib/browserctl/runner.rb +38 -4
  28. data/lib/browserctl/server/command_dispatcher.rb +10 -1
  29. data/lib/browserctl/server/handlers/interaction.rb +3 -3
  30. data/lib/browserctl/server/handlers/navigation.rb +33 -4
  31. data/lib/browserctl/server/handlers/observation.rb +43 -2
  32. data/lib/browserctl/server/handlers/state.rb +149 -0
  33. data/lib/browserctl/server/page_session.rb +9 -7
  34. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  35. data/lib/browserctl/snapshot/annotator.rb +75 -0
  36. data/lib/browserctl/snapshot/extractor.rb +21 -0
  37. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  38. data/lib/browserctl/snapshot/ref.rb +70 -0
  39. data/lib/browserctl/snapshot/serializer.rb +17 -0
  40. data/lib/browserctl/state/bundle.rb +242 -0
  41. data/lib/browserctl/state/transport.rb +64 -0
  42. data/lib/browserctl/state/transports/file.rb +35 -0
  43. data/lib/browserctl/state/transports/one_password.rb +67 -0
  44. data/lib/browserctl/state/transports/s3.rb +42 -0
  45. data/lib/browserctl/state.rb +208 -0
  46. data/lib/browserctl/version.rb +1 -1
  47. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  48. data/lib/browserctl/workflow/promoter.rb +96 -0
  49. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  50. data/lib/browserctl/workflow.rb +180 -16
  51. metadata +31 -2
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "json"
4
5
  require_relative "cli_output"
6
+ require_relative "../recording"
7
+ require_relative "../workflow/promoter"
5
8
 
6
9
  module Browserctl
7
10
  module Commands
8
11
  module Workflow
9
12
  extend CliOutput
10
13
 
11
- USAGE = "Usage: browserctl workflow <run|list|describe> [args]"
14
+ USAGE = "Usage: browserctl workflow <run|list|describe|generate|promote> [args]"
12
15
 
13
16
  def self.run(runner, args)
14
17
  sub = args.shift or abort USAGE
@@ -16,18 +19,73 @@ module Browserctl
16
19
  when "run" then run_workflow(runner, args)
17
20
  when "list" then run_list(runner)
18
21
  when "describe" then run_describe(runner, args)
22
+ when "generate" then run_generate(args)
23
+ when "promote" then run_promote(args)
19
24
  else abort "unknown workflow subcommand '#{sub}'\n#{USAGE}"
20
25
  end
21
26
  end
22
27
 
28
+ def self.run_promote(args)
29
+ name = args.shift or abort \
30
+ "usage: browserctl workflow promote <name> [--force] [--threshold N] [--as-flow]"
31
+
32
+ force = !args.delete("--force").nil?
33
+ as_flow = !args.delete("--as-flow").nil?
34
+
35
+ threshold_idx = args.index("--threshold")
36
+ threshold = if threshold_idx
37
+ val = args.delete_at(threshold_idx + 1)
38
+ args.delete_at(threshold_idx)
39
+ Integer(val)
40
+ else
41
+ Browserctl::Workflow::PromotionLedger::DEFAULT_THRESHOLD
42
+ end
43
+
44
+ result = Browserctl::Workflow::Promoter.promote(
45
+ workflow: name, force: force, threshold: threshold, as_flow: as_flow
46
+ )
47
+ puts JSON.generate(ok: true, **result)
48
+ rescue Browserctl::Workflow::Promoter::IneligibleError => e
49
+ puts JSON.generate(
50
+ ok: false, error: "ineligible",
51
+ message: e.message, streak: e.streak, threshold: e.threshold
52
+ )
53
+ exit 1
54
+ rescue Browserctl::Workflow::Promoter::NotFoundError => e
55
+ abort "Error: #{e.message}"
56
+ rescue ArgumentError => e
57
+ abort "Error: invalid --threshold value: #{e.message}"
58
+ end
59
+
60
+ def self.run_generate(args)
61
+ name = args.shift or abort \
62
+ "usage: browserctl workflow generate <recording> [--out PATH]"
63
+ out_idx = args.index("--out")
64
+ out = if out_idx
65
+ args.delete_at(out_idx + 1).tap { args.delete_at(out_idx) }
66
+ else
67
+ File.join(".browserctl/workflows", "#{name}.rb")
68
+ end
69
+ FileUtils.mkdir_p(File.dirname(out))
70
+ Browserctl::Recording.generate_workflow(name, output_path: out, keep_log: true)
71
+ puts JSON.generate({ ok: true, name: name, path: out })
72
+ rescue StandardError => e
73
+ abort "Error generating workflow: #{e.message}"
74
+ end
75
+
76
+ EXIT_CODE = { clean: 0, drift: 2, fail: 1 }.freeze
77
+
23
78
  def self.run_workflow(runner, args)
24
- name = args.shift or abort "usage: browserctl workflow run <name|file> [--params file] [--key value ...]"
79
+ name = args.shift or abort \
80
+ "usage: browserctl workflow run <name|file> [--check] [--params file] [--key value ...]"
25
81
  if File.exist?(name)
26
82
  before = Browserctl.registry_snapshot.keys
27
83
  load File.expand_path(name)
28
84
  name = (Browserctl.registry_snapshot.keys - before).first || File.basename(name, ".rb")
29
85
  end
30
86
 
87
+ check = !args.delete("--check").nil?
88
+
31
89
  params_file_idx = args.index("--params")
32
90
  file_params = {}
33
91
  if params_file_idx
@@ -47,8 +105,8 @@ module Browserctl
47
105
  end
48
106
 
49
107
  params = file_params.merge(cli_params)
50
- success = runner.run_workflow(name, **params)
51
- exit(success ? 0 : 1)
108
+ verdict = runner.run_workflow(name, check: check, **params)
109
+ exit(EXIT_CODE.fetch(verdict, 1))
52
110
  end
53
111
 
54
112
  def self.run_list(runner)
@@ -5,7 +5,7 @@ module Browserctl
5
5
  IDLE_TTL = 30 * 60
6
6
  # Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
7
7
  # Clients read this from `ping` to verify compatibility before sending commands.
8
- PROTOCOL_VERSION = "2"
8
+ PROTOCOL_VERSION = "3"
9
9
 
10
10
  def self.socket_path(name = nil)
11
11
  File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Detectors
5
+ # Detects "you need to log in again" — the signal that flips a workflow's
6
+ # `load_state` into `state rotate` territory. Lives alongside the older
7
+ # `Detectors.cloudflare?` and follows the same `(page) -> Result` shape.
8
+ #
9
+ # Three independent checks, evaluated in order. The first to fire wins:
10
+ #
11
+ # 1. URL → login path. The page is currently sitting on /login,
12
+ # /signin, /auth/login, or a similar canonical login route. This
13
+ # catches redirect-based auth ("we noticed you're not logged in").
14
+ # 2. Recent HTTP 401 / 403. The most recent network response on this
15
+ # page was an auth challenge from the backend.
16
+ # 3. Cookie ledger. A caller-supplied list of cookies contains entries
17
+ # that have already expired. Useful when the daemon is preflighting
18
+ # a bundle before navigating.
19
+ #
20
+ # Each check is pure — no daemon dependencies — so the same detector
21
+ # runs server-side (handlers/observation.rb) and client-side (workflow
22
+ # `load_state` hook).
23
+ module AuthRequired
24
+ Result = Struct.new(:triggered, :code, :reason, :suggested_flow, keyword_init: true) do
25
+ def to_h
26
+ { triggered: triggered, code: code, reason: reason, suggested_flow: suggested_flow }.compact
27
+ end
28
+ end
29
+
30
+ LOGIN_PATH_RE = %r{(?:^|/)(login|signin|sign[-_]in|auth/(?:login|signin)|account/login)(?:/|$|\?)}i
31
+
32
+ AUTH_HTTP_STATUSES = [401, 403].freeze
33
+
34
+ class << self
35
+ # Run every check; return the first triggered Result, or a non-
36
+ # triggered Result if all pass.
37
+ #
38
+ # @param page [#current_url] anything quacking like a Page
39
+ # @param recent_responses [Array<Hash>] each `{ status:, url: }`; the
40
+ # caller is responsible for collecting these (e.g. via Ferrum's
41
+ # network.traffic). Empty by default so callers without traffic
42
+ # instrumentation still get the URL/cookie checks.
43
+ # @param cookies [Array<Hash>, nil] each `{ name:, expires: }`;
44
+ # `expires` is a unix timestamp. nil to skip the cookie check.
45
+ # @param suggested_flow [String, nil] flow name to surface when the
46
+ # detector fires — populated by callers from the bundle manifest.
47
+ # @return [Result]
48
+ def detect(page, recent_responses: [], cookies: nil, suggested_flow: nil)
49
+ [
50
+ check_url(page, suggested_flow),
51
+ check_responses(recent_responses, suggested_flow),
52
+ check_cookies(cookies, suggested_flow)
53
+ ].find(&:triggered) || negative
54
+ end
55
+
56
+ # Convenience predicate matching the existing `Detectors.cloudflare?`
57
+ # style. Callers that just need a boolean can use this.
58
+ def triggered?(page, **)
59
+ detect(page, **).triggered
60
+ end
61
+
62
+ private
63
+
64
+ def check_url(page, suggested_flow)
65
+ url = safe_call(page, :current_url).to_s
66
+ match = LOGIN_PATH_RE.match(url)
67
+ return negative unless match
68
+
69
+ Result.new(triggered: true, code: "redirect_login",
70
+ reason: "url '#{url}' matches login path", suggested_flow: suggested_flow)
71
+ end
72
+
73
+ def check_responses(recent_responses, suggested_flow)
74
+ response = Array(recent_responses).reverse_each.find do |r|
75
+ AUTH_HTTP_STATUSES.include?(r[:status] || r["status"])
76
+ end
77
+ return negative unless response
78
+
79
+ status = response[:status] || response["status"]
80
+ url = response[:url] || response["url"]
81
+ Result.new(triggered: true, code: "http_#{status}",
82
+ reason: "recent #{status} from #{url}", suggested_flow: suggested_flow)
83
+ end
84
+
85
+ def check_cookies(cookies, suggested_flow)
86
+ return negative if cookies.nil?
87
+
88
+ expired = expired_cookies(cookies)
89
+ return negative if expired.empty?
90
+
91
+ names = expired.map { |c| c[:name] || c["name"] }.compact.join(", ")
92
+ Result.new(triggered: true, code: "cookie_expired",
93
+ reason: "expired cookies: #{names}", suggested_flow: suggested_flow)
94
+ end
95
+
96
+ def expired_cookies(cookies)
97
+ now = Time.now.to_f
98
+ Array(cookies).select { |c| cookie_expired?(c, now) }
99
+ end
100
+
101
+ def cookie_expired?(cookie, now)
102
+ exp = cookie[:expires] || cookie["expires"]
103
+ return false unless exp
104
+
105
+ float = exp.to_f
106
+ float.positive? && float < now
107
+ end
108
+
109
+ def negative
110
+ Result.new(triggered: false, code: nil, reason: nil, suggested_flow: nil)
111
+ end
112
+
113
+ def safe_call(obj, method)
114
+ obj.respond_to?(method) ? obj.public_send(method) : nil
115
+ end
116
+ end
117
+ end
118
+
119
+ # Module-level shorthand so callers match the existing `Detectors.cloudflare?` style.
120
+ def self.auth_required?(page, **)
121
+ AuthRequired.triggered?(page, **)
122
+ end
123
+
124
+ def self.auth_required(page, **)
125
+ AuthRequired.detect(page, **)
126
+ end
127
+ end
128
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "detectors/auth_required"
4
+
3
5
  module Browserctl
4
6
  module Detectors
5
7
  CLOUDFLARE_SIGNALS = [
@@ -25,6 +25,36 @@ module Browserctl
25
25
  class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
26
26
  class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
27
27
 
28
+ # Raised when the daemon detects that the current page needs authentication —
29
+ # the canonical signal that a workflow's `load_state` should rotate the bound
30
+ # flow. Carries the bundle name (when a state load was in progress) and a
31
+ # suggested flow (from the bundle manifest) so callers can recover without
32
+ # additional lookups. The CLI maps this code to exit status 7.
33
+ class AuthRequiredError < Error
34
+ def self.default_code = "AUTH_REQUIRED"
35
+
36
+ AUTH_REQUIRED_EXIT_CODE = 7
37
+
38
+ attr_reader :state, :suggested_flow, :reason
39
+
40
+ def initialize(msg = "authentication required", state: nil, suggested_flow: nil, reason: nil)
41
+ super(msg)
42
+ @state = state
43
+ @suggested_flow = suggested_flow
44
+ @reason = reason
45
+ end
46
+
47
+ def to_response
48
+ {
49
+ error: message,
50
+ code: self.class.default_code,
51
+ state: state,
52
+ suggested_flow: suggested_flow,
53
+ reason: reason
54
+ }.compact
55
+ end
56
+ end
57
+
28
58
  class WorkflowError < Error; def self.default_code = "workflow_error" end
29
59
  class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
30
60
 
@@ -186,9 +186,30 @@ module Browserctl
186
186
  end
187
187
  end
188
188
 
189
+ @flow_registry_mutex = Mutex.new
190
+ @flow_registry = {}
191
+
189
192
  def self.flow(name, &block)
190
193
  raise ArgumentError, "Browserctl.flow requires a block" unless block
191
194
 
192
- Flow.new(name).tap { |f| f.instance_exec(&block) }
195
+ flow = Flow.new(name).tap { |f| f.instance_exec(&block) }
196
+ register_flow(flow)
197
+ flow
198
+ end
199
+
200
+ def self.register_flow(flow)
201
+ @flow_registry_mutex.synchronize { @flow_registry[flow.name] = flow }
202
+ end
203
+
204
+ def self.lookup_flow(name)
205
+ @flow_registry_mutex.synchronize { @flow_registry[name.to_s] }
206
+ end
207
+
208
+ def self.flow_registry_snapshot
209
+ @flow_registry_mutex.synchronize { @flow_registry.dup }
210
+ end
211
+
212
+ def self.flow_registry_reset!
213
+ @flow_registry_mutex.synchronize { @flow_registry.clear }
193
214
  end
194
215
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flow"
4
+
5
+ module Browserctl
6
+ # Discovers and loads flow files from project, user, and bundled stdlib
7
+ # locations. Project flows shadow user flows, which shadow stdlib flows.
8
+ #
9
+ # Files are plain Ruby; loading them is expected to invoke
10
+ # `Browserctl.flow(name) { ... }`, which registers the flow in the global
11
+ # registry. Filename should match the registered name (validated lazily —
12
+ # mismatches are allowed but discouraged).
13
+ class FlowRegistry
14
+ SAFE_NAME = /\A[a-zA-Z0-9_-]+\z/
15
+
16
+ # Search order: highest-precedence last so later registrations
17
+ # overwrite earlier ones in the global registry.
18
+ def self.bundled_dir = File.expand_path("flows/stdlib", __dir__)
19
+ def self.user_dir = File.expand_path("~/.browserctl/flows")
20
+ def self.project_dir = "./.browserctl/flows"
21
+
22
+ def self.search_paths
23
+ [bundled_dir, user_dir, project_dir]
24
+ end
25
+
26
+ # Loads every flow file from every search path. Lower-precedence dirs
27
+ # run first; project files load last and win on name collisions.
28
+ def self.load_all
29
+ search_paths.each do |dir|
30
+ next unless Dir.exist?(dir)
31
+
32
+ Dir.glob(File.join(dir, "*.rb")).each { |f| load f }
33
+ end
34
+ Browserctl.flow_registry_snapshot
35
+ end
36
+
37
+ # Resolves a name to a registered flow, loading from disk on demand.
38
+ # Searches in precedence order: project → user → stdlib. The first
39
+ # matching file is loaded and the flow returned via the global registry.
40
+ def self.resolve(name)
41
+ validate_name!(name)
42
+ existing = Browserctl.lookup_flow(name)
43
+ return existing if existing
44
+
45
+ search_paths.reverse_each do |dir|
46
+ candidate = File.join(dir, "#{name}.rb")
47
+ next unless File.exist?(candidate)
48
+
49
+ load candidate
50
+ flow = Browserctl.lookup_flow(name)
51
+ return flow if flow
52
+ end
53
+ nil
54
+ end
55
+
56
+ def self.list
57
+ load_all.map { |n, f| { name: n, desc: f.description, version: f.version_string } }
58
+ end
59
+
60
+ def self.validate_name!(name)
61
+ return if SAFE_NAME.match?(name.to_s)
62
+
63
+ raise ArgumentError, "invalid flow name: #{name.inspect} — use letters, digits, _ and - only"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../../flow"
5
+
6
+ # Authenticates with an HTTP Basic Auth-protected URL by navigating to it
7
+ # with credentials embedded in the URL. This avoids the native auth
8
+ # dialog entirely; Chromium ingests the userinfo and supplies it on the
9
+ # request without prompting.
10
+ #
11
+ # Use for sites where the auth challenge is real HTTP Basic. For form-
12
+ # based "username + password" logins, use a workflow with `page.fill`.
13
+ Browserctl.flow("basic_auth") do
14
+ version "1.0.0"
15
+ requires_browserctl "0.11.0"
16
+ desc "Navigate to an HTTP Basic Auth URL using credentials embedded in the URL."
17
+
18
+ param :url, required: true
19
+ param :username, required: true
20
+ param :password, required: true, secret: true
21
+
22
+ precondition("page proxy is present") { !page.nil? }
23
+
24
+ step("navigate with embedded credentials") do
25
+ parsed = URI.parse(url)
26
+ parsed.user = URI.encode_www_form_component(username)
27
+ parsed.password = URI.encode_www_form_component(password)
28
+ page.navigate(parsed.to_s)
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../detectors"
4
+ require_relative "../../flow"
5
+
6
+ # Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
7
+ # moment...", interactive checkbox), then verifies the challenge cleared
8
+ # before returning. Optionally saves the post-solve session under a name
9
+ # you can reload later with `state load` or `session_load`.
10
+ #
11
+ # Reuses Browserctl::Detectors.cloudflare? — the server-side detector
12
+ # already shipped in v0.8 — by adapting the client-facing PageProxy to
13
+ # the duck-typed (current_url, body) interface the detector expects.
14
+ module Browserctl
15
+ module Flows
16
+ PageDetectorAdapter = Struct.new(:current_url, :body)
17
+
18
+ module CloudflareSolve
19
+ module_function
20
+
21
+ def detect?(page_proxy)
22
+ body = page_proxy.evaluate("document.body && document.body.innerText || ''").to_s
23
+ adapter = PageDetectorAdapter.new(page_proxy.url, body)
24
+ Browserctl::Detectors.cloudflare?(adapter)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ Browserctl.flow("cloudflare_solve") do
31
+ version "1.0.0"
32
+ requires_browserctl "0.11.0"
33
+ desc "Pause for a human to solve a Cloudflare challenge; verify it cleared; optionally capture state."
34
+
35
+ param :prompt,
36
+ default: "Cloudflare challenge detected. Solve it in the browser, then press Enter to continue."
37
+ param :state_name # optional — if set, session_save is called after the challenge clears
38
+
39
+ precondition("page proxy is present") { !page.nil? }
40
+ precondition("cloudflare challenge is present") do
41
+ Browserctl::Flows::CloudflareSolve.detect?(page)
42
+ end
43
+
44
+ step("wait for human signal") do
45
+ warn "[browserctl] #{prompt}"
46
+ line = $stdin.gets
47
+ raise "stdin closed before user signaled" if line.nil?
48
+ end
49
+
50
+ step("verify challenge cleared") do
51
+ raise "cloudflare challenge still detected after human signal" if Browserctl::Flows::CloudflareSolve.detect?(page)
52
+ end
53
+
54
+ produces_state do
55
+ next nil unless state_name && client
56
+
57
+ client.session_save(state_name)
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # HITL flow for magic-link login: pauses, asks the human to paste the
6
+ # link they received via email, then navigates the page to it.
7
+ #
8
+ # Does NOT read the email mailbox; that would require provider-specific
9
+ # IMAP/Gmail integration which belongs in a vendor flow, not stdlib.
10
+ Browserctl.flow("magic_link_email") do
11
+ version "1.0.0"
12
+ requires_browserctl "0.11.0"
13
+ desc "Wait for the human to paste a magic link from their email, then navigate to it."
14
+
15
+ param :prompt, default: "Paste the magic link from your email:"
16
+
17
+ precondition("page proxy is present") { !page.nil? }
18
+
19
+ step("prompt and navigate") do
20
+ $stderr.print("[browserctl] #{prompt} ")
21
+ link = $stdin.gets&.strip
22
+ raise "no magic link provided" if link.nil? || link.empty?
23
+
24
+ raise "magic link must start with http:// or https:// (got #{link.inspect})" unless link.match?(%r{\Ahttps?://}i)
25
+
26
+ page.navigate(link)
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # Clicks the "Authorize <app>" button on a GitHub OAuth consent screen.
6
+ #
7
+ # Assumes the user is already signed in to GitHub and the page is parked
8
+ # on the consent URL — this flow does not handle the credential entry
9
+ # step. Use a separate workflow or flow to land on the consent page first.
10
+ Browserctl.flow("oauth_github") do
11
+ version "1.0.0"
12
+ requires_browserctl "0.11.0"
13
+ desc "Click the Authorize button on a GitHub OAuth consent screen."
14
+
15
+ # The default selector targets the green Authorize submit button on
16
+ # github.com/login/oauth/authorize. GitHub keeps name="authorize" stable
17
+ # across UI revisions; override only if you're testing against a forked
18
+ # GitHub Enterprise instance with a customised template.
19
+ param :authorize_selector, default: 'button[name="authorize"][value="1"]'
20
+
21
+ precondition("on a github oauth consent page") do
22
+ page.url.include?("/login/oauth/authorize")
23
+ end
24
+
25
+ step("click authorize") do
26
+ page.click(authorize_selector)
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # Clicks the Continue / Allow button on a Google OAuth consent screen.
6
+ #
7
+ # Assumes the user is already signed in to Google and the page is parked
8
+ # on accounts.google.com showing the consent prompt. This flow does not
9
+ # pick an account from the chooser, enter a password, or solve 2FA —
10
+ # compose those before calling this flow.
11
+ #
12
+ # Google rotates consent UI more often than GitHub, so the default
13
+ # selector is a best-effort match against the modern Material 3 button.
14
+ # Override if your account or app version sees a different layout.
15
+ Browserctl.flow("oauth_google") do
16
+ version "1.0.0"
17
+ requires_browserctl "0.11.0"
18
+ desc "Click the Continue/Allow button on a Google OAuth consent screen."
19
+
20
+ param :continue_selector, default: 'button[jsname="LgbsSe"]'
21
+
22
+ precondition("on a google oauth consent page") do
23
+ url = page.url
24
+ url.include?("accounts.google.com") && (url.include?("/oauth") || url.include?("/signin/oauth"))
25
+ end
26
+
27
+ step("click continue") do
28
+ page.click(continue_selector)
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "../../flow"
5
+
6
+ module Browserctl
7
+ module Flows
8
+ # RFC 6238 TOTP code generation from a base32 secret.
9
+ # Pure Ruby; no network and no external gem.
10
+ module TOTP
11
+ module_function
12
+
13
+ def generate(secret, at: Time.now, digits: 6, period: 30, digest: "SHA1")
14
+ counter = (at.to_i / period).to_i
15
+ key = decode_base32(secret)
16
+ counter_b = [counter].pack("Q>") # 64-bit big-endian
17
+ hmac = OpenSSL::HMAC.digest(digest, key, counter_b)
18
+ offset = hmac[-1].ord & 0x0f
19
+ truncated = hmac[offset, 4].unpack1("N") & 0x7fffffff
20
+ truncated.to_s.rjust(digits, "0")[-digits..]
21
+ end
22
+
23
+ BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
24
+
25
+ def decode_base32(secret)
26
+ cleaned = secret.to_s.upcase.gsub(/[^A-Z2-7]/, "")
27
+ bits = cleaned.each_char.map { |c| char_to_bits(c) }.join
28
+ whole_bytes = bits[0, (bits.length / 8) * 8]
29
+ whole_bytes.scan(/.{8}/).map { |b| b.to_i(2).chr }.join
30
+ end
31
+
32
+ def char_to_bits(char)
33
+ idx = BASE32_ALPHABET.index(char) or
34
+ raise ArgumentError, "invalid base32 char #{char.inspect}"
35
+ idx.to_s(2).rjust(5, "0")
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ Browserctl.flow("totp_2fa") do
42
+ version "1.0.0"
43
+ requires_browserctl "0.11.0"
44
+ desc "Generate an RFC 6238 TOTP code from a base32 secret and type it into the page."
45
+
46
+ param :secret, required: true, secret: true
47
+ param :selector, required: true
48
+ param :digits, default: 6
49
+ param :period, default: 30
50
+
51
+ precondition("page proxy is present") { !page.nil? }
52
+
53
+ step("compute and fill code") do
54
+ code = Browserctl::Flows::TOTP.generate(
55
+ secret,
56
+ digits: digits.to_i,
57
+ period: period.to_i
58
+ )
59
+ page.fill(selector, code)
60
+ end
61
+ end