browserctl 0.11.0 → 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -1,65 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "date"
5
- require "time"
6
- require "fileutils"
7
3
  require "tmpdir"
8
- require "uri"
4
+ require_relative "errors"
5
+ require_relative "error/codes"
9
6
 
10
7
  module Browserctl
11
- class Recording # rubocop:disable Metrics/ClassLength
8
+ # Captures a sequence of daemon commands as a JSONL log and renders it
9
+ # back out as a Ruby workflow file. This class is the public facade;
10
+ # the focused responsibilities live under `Browserctl::Recording::*`:
11
+ #
12
+ # - `Recording::State` — singleton over the on-disk marker.
13
+ # - `Recording::Redactor` — secret-aware redaction.
14
+ # - `Recording::LogWriter` — log file I/O and JSONL formatting.
15
+ # - `Recording::WorkflowRenderer` — log-to-Ruby workflow translation.
16
+ class Recording
12
17
  RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
13
18
  STATE_FILE = File.expand_path("~/.browserctl/active_recording")
14
19
 
15
- RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
16
-
17
- SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
18
-
19
- # Selector tokens that signal a fill is targeting a secret-shaped field.
20
- # The captured group (or matched substring) is used as the inferred field
21
- # name; that name later drives the generated `secret_ref:` placeholder.
22
- SECRET_FIELD_PATTERN = /\b(password|passwd|api[_-]?key|token|secret|otp|pin|client[_-]?secret|access[_-]?token)\b/i
23
-
24
- # Conservative thresholds for inferring an explicit wait between recorded
25
- # steps. Gaps shorter than the threshold come from natural input cadence;
26
- # gaps above it usually mean the page actually had work to do.
27
- WAIT_THRESHOLD_SECONDS = 1.5
28
- WAIT_PADDING_SECONDS = 5
29
- WAIT_FLOOR_SECONDS = 5
20
+ # Recording-log format version, written into the `_meta` header and
21
+ # validated when generate_workflow loads a recording. See
22
+ # docs/reference/format-versions.md.
23
+ RECORDING_FORMAT_VERSION = 1
24
+ SUPPORTED_FORMAT_VERSIONS = [RECORDING_FORMAT_VERSION].freeze
30
25
 
31
26
  # Bumped when the recording log shape changes in a way that older
32
27
  # tooling (workflow generate, replay) cannot read.
33
28
  LOG_FORMAT = "v0.11"
34
29
 
30
+ RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
31
+
35
32
  def self.start(name)
36
- FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
37
- FileUtils.mkdir_p(File.dirname(STATE_FILE))
38
- File.write(STATE_FILE, name)
39
- FileUtils.rm_f(log_path(name))
40
- FileUtils.touch(log_path(name))
41
- File.chmod(0o600, log_path(name))
42
- File.open(log_path(name), "a") do |f|
43
- f.puts JSON.generate(
44
- cmd: "_meta",
45
- log_format: LOG_FORMAT,
46
- recording: name,
47
- started_at: Time.now.utc.iso8601
48
- )
49
- end
33
+ LogWriter.init_log(name)
34
+ State.write(name)
50
35
  name
51
36
  end
52
37
 
53
38
  def self.stop
54
- name = active
55
- raise "no active recording — run: browserctl record start <name>" unless name
56
-
57
- File.unlink(STATE_FILE)
58
- name
39
+ State.clear!
59
40
  end
60
41
 
61
42
  def self.active
62
- File.exist?(STATE_FILE) ? File.read(STATE_FILE).strip : nil
43
+ State.active
63
44
  end
64
45
 
65
46
  def self.append(cmd, response: nil, **attrs)
@@ -76,23 +57,22 @@ module Browserctl
76
57
  entry = { cmd: cmd.to_s, ts: now }.merge(attrs.transform_keys(&:to_s))
77
58
  entry.merge!(replay_metadata(response)) if response
78
59
 
79
- File.open(log_path(name), "a") do |f|
80
- f.puts JSON.generate(entry)
81
- end
60
+ LogWriter.append_entry(name, entry)
82
61
  end
83
62
 
84
63
  def self.generate_workflow(name, output_path: nil, keep_log: false)
85
- log = log_path(name)
86
- raise "no recording found for '#{name}'" unless File.exist?(log)
64
+ log = LogWriter.log_path(name)
65
+ raise Browserctl::Error, "no recording found for '#{name}'" unless File.exist?(log)
87
66
 
88
- raw = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
67
+ raw = LogWriter.read_entries(name)
68
+ LogWriter.verify_format_version!(raw, path: log)
89
69
  lines = raw.reject { |l| l[:cmd] == "_meta" }
90
- ruby = build_workflow_ruby(name, lines)
70
+ ruby = WorkflowRenderer.render(name, lines)
91
71
  File.write(output_path, ruby) if output_path
92
72
  warn_about_ref_interactions(lines)
93
73
  ruby
94
74
  ensure
95
- FileUtils.rm_f(log) if log && !keep_log
75
+ LogWriter.delete_log(name) unless keep_log
96
76
  end
97
77
 
98
78
  def self.warn_about_ref_interactions(lines)
@@ -106,25 +86,19 @@ module Browserctl
106
86
  class << self
107
87
  private
108
88
 
109
- def log_path(name)
110
- File.join(RECORDINGS_DIR, "#{name}.jsonl")
89
+ def now
90
+ Time.now.utc.to_f
111
91
  end
112
92
 
113
93
  def record_ref_interaction(recording_name, cmd, attrs, response)
114
94
  entry = { cmd: "_ref_interaction", ts: now, action: cmd, ref: attrs[:ref], name: attrs[:name] }
115
95
  entry.merge!(replay_metadata(response)) if response
116
- File.open(log_path(recording_name), "a") do |f|
117
- f.puts JSON.generate(entry)
118
- end
96
+ LogWriter.append_entry(recording_name, entry)
119
97
  end
120
98
 
121
99
  # Pulls the replay-relevant fields out of a daemon response. Each
122
100
  # is optional — older daemons or non-resolving commands may omit
123
101
  # any of them.
124
- def now
125
- Time.now.utc.to_f
126
- end
127
-
128
102
  def replay_metadata(response)
129
103
  meta = {}
130
104
  meta[:ref] = response[:ref] if response[:ref]
@@ -135,227 +109,24 @@ module Browserctl
135
109
  meta.transform_keys(&:to_s)
136
110
  end
137
111
 
138
- def build_workflow_ruby(name, commands)
139
- steps = annotated_steps(commands).join("\n\n")
140
- secrets = commands.map { |c| c[:secret_field] }.compact.uniq
141
- header = secret_header(secrets)
142
- <<~RUBY
143
- # frozen_string_literal: true
144
- #{header}
145
- Browserctl.workflow #{name.inspect} do
146
- desc "Recorded on #{Date.today}"
147
- #{secrets.map { |f| " param :secret_#{f}, secret: true" }.join("\n")}
148
- #{steps.gsub(/^/, ' ')}
149
- end
150
- RUBY
151
- end
152
-
153
- # Walks the recorded events and emits the rendered step strings,
154
- # interleaving inferred waits before selector-driven actions whose
155
- # preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL
156
- # postconditions after click/fill steps that triggered navigation.
157
- def annotated_steps(commands)
158
- last_url = {}
159
- commands.each_with_index.flat_map do |cmd, i|
160
- rendered = []
161
- if i.positive? && (wait = inferred_wait_step(commands[i - 1], cmd))
162
- rendered << wait
163
- end
164
- rendered << build_step(cmd)
165
- if (post = url_postcondition_step(cmd, last_url))
166
- rendered << post
167
- end
168
- if (snap = snapshot_postcondition_step(cmd))
169
- rendered << snap
170
- end
171
- update_last_url!(cmd, last_url)
172
- rendered
173
- end
174
- end
175
-
176
- # Emits a postcondition assertion when a click/fill resulted in a URL
177
- # change. Compares the canonical (scheme+host+path) form so query
178
- # strings and fragments don't make every replay flaky.
179
- def url_postcondition_step(cmd, last_url)
180
- return nil unless %w[click fill].include?(cmd[:cmd])
181
- return nil unless cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
182
-
183
- page = cmd[:name]
184
- observed = cmd[:postcondition_hint][:url]
185
- prior = last_url[page]
186
- return nil if canonical_url(observed) == canonical_url(prior)
187
-
188
- prefix = canonical_url(observed)
189
- return nil unless prefix
190
-
191
- <<~RUBY.chomp
192
- step "assert url after #{cmd[:cmd]} on #{page}" do
193
- current = page(:#{page}).url
194
- assert current.start_with?(#{prefix.inspect}), "expected URL to start with #{prefix}, got \#{current}"
195
- end
196
- RUBY
197
- end
198
-
199
- # Emits an assert_snapshot_stable step when the recording captured a
200
- # post-step DOM digest. Under workflow run --check the helper records
201
- # drift on mismatch instead of raising, so a wiggly page surfaces in
202
- # the report rather than failing the run outright.
203
- def snapshot_postcondition_step(cmd)
204
- return nil unless %w[click fill].include?(cmd[:cmd])
205
- return nil unless cmd[:post_snapshot_digest]
206
-
207
- page = cmd[:name]
208
- digest = cmd[:post_snapshot_digest]
209
- <<~RUBY.chomp
210
- step "assert post-snapshot stable on #{page}" do
211
- assert_snapshot_stable(:#{page}, expected_digest: #{digest.inspect})
212
- end
213
- RUBY
214
- end
215
-
216
- def update_last_url!(cmd, last_url)
217
- case cmd[:cmd]
218
- when "navigate", "page_open"
219
- last_url[cmd[:name]] = cmd[:url] if cmd[:url]
220
- when "click", "fill"
221
- observed = cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
222
- last_url[cmd[:name]] = observed if observed
223
- end
224
- end
225
-
226
- def canonical_url(url)
227
- return nil if url.nil? || url.empty?
228
-
229
- uri = URI.parse(url)
230
- path = uri.path.to_s
231
- path = "/" if path.empty?
232
- "#{uri.scheme}://#{uri.host}#{path}"
233
- rescue URI::InvalidURIError
234
- nil
235
- end
236
-
237
- def inferred_wait_step(prev, current)
238
- return nil unless %w[fill click].include?(current[:cmd])
239
- return nil unless current[:selector]
240
-
241
- delta = elapsed(prev, current)
242
- return nil unless delta && delta >= WAIT_THRESHOLD_SECONDS
243
-
244
- timeout = [WAIT_FLOOR_SECONDS, delta.ceil + WAIT_PADDING_SECONDS].max
245
- page = current[:name]
246
- sel = current[:selector]
247
- <<~RUBY.chomp
248
- # inferred wait: prior step took ~#{format('%.1f', delta)}s
249
- step "wait for #{sel} on #{page}" do
250
- page(:#{page}).wait(#{sel.inspect}, timeout: #{timeout})
251
- end
252
- RUBY
253
- end
254
-
255
- def elapsed(prev, current)
256
- return nil unless prev && current && prev[:ts] && current[:ts]
257
-
258
- current[:ts] - prev[:ts]
259
- end
260
-
261
- def secret_header(secrets)
262
- return "" if secrets.empty?
263
-
264
- lines = ["# TODO: review the following secret-shaped fields detected during recording.",
265
- "# Configure a secret_ref: source for each before running:"]
266
- secrets.each { |f| lines << "# - secret_#{f}" }
267
- "\n#{lines.join("\n")}\n"
268
- end
269
-
270
- def build_step(cmd)
271
- label, body = step_parts(cmd)
272
-
273
- if body.nil?
274
- page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
275
- action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
276
- return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
277
- "replace with a stable CSS selector\n" \
278
- "# step #{label.inspect} do\n" \
279
- "# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
280
- "# end"
281
- end
282
-
283
- prefix = []
284
- prefix << "# NOTE: sensitive query params were redacted during recording" \
285
- if cmd[:url].to_s.include?("[REDACTED]")
286
- prefix << "# fingerprint fallback: #{cmd[:fingerprint].to_json}" if cmd[:fingerprint]
287
-
288
- head = prefix.empty? ? "" : "#{prefix.join("\n")}\n"
289
- "#{head}step #{label.inspect} do\n #{body}\nend"
290
- end
291
-
292
- def step_parts(cmd)
293
- return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
294
- return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
295
-
296
- page = cmd[:name]
297
- case cmd[:cmd]
298
- when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
299
- when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
300
- when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
301
- when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
302
- else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
303
- end
304
- end
305
-
306
- def ref_interaction_parts(cmd)
307
- ["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
308
- end
309
-
310
- def selector_parts(cmd)
311
- page = cmd[:name]
312
- case cmd[:cmd]
313
- when "fill"
314
- value_arg = cmd[:secret_field] ? "params[:secret_#{cmd[:secret_field]}]" : "params[:fill_value]"
315
- ["fill #{cmd[:selector]} on #{page}",
316
- "page(:#{page}).fill(#{cmd[:selector].inspect}, #{value_arg})"]
317
- when "click"
318
- ["click #{cmd[:selector]} on #{page}",
319
- "page(:#{page}).click(#{cmd[:selector].inspect})"]
320
- end
321
- end
322
-
323
112
  def prepare_attrs(cmd, attrs)
324
113
  attrs = attrs.except(:capture_post_snapshot)
325
114
  if cmd == "fill"
326
115
  attrs = attrs.except(:value)
327
- field = infer_secret_field(attrs[:selector])
116
+ field = Redactor.infer_secret_field(attrs[:selector])
328
117
  if field
329
118
  attrs[:secret_hint] = true
330
119
  attrs[:secret_field] = field
331
120
  end
332
121
  end
333
- attrs[:url] = redact_url(attrs[:url]) if %w[navigate page_open].include?(cmd) && attrs[:url]
122
+ attrs[:url] = Redactor.redact_url(attrs[:url]) if %w[navigate page_open].include?(cmd) && attrs[:url]
334
123
  attrs
335
124
  end
336
-
337
- def infer_secret_field(selector)
338
- return nil unless selector
339
-
340
- match = selector.match(SECRET_FIELD_PATTERN)
341
- return nil unless match
342
-
343
- match[1].downcase.gsub(/[^a-z0-9]/, "_")
344
- end
345
-
346
- def redact_url(url)
347
- uri = URI.parse(url)
348
- return url if uri.query.nil?
349
-
350
- uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
351
- raw_key = ::Regexp.last_match(1)
352
- key = URI.decode_www_form_component(raw_key)
353
- key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
354
- end
355
- uri.to_s
356
- rescue URI::InvalidURIError
357
- url
358
- end
359
125
  end
360
126
  end
361
127
  end
128
+
129
+ require_relative "recording/state"
130
+ require_relative "recording/redactor"
131
+ require_relative "recording/log_writer"
132
+ require_relative "recording/workflow_renderer"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ # Redacts known secret values from arbitrary strings.
5
+ #
6
+ # Used by `browserctl trace` to ensure traces are safe to attach to issues
7
+ # by default. Secret values are sourced from two places:
8
+ # 1. ENV variables whose names match well-known secret patterns
9
+ # (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`).
10
+ # 2. Values captured at runtime by `SecretResolverRegistry` (in-memory
11
+ # only — never persisted).
12
+ #
13
+ # Replacement marker is the literal `[REDACTED]`. We considered including
14
+ # a sha256 prefix to distinguish values, but that doubles the surface area
15
+ # for accidental leakage (a determined attacker could brute-force short
16
+ # secrets from the prefix), and the timeline is more scannable with a
17
+ # uniform marker.
18
+ class Redactor
19
+ MARKER = "[REDACTED]"
20
+ MIN_LENGTH = 4
21
+ ENV_PATTERNS = [/_TOKEN\z/, /_KEY\z/, /_SECRET\z/, /_PASSWORD\z/].freeze
22
+
23
+ # @param secrets [Array<String>] secret values to redact.
24
+ def initialize(secrets: [])
25
+ # Filter empty / too-short values; longest-first to avoid partial
26
+ # overlaps (e.g. redacting "abcd" before "abcdef" would leave "ef").
27
+ @secrets = secrets
28
+ .compact
29
+ .map(&:to_s)
30
+ .reject { |s| s.length < MIN_LENGTH }
31
+ .uniq
32
+ .sort_by { |s| -s.length }
33
+ end
34
+
35
+ # @param string [String, nil]
36
+ # @return [String, nil]
37
+ def redact(string)
38
+ return string if string.nil?
39
+
40
+ result = string.to_s
41
+ @secrets.each { |secret| result = result.gsub(secret, MARKER) }
42
+ result
43
+ end
44
+
45
+ def empty?
46
+ @secrets.empty?
47
+ end
48
+
49
+ # Build a Redactor from the current ENV using well-known patterns.
50
+ # Optionally merges in additional values (e.g. from runtime instrumentation).
51
+ def self.from_env(env: ENV, extra: [])
52
+ values = env.each_with_object([]) do |(name, value), acc|
53
+ acc << value if ENV_PATTERNS.any? { |re| name =~ re }
54
+ end
55
+ new(secrets: values + Array(extra))
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Browserctl
8
+ # Enforces that any explicit `code:` keyword passed to a `raise` of a
9
+ # `Browserctl::*` error refers to a constant from
10
+ # `Browserctl::Error::Codes` rather than a free-form string literal.
11
+ #
12
+ # Subclasses with their own `default_code` are trusted (the cop does not
13
+ # try to statically resolve `default_code` across files); the contract
14
+ # is enforced by the unit test suite. This cop's job is to catch the
15
+ # specific failure mode of inlining a stale snake_case code at the
16
+ # raise site and bypassing the canonical enum.
17
+ #
18
+ # @example
19
+ # # bad — string literal that isn't a Codes constant
20
+ # raise Browserctl::Error, "state expired", code: "state_expired"
21
+ #
22
+ # # good — Codes constant reference
23
+ # raise Browserctl::Error, "state expired",
24
+ # code: Browserctl::Error::Codes::STATE_EXPIRED
25
+ #
26
+ # # good — typed subclass relies on its own default_code
27
+ # raise Browserctl::SelectorNotFound, "no such selector"
28
+ #
29
+ # The pattern is intentionally narrow. The full default_code-vs-Codes
30
+ # reconciliation lives in `lib/browserctl/errors.rb` and is covered by
31
+ # `spec/unit/errors_spec.rb`.
32
+ class TypedError < RuboCop::Cop::Base
33
+ MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
34
+ "got string literal %<value>p. See lib/browserctl/error/codes.rb."
35
+
36
+ VALID_CODES = %w[
37
+ AUTH_REQUIRED
38
+ SELECTOR_NOT_FOUND
39
+ STATE_EXPIRED
40
+ SECRET_RESOLUTION_FAILED
41
+ DAEMON_UNREACHABLE
42
+ PROTOCOL_MISMATCH
43
+ ].freeze
44
+
45
+ # Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
46
+ # `code:` value node.
47
+ def_node_matcher :browserctl_raise_with_code, <<~PATTERN
48
+ (send nil? :raise
49
+ (const (const nil? :Browserctl) _)
50
+ ...
51
+ (hash <(pair (sym :code) $_) ...>))
52
+ PATTERN
53
+
54
+ def on_send(node)
55
+ return unless node.method?(:raise)
56
+
57
+ browserctl_raise_with_code(node) do |code_value|
58
+ next unless code_value.str_type?
59
+
60
+ value = code_value.value
61
+ next if VALID_CODES.include?(value)
62
+
63
+ add_offense(code_value, message: format(MSG, value: value))
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -57,7 +57,7 @@ module Browserctl
57
57
  SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
58
58
 
59
59
  def self.load_params_file(path)
60
- raise "params file not found: #{path}" unless File.exist?(path)
60
+ raise Browserctl::WorkflowError, "params file not found: #{path}" unless File.exist?(path)
61
61
 
62
62
  case File.extname(path).downcase
63
63
  when ".yml", ".yaml"
@@ -66,12 +66,12 @@ module Browserctl
66
66
  when ".json"
67
67
  JSON.parse(File.read(path), symbolize_names: true)
68
68
  else
69
- raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
69
+ raise Browserctl::WorkflowError, "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
70
70
  end
71
71
  rescue Psych::SyntaxError => e
72
- raise "invalid YAML in #{path}: #{e.message}"
72
+ raise Browserctl::WorkflowError, "invalid YAML in #{path}: #{e.message}"
73
73
  rescue JSON::ParserError => e
74
- raise "invalid JSON in #{path}: #{e.message}"
74
+ raise Browserctl::WorkflowError, "invalid JSON in #{path}: #{e.message}"
75
75
  end
76
76
 
77
77
  private
@@ -95,7 +95,10 @@ module Browserctl
95
95
  return if Browserctl.lookup_workflow(name.to_s)
96
96
 
97
97
  path = workflow_path(name)
98
- load path if path
98
+ return unless path
99
+
100
+ Browserctl.verify_workflow_format_version!(path)
101
+ load path
99
102
  end
100
103
 
101
104
  def workflow_path(name)
@@ -111,7 +114,10 @@ module Browserctl
111
114
 
112
115
  def load_from_dir(dir)
113
116
  Dir.glob("#{dir}/*.rb").each do |f|
114
- load f unless $LOADED_FEATURES.include?(f)
117
+ next if $LOADED_FEATURES.include?(f)
118
+
119
+ Browserctl.verify_workflow_format_version!(f)
120
+ load f
115
121
  end
116
122
  end
117
123
 
@@ -4,8 +4,9 @@ require_relative "errors"
4
4
 
5
5
  module Browserctl
6
6
  class SecretResolverRegistry
7
- @mutex = Mutex.new
8
- @registry = {}
7
+ @mutex = Mutex.new
8
+ @registry = {}
9
+ @resolved_values = []
9
10
 
10
11
  def self.register(resolver_class)
11
12
  instance = resolver_class.new
@@ -25,7 +26,9 @@ module Browserctl
25
26
  raise SecretResolverError, msg
26
27
  end
27
28
 
28
- resolver.resolve(reference)
29
+ value = resolver.resolve(reference)
30
+ record_resolved_value(value)
31
+ value
29
32
  rescue SecretResolverError
30
33
  raise
31
34
  rescue StandardError => e
@@ -36,8 +39,24 @@ module Browserctl
36
39
  @mutex.synchronize { @registry.key?(scheme) }
37
40
  end
38
41
 
42
+ # In-memory record of values resolved during this process. Used by the
43
+ # Redactor so trace output never leaks values that flowed through the
44
+ # registry. Never persisted.
45
+ def self.resolved_values
46
+ @mutex.synchronize { @resolved_values.dup }
47
+ end
48
+
49
+ def self.record_resolved_value(value)
50
+ return unless value.is_a?(String) && !value.empty?
51
+
52
+ @mutex.synchronize { @resolved_values << value unless @resolved_values.include?(value) }
53
+ end
54
+
39
55
  def self.reset!
40
- @mutex.synchronize { @registry.clear }
56
+ @mutex.synchronize do
57
+ @registry.clear
58
+ @resolved_values.clear
59
+ end
41
60
  end
42
61
  end
43
62
  end