browserctl 0.11.0 → 0.12.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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "logger"
4
4
  require "fileutils"
5
+ require "json"
6
+ require "time"
5
7
 
6
8
  module Browserctl
7
9
  LEVEL_MAP = {
@@ -11,6 +13,16 @@ module Browserctl
11
13
  "error" => ::Logger::ERROR
12
14
  }.freeze
13
15
 
16
+ # JSONL rotation policy. Stdlib `Logger` rotates by size when given an
17
+ # integer `shift_age` and `shift_size`.
18
+ LOG_SHIFT_AGE = 10 # keep last 10 rotated files
19
+ LOG_SHIFT_SIZE = 10 * 1024 * 1024 # rotate at 10MB
20
+
21
+ # Resolved at call time so tests can override BROWSERCTL_DIR via stub_const.
22
+ def self.log_dir
23
+ File.join(BROWSERCTL_DIR, "logs")
24
+ end
25
+
14
26
  class MultiLogger
15
27
  def initialize(*loggers)
16
28
  @loggers = loggers
@@ -30,6 +42,37 @@ module Browserctl
30
42
  end
31
43
  end
32
44
 
45
+ # Formats every log line as a single JSON object: {ts, level, component, msg, ...}.
46
+ # If the message is a Hash, its keys are merged so callers can attach
47
+ # structured context, e.g. `logger.info(event: "x", session: id)`.
48
+ class JsonlFormatter
49
+ def initialize(component:)
50
+ @component = component
51
+ end
52
+
53
+ def call(severity, time, _progname, msg)
54
+ record = {
55
+ ts: time.utc.iso8601(3),
56
+ level: severity,
57
+ component: @component
58
+ }
59
+
60
+ case msg
61
+ when Hash
62
+ explicit = msg[:msg] || msg["msg"]
63
+ record[:msg] = explicit if explicit
64
+ record.merge!(msg.reject { |k, _| k.to_s == "msg" })
65
+ when Exception
66
+ record[:msg] = "#{msg.class}: #{msg.message}"
67
+ record[:backtrace] = Array(msg.backtrace).first(10)
68
+ else
69
+ record[:msg] = msg.to_s
70
+ end
71
+
72
+ "#{JSON.generate(record)}\n"
73
+ end
74
+ end
75
+
33
76
  def self.logger
34
77
  @logger ||= build_logger("info")
35
78
  end
@@ -38,19 +81,69 @@ module Browserctl
38
81
  @logger = instance
39
82
  end
40
83
 
41
- def self.build_logger(level_name, log_path: nil)
84
+ # Build a logger that writes:
85
+ # - human-readable lines to stderr (unchanged behaviour)
86
+ # - human-readable lines to log_path: when given (the daemon tail file)
87
+ # - structured JSONL lines to ~/.browserctl/logs/<component>.log (rotating
88
+ # 10 files x 10MB) when jsonl: is true
89
+ #
90
+ # JSONL output is purely additive — existing stderr/stdout behaviour is
91
+ # preserved so scripted callers see no change.
92
+ def self.build_logger(level_name, log_path: nil, component: "daemon", jsonl: true)
42
93
  level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
43
- formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
94
+ text_formatter = proc do |sev, t, prog, msg|
95
+ "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{format_text_msg(msg)}\n"
96
+ end
97
+
98
+ loggers = [make_logger($stderr, level, text_formatter)]
44
99
 
45
- stderr_log = make_logger($stderr, level, formatter)
46
- return stderr_log unless log_path
100
+ if log_path
101
+ FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
102
+ FileUtils.touch(log_path)
103
+ File.chmod(0o600, log_path)
104
+ loggers << make_logger(log_path, level, text_formatter)
105
+ end
47
106
 
48
- FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
49
- FileUtils.touch(log_path)
50
- File.chmod(0o600, log_path)
51
- file_log = make_logger(log_path, level, formatter)
52
- MultiLogger.new(stderr_log, file_log)
107
+ if jsonl
108
+ jsonl_logger = build_jsonl_logger(level, component)
109
+ loggers << jsonl_logger if jsonl_logger
110
+ end
111
+
112
+ loggers.length == 1 ? loggers.first : MultiLogger.new(*loggers)
113
+ end
114
+
115
+ # Returns a stdlib Logger writing JSON-Lines records to
116
+ # ~/.browserctl/logs/<component>.log with size-based rotation. Returns nil
117
+ # (and stays silent) if the directory cannot be created so logging never
118
+ # crashes the daemon.
119
+ # LogDevice that suppresses stdlib's "# Logfile created on ..." header so
120
+ # the resulting file is pure JSON Lines.
121
+ class HeaderlessLogDevice < ::Logger::LogDevice
122
+ def add_log_header(_file); end
123
+ end
124
+
125
+ def self.build_jsonl_logger(level, component)
126
+ dir = log_dir
127
+ FileUtils.mkdir_p(dir, mode: 0o700)
128
+ path = File.join(dir, "#{component}.log")
129
+ device = HeaderlessLogDevice.new(path, shift_age: LOG_SHIFT_AGE, shift_size: LOG_SHIFT_SIZE)
130
+ log = ::Logger.new(device)
131
+ log.level = level
132
+ log.progname = component
133
+ log.formatter = JsonlFormatter.new(component: component)
134
+ log
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def self.format_text_msg(msg)
140
+ case msg
141
+ when Hash then (msg[:msg] || msg["msg"] || msg.inspect).to_s
142
+ when Exception then "#{msg.class}: #{msg.message}"
143
+ else msg.to_s
144
+ end
53
145
  end
146
+ private_class_method :format_text_msg
54
147
 
55
148
  def self.make_logger(device, level, formatter)
56
149
  log = ::Logger.new(device)
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+ require_relative "error/codes"
6
+
7
+ module Browserctl
8
+ # Migration registry for browserctl persisted formats. Operators run
9
+ # `browserctl migrate <path>` to upgrade an artifact written by an older
10
+ # browserctl to the current build's format version.
11
+ #
12
+ # Distinct from `verify_format_version!` (in bundle/recording/workflow):
13
+ # those gates stay strict — a reader that encounters an unknown version
14
+ # raises {Browserctl::ProtocolMismatch}. This module is the only blessed
15
+ # path to mutate an old artifact in place.
16
+ #
17
+ # The registry ships **empty** in v0.12. The first real migration arrives
18
+ # only when a format actually changes post-1.0. See
19
+ # `docs/reference/format-versions.md` ("Migration registry").
20
+ module Migrations
21
+ # Single registered upgrader. `upgrade` is a Proc invoked with the
22
+ # absolute path of the file being migrated; it is responsible for
23
+ # rewriting that file in place, advancing it from `from_version` to
24
+ # `to_version`.
25
+ Migration = Struct.new(:format, :from_version, :to_version, :upgrade, keyword_init: true)
26
+
27
+ # Result of {.run}. `applied` is the ordered list of {Migration} steps
28
+ # that ran. When the artifact was already at target, `applied` is empty.
29
+ Result = Struct.new(:format, :from, :to, :applied, keyword_init: true)
30
+
31
+ FORMAT_EXTENSIONS = {
32
+ ".bctl" => :bundle,
33
+ ".jsonl" => :recording,
34
+ ".rb" => :workflow
35
+ }.freeze
36
+
37
+ @registry = []
38
+ @mutex = Mutex.new
39
+
40
+ class << self
41
+ # Registers an upgrader for one hop in `format`'s version chain. The
42
+ # block receives the file path as a keyword argument and must rewrite
43
+ # the file in place to the new version.
44
+ def register(format:, from_version:, to_version:, &upgrade)
45
+ raise ArgumentError, "upgrade block required" unless upgrade
46
+
47
+ @mutex.synchronize do
48
+ @registry << Migration.new(format: format, from_version: from_version,
49
+ to_version: to_version, upgrade: upgrade)
50
+ end
51
+ end
52
+
53
+ # All registered migrations, in registration order.
54
+ def all
55
+ @mutex.synchronize { @registry.dup }
56
+ end
57
+
58
+ # Test-only hook — clears the registry. Not part of the public API.
59
+ def reset!
60
+ @mutex.synchronize { @registry.clear }
61
+ end
62
+
63
+ # Breadth-first search through registered migrations to chain a path
64
+ # for `format` from `from` to `to`. Returns the ordered list of
65
+ # {Migration} hops, or `nil` if no path is reachable. When `from == to`
66
+ # returns an empty array (already at target — no work to do).
67
+ def find_path(format:, from:, to:)
68
+ return [] if from == to
69
+
70
+ all_for_format = all.select { |m| m.format == format }
71
+ queue = [[from, []]]
72
+ seen = { from => true }
73
+
74
+ until queue.empty?
75
+ current, path = queue.shift
76
+ all_for_format.each do |m|
77
+ next unless m.from_version == current
78
+ next if seen[m.to_version]
79
+
80
+ new_path = path + [m]
81
+ return new_path if m.to_version == to
82
+
83
+ seen[m.to_version] = true
84
+ queue << [m.to_version, new_path]
85
+ end
86
+ end
87
+ nil
88
+ end
89
+
90
+ # Inspects an artifact path and returns a format symbol — `:bundle`,
91
+ # `:recording`, or `:workflow` — or `nil` when the format cannot be
92
+ # identified. Detection is extension-driven: keep new formats listed
93
+ # in {FORMAT_EXTENSIONS} so this stays a one-line lookup.
94
+ def detect_format(path)
95
+ FORMAT_EXTENSIONS[File.extname(path.to_s).downcase]
96
+ end
97
+
98
+ # Reads `path` and returns the integer format_version it declares, or
99
+ # `nil` when no version header is present. Format-specific because
100
+ # each format stores the header differently — the bundle in its
101
+ # binary manifest, the recording in a `_meta` JSONL line, the
102
+ # workflow in a Ruby comment.
103
+ def detect_version(path, format)
104
+ case format
105
+ when :bundle then peek_bundle_version(path)
106
+ when :recording then peek_recording_version(path)
107
+ when :workflow then peek_workflow_version(path)
108
+ end
109
+ end
110
+
111
+ # End-to-end migration. Detects format and current version, finds a
112
+ # chain of registered upgraders to `target_version` (or the latest
113
+ # `to_version` seen for this format if `target_version` is nil), and
114
+ # invokes each in order. Each upgrader rewrites the file in place.
115
+ #
116
+ # Returns a {Result}. When no migrations are needed (or the registry
117
+ # has no entries for this format), `applied` is empty.
118
+ #
119
+ # Raises {Browserctl::ProtocolMismatch} when format detection fails,
120
+ # the version cannot be read, or no chain reaches the target.
121
+ def run(path, target_version: nil)
122
+ format = detect_format(path) or raise_protocol("could not detect format for #{path}")
123
+ current = detect_version(path, format) or raise_protocol("could not read format_version from #{path}")
124
+ target = target_version || latest_known_target(format, current)
125
+
126
+ if current == target
127
+ # If we'd be a no-op but the artifact's declared version is one this
128
+ # build's reader does not support, surface that as PROTOCOL_MISMATCH —
129
+ # there is no migration registered to bring it into range.
130
+ unless format_version_supported?(format, current)
131
+ raise_protocol("#{format} at #{path} declares unsupported format_version=#{current}; " \
132
+ "no migration registered")
133
+ end
134
+ return Result.new(format: format, from: current, to: current, applied: [])
135
+ end
136
+
137
+ chain = find_path(format: format, from: current, to: target)
138
+ raise_protocol("no migration path from #{format} v#{current} to v#{target}") if chain.nil?
139
+
140
+ chain.each { |m| m.upgrade.call(path: path, from_version: m.from_version, to_version: m.to_version) }
141
+ Result.new(format: format, from: current, to: target, applied: chain)
142
+ end
143
+
144
+ private
145
+
146
+ # Reflects whether each format's reader currently accepts a given
147
+ # version. Mirrors the SUPPORTED_FORMAT_VERSIONS constant on the
148
+ # corresponding class — kept here so adding a new format is one
149
+ # branch, not a new public API.
150
+ def format_version_supported?(format, version)
151
+ case format
152
+ when :bundle
153
+ require_relative "state/bundle"
154
+ Browserctl::State::Bundle::SUPPORTED_FORMAT_VERSIONS.include?(version)
155
+ when :recording
156
+ require_relative "recording"
157
+ Browserctl::Recording::SUPPORTED_FORMAT_VERSIONS.include?(version)
158
+ when :workflow
159
+ require_relative "workflow"
160
+ Browserctl::SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
161
+ else
162
+ false
163
+ end
164
+ end
165
+
166
+ def latest_known_target(format, current)
167
+ targets = all.select { |m| m.format == format }.map(&:to_version)
168
+ targets.empty? ? current : targets.max
169
+ end
170
+
171
+ def peek_bundle_version(path)
172
+ require_relative "state/bundle"
173
+ manifest = Browserctl::State::Bundle.peek_manifest(File.binread(path))
174
+ manifest[:format_version] || manifest["format_version"]
175
+ rescue Browserctl::ProtocolMismatch
176
+ # `peek_manifest` raises on unknown versions, but we want to *return*
177
+ # the version so a migration can target it. Re-parse the manifest
178
+ # bytes directly when the strict gate refuses.
179
+ peek_bundle_version_loosely(path)
180
+ end
181
+
182
+ def peek_bundle_version_loosely(path)
183
+ blob = File.binread(path)
184
+ # Manifest length is at byte offset 8 (5 magic + 3 header). Parse
185
+ # the JSON without invoking Bundle's strict version check.
186
+ manifest_len = blob.byteslice(8, 4).unpack1("N")
187
+ manifest_bytes = blob.byteslice(12, manifest_len)
188
+ manifest = JSON.parse(manifest_bytes)
189
+ manifest["format_version"]
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ def peek_recording_version(path)
195
+ first_line = File.foreach(path).first
196
+ return nil unless first_line
197
+
198
+ meta = JSON.parse(first_line, symbolize_names: true)
199
+ return nil unless meta[:cmd] == "_meta"
200
+
201
+ meta[:format_version]
202
+ rescue JSON::ParserError
203
+ nil
204
+ end
205
+
206
+ def peek_workflow_version(path)
207
+ require_relative "workflow"
208
+ Browserctl.parse_workflow_format_version(File.read(path))
209
+ end
210
+
211
+ def raise_protocol(msg)
212
+ raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
213
+ end
214
+ end
215
+ end
216
+ end
@@ -6,12 +6,22 @@ require "time"
6
6
  require "fileutils"
7
7
  require "tmpdir"
8
8
  require "uri"
9
+ require_relative "errors"
10
+ require_relative "error/codes"
9
11
 
10
12
  module Browserctl
11
13
  class Recording # rubocop:disable Metrics/ClassLength
12
14
  RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
13
15
  STATE_FILE = File.expand_path("~/.browserctl/active_recording")
14
16
 
17
+ # Recording-log format version, written into the `_meta` header and
18
+ # validated when generate_workflow loads a recording. Distinct from
19
+ # LOG_FORMAT below — that string ("v0.11") tracks the human-readable
20
+ # log shape; this integer is the machine-readable schema gate per the
21
+ # WS-1 format-version convention. See docs/reference/format-versions.md.
22
+ RECORDING_FORMAT_VERSION = 1
23
+ SUPPORTED_FORMAT_VERSIONS = [RECORDING_FORMAT_VERSION].freeze
24
+
15
25
  RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
16
26
 
17
27
  SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
@@ -42,6 +52,7 @@ module Browserctl
42
52
  File.open(log_path(name), "a") do |f|
43
53
  f.puts JSON.generate(
44
54
  cmd: "_meta",
55
+ format_version: RECORDING_FORMAT_VERSION,
45
56
  log_format: LOG_FORMAT,
46
57
  recording: name,
47
58
  started_at: Time.now.utc.iso8601
@@ -52,7 +63,7 @@ module Browserctl
52
63
 
53
64
  def self.stop
54
65
  name = active
55
- raise "no active recording — run: browserctl record start <name>" unless name
66
+ raise Browserctl::Error, "no active recording — run: browserctl record start <name>" unless name
56
67
 
57
68
  File.unlink(STATE_FILE)
58
69
  name
@@ -83,9 +94,10 @@ module Browserctl
83
94
 
84
95
  def self.generate_workflow(name, output_path: nil, keep_log: false)
85
96
  log = log_path(name)
86
- raise "no recording found for '#{name}'" unless File.exist?(log)
97
+ raise Browserctl::Error, "no recording found for '#{name}'" unless File.exist?(log)
87
98
 
88
99
  raw = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
100
+ verify_format_version!(raw, path: log)
89
101
  lines = raw.reject { |l| l[:cmd] == "_meta" }
90
102
  ruby = build_workflow_ruby(name, lines)
91
103
  File.write(output_path, ruby) if output_path
@@ -106,6 +118,25 @@ module Browserctl
106
118
  class << self
107
119
  private
108
120
 
121
+ # Raises Browserctl::ProtocolMismatch when the recording log's _meta
122
+ # header is missing or declares a format_version this build does not
123
+ # support. Mirrors Browserctl::State::Bundle.verify_format_version!.
124
+ def verify_format_version!(raw_lines, path: nil)
125
+ meta = raw_lines.first
126
+ version = meta && meta[:cmd] == "_meta" ? meta[:format_version] : nil
127
+ return if version && SUPPORTED_FORMAT_VERSIONS.include?(version)
128
+
129
+ where = path ? " at #{path}" : ""
130
+ msg = if version.nil?
131
+ "recording log#{where} is missing format_version " \
132
+ "(supported: #{SUPPORTED_FORMAT_VERSIONS.inspect})"
133
+ else
134
+ "recording log#{where} declares format_version=#{version.inspect}, " \
135
+ "this build supports #{SUPPORTED_FORMAT_VERSIONS.inspect}"
136
+ end
137
+ raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
138
+ end
139
+
109
140
  def log_path(name)
110
141
  File.join(RECORDINGS_DIR, "#{name}.jsonl")
111
142
  end
@@ -141,6 +172,7 @@ module Browserctl
141
172
  header = secret_header(secrets)
142
173
  <<~RUBY
143
174
  # frozen_string_literal: true
175
+ # format_version: #{Browserctl::WORKFLOW_FORMAT_VERSION}
144
176
  #{header}
145
177
  Browserctl.workflow #{name.inspect} do
146
178
  desc "Recorded on #{Date.today}"
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "snapshot_builder"
4
4
  require_relative "page_session"
5
+ require_relative "handlers/error_payload"
5
6
  require_relative "handlers/page_lifecycle"
6
7
  require_relative "handlers/navigation"
7
8
  require_relative "handlers/observation"
@@ -15,10 +16,12 @@ require_relative "handlers/state"
15
16
  require_relative "handlers/interaction"
16
17
  require_relative "../detectors"
17
18
  require_relative "../policy"
19
+ require_relative "../errors"
18
20
  require_relative "../replay/snapshot_diff"
19
21
 
20
22
  module Browserctl
21
23
  class CommandDispatcher
24
+ include Handlers::ErrorPayload
22
25
  include Handlers::PageLifecycle
23
26
  include Handlers::Navigation
24
27
  include Handlers::Observation