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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +1 -0
- data/bin/browserctl +143 -94
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +3 -3
- data/lib/browserctl/commands/cli_output.rb +21 -1
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +34 -2
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +3 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/state/bundle.rb +43 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow.rb +56 -1
- metadata +15 -7
data/lib/browserctl/logger.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
data/lib/browserctl/recording.rb
CHANGED
|
@@ -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
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|