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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +1 -1
- data/bin/browserctl +45 -4
- data/lib/browserctl/client.rb +47 -3
- data/lib/browserctl/commands/cli_output.rb +16 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +1 -1
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/errors.rb +30 -0
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/recording.rb +212 -26
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/runner.rb +38 -4
- data/lib/browserctl/server/command_dispatcher.rb +10 -1
- data/lib/browserctl/server/handlers/interaction.rb +3 -3
- data/lib/browserctl/server/handlers/navigation.rb +33 -4
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +242 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +180 -16
- 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
|
|
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
|
-
|
|
51
|
-
exit(
|
|
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)
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -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 = "
|
|
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
|
data/lib/browserctl/detectors.rb
CHANGED
data/lib/browserctl/errors.rb
CHANGED
|
@@ -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
|
|
data/lib/browserctl/flow.rb
CHANGED
|
@@ -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
|