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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -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 +36 -0
  14. data/lib/browserctl/flow.rb +215 -0
  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 +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 "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,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