browserctl 0.9.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 +45 -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 +36 -0
- data/lib/browserctl/flow.rb +215 -0
- 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 +32 -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,42 @@ 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
|
|
60
|
+
|
|
61
|
+
class FlowError < WorkflowError; def self.default_code = "flow_error" end
|
|
62
|
+
class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
|
|
63
|
+
class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
|
|
64
|
+
class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
|
|
65
|
+
class FlowPostconditionError < FlowError; def self.default_code = "flow_postcondition_failed" end
|
|
30
66
|
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "secret_resolvers"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
FlowParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
9
|
+
FlowStepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
10
|
+
FlowConditionDef = Struct.new(:kind, :label, :block, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
class FlowContext
|
|
13
|
+
attr_reader :page, :client, :params
|
|
14
|
+
|
|
15
|
+
def initialize(page:, params:, client: nil)
|
|
16
|
+
@page = page
|
|
17
|
+
@client = client
|
|
18
|
+
@params = params
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def method_missing(name, *args)
|
|
22
|
+
return @params[name] if args.empty? && @params.key?(name)
|
|
23
|
+
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def respond_to_missing?(name, include_private = false)
|
|
28
|
+
@params.key?(name) || super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Flow
|
|
33
|
+
SEMVER_RE = /\A\d+\.\d+\.\d+\z/
|
|
34
|
+
|
|
35
|
+
attr_reader :name,
|
|
36
|
+
:version_string,
|
|
37
|
+
:description,
|
|
38
|
+
:param_defs,
|
|
39
|
+
:steps,
|
|
40
|
+
:preconditions,
|
|
41
|
+
:postconditions,
|
|
42
|
+
:produces_state_block,
|
|
43
|
+
:min_browserctl_version
|
|
44
|
+
|
|
45
|
+
def initialize(name)
|
|
46
|
+
@name = name.to_s
|
|
47
|
+
@version_string = "0.0.0"
|
|
48
|
+
@description = nil
|
|
49
|
+
@param_defs = {}
|
|
50
|
+
@steps = []
|
|
51
|
+
@preconditions = []
|
|
52
|
+
@postconditions = []
|
|
53
|
+
@produces_state_block = nil
|
|
54
|
+
@min_browserctl_version = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def version(value)
|
|
58
|
+
validate_semver!(value, label: "version")
|
|
59
|
+
@version_string = value.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def requires_browserctl(value)
|
|
63
|
+
validate_semver!(value, label: "requires_browserctl")
|
|
64
|
+
@min_browserctl_version = value.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def desc(text)
|
|
68
|
+
@description = text.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
72
|
+
secret = true if secret_ref
|
|
73
|
+
@param_defs[name] = FlowParamDef.new(
|
|
74
|
+
name: name,
|
|
75
|
+
required: required,
|
|
76
|
+
secret: secret,
|
|
77
|
+
default: default,
|
|
78
|
+
secret_ref: secret_ref
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def step(label, retry_count: 0, timeout: nil, &block)
|
|
83
|
+
raise ArgumentError, "flow step '#{label}' requires a block" unless block
|
|
84
|
+
|
|
85
|
+
@steps << FlowStepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def precondition(label = "precondition", &block)
|
|
89
|
+
raise ArgumentError, "precondition '#{label}' requires a block" unless block
|
|
90
|
+
|
|
91
|
+
@preconditions << FlowConditionDef.new(kind: :precondition, label: label, block: block)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def postcondition(label = "postcondition", &block)
|
|
95
|
+
raise ArgumentError, "postcondition '#{label}' requires a block" unless block
|
|
96
|
+
|
|
97
|
+
@postconditions << FlowConditionDef.new(kind: :postcondition, label: label, block: block)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def produces_state(&block)
|
|
101
|
+
raise ArgumentError, "produces_state requires a block" unless block
|
|
102
|
+
|
|
103
|
+
@produces_state_block = block
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def run(page: nil, client: nil, **params)
|
|
107
|
+
ctx = FlowContext.new(page: page, client: client, params: resolve_params(params))
|
|
108
|
+
|
|
109
|
+
run_conditions(ctx, @preconditions, error_class: FlowPreconditionError)
|
|
110
|
+
run_steps(ctx)
|
|
111
|
+
run_conditions(ctx, @postconditions, error_class: FlowPostconditionError)
|
|
112
|
+
|
|
113
|
+
produce_state(ctx)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def validate_semver!(value, label:)
|
|
119
|
+
return if value.to_s.match?(SEMVER_RE)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def resolve_params(provided)
|
|
125
|
+
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
126
|
+
val = if defn.secret_ref
|
|
127
|
+
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
128
|
+
elsif provided.key?(name)
|
|
129
|
+
provided[name]
|
|
130
|
+
else
|
|
131
|
+
defn.default
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
raise FlowParamError, "flow '#{@name}' requires param '#{name}'" if defn.required && val.nil?
|
|
135
|
+
|
|
136
|
+
out[name] = val
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def run_conditions(ctx, conditions, error_class:)
|
|
141
|
+
conditions.each do |cond|
|
|
142
|
+
result = ctx.instance_exec(&cond.block)
|
|
143
|
+
next if result
|
|
144
|
+
|
|
145
|
+
raise error_class,
|
|
146
|
+
"flow '#{@name}' #{cond.kind} '#{cond.label}' returned #{result.inspect}"
|
|
147
|
+
rescue FlowError
|
|
148
|
+
raise
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
raise error_class,
|
|
151
|
+
"flow '#{@name}' #{cond.kind} '#{cond.label}' raised: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run_steps(ctx)
|
|
156
|
+
@steps.each { |defn| run_step(ctx, defn) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_step(ctx, defn)
|
|
160
|
+
last_error = nil
|
|
161
|
+
(defn.retry_count + 1).times do
|
|
162
|
+
execute_step_block(ctx, defn)
|
|
163
|
+
return
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
last_error = e
|
|
166
|
+
end
|
|
167
|
+
raise FlowStepError,
|
|
168
|
+
"flow '#{@name}' step '#{defn.label}' failed: #{last_error.message}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def execute_step_block(ctx, defn)
|
|
172
|
+
if defn.timeout
|
|
173
|
+
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
174
|
+
else
|
|
175
|
+
ctx.instance_exec(&defn.block)
|
|
176
|
+
end
|
|
177
|
+
rescue ::Timeout::Error
|
|
178
|
+
raise FlowStepError,
|
|
179
|
+
"flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def produce_state(ctx)
|
|
183
|
+
return nil unless @produces_state_block
|
|
184
|
+
|
|
185
|
+
ctx.instance_exec(&@produces_state_block)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@flow_registry_mutex = Mutex.new
|
|
190
|
+
@flow_registry = {}
|
|
191
|
+
|
|
192
|
+
def self.flow(name, &block)
|
|
193
|
+
raise ArgumentError, "Browserctl.flow requires a block" unless block
|
|
194
|
+
|
|
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 }
|
|
214
|
+
end
|
|
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
|