browserctl 0.12.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +3 -3
- data/bin/browserctl +39 -32
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +0 -27
- data/lib/browserctl/commands/cli_output.rb +17 -3
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +56 -8
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +40 -11
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +33 -294
- data/lib/browserctl/server/command_dispatcher.rb +25 -16
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +20 -47
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +61 -237
- metadata +11 -8
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
data/lib/browserctl/recording.rb
CHANGED
|
@@ -1,76 +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"
|
|
9
4
|
require_relative "errors"
|
|
10
5
|
require_relative "error/codes"
|
|
11
6
|
|
|
12
7
|
module Browserctl
|
|
13
|
-
|
|
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
|
|
14
17
|
RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
|
|
15
18
|
STATE_FILE = File.expand_path("~/.browserctl/active_recording")
|
|
16
19
|
|
|
17
20
|
# Recording-log format version, written into the `_meta` header and
|
|
18
|
-
# validated when generate_workflow loads a recording.
|
|
19
|
-
#
|
|
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.
|
|
21
|
+
# validated when generate_workflow loads a recording. See
|
|
22
|
+
# docs/reference/format-versions.md.
|
|
22
23
|
RECORDING_FORMAT_VERSION = 1
|
|
23
24
|
SUPPORTED_FORMAT_VERSIONS = [RECORDING_FORMAT_VERSION].freeze
|
|
24
25
|
|
|
25
|
-
RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
|
|
26
|
-
|
|
27
|
-
SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
|
|
28
|
-
|
|
29
|
-
# Selector tokens that signal a fill is targeting a secret-shaped field.
|
|
30
|
-
# The captured group (or matched substring) is used as the inferred field
|
|
31
|
-
# name; that name later drives the generated `secret_ref:` placeholder.
|
|
32
|
-
SECRET_FIELD_PATTERN = /\b(password|passwd|api[_-]?key|token|secret|otp|pin|client[_-]?secret|access[_-]?token)\b/i
|
|
33
|
-
|
|
34
|
-
# Conservative thresholds for inferring an explicit wait between recorded
|
|
35
|
-
# steps. Gaps shorter than the threshold come from natural input cadence;
|
|
36
|
-
# gaps above it usually mean the page actually had work to do.
|
|
37
|
-
WAIT_THRESHOLD_SECONDS = 1.5
|
|
38
|
-
WAIT_PADDING_SECONDS = 5
|
|
39
|
-
WAIT_FLOOR_SECONDS = 5
|
|
40
|
-
|
|
41
26
|
# Bumped when the recording log shape changes in a way that older
|
|
42
27
|
# tooling (workflow generate, replay) cannot read.
|
|
43
28
|
LOG_FORMAT = "v0.11"
|
|
44
29
|
|
|
30
|
+
RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
|
|
31
|
+
|
|
45
32
|
def self.start(name)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
File.write(STATE_FILE, name)
|
|
49
|
-
FileUtils.rm_f(log_path(name))
|
|
50
|
-
FileUtils.touch(log_path(name))
|
|
51
|
-
File.chmod(0o600, log_path(name))
|
|
52
|
-
File.open(log_path(name), "a") do |f|
|
|
53
|
-
f.puts JSON.generate(
|
|
54
|
-
cmd: "_meta",
|
|
55
|
-
format_version: RECORDING_FORMAT_VERSION,
|
|
56
|
-
log_format: LOG_FORMAT,
|
|
57
|
-
recording: name,
|
|
58
|
-
started_at: Time.now.utc.iso8601
|
|
59
|
-
)
|
|
60
|
-
end
|
|
33
|
+
LogWriter.init_log(name)
|
|
34
|
+
State.write(name)
|
|
61
35
|
name
|
|
62
36
|
end
|
|
63
37
|
|
|
64
38
|
def self.stop
|
|
65
|
-
|
|
66
|
-
raise Browserctl::Error, "no active recording — run: browserctl record start <name>" unless name
|
|
67
|
-
|
|
68
|
-
File.unlink(STATE_FILE)
|
|
69
|
-
name
|
|
39
|
+
State.clear!
|
|
70
40
|
end
|
|
71
41
|
|
|
72
42
|
def self.active
|
|
73
|
-
|
|
43
|
+
State.active
|
|
74
44
|
end
|
|
75
45
|
|
|
76
46
|
def self.append(cmd, response: nil, **attrs)
|
|
@@ -87,24 +57,22 @@ module Browserctl
|
|
|
87
57
|
entry = { cmd: cmd.to_s, ts: now }.merge(attrs.transform_keys(&:to_s))
|
|
88
58
|
entry.merge!(replay_metadata(response)) if response
|
|
89
59
|
|
|
90
|
-
|
|
91
|
-
f.puts JSON.generate(entry)
|
|
92
|
-
end
|
|
60
|
+
LogWriter.append_entry(name, entry)
|
|
93
61
|
end
|
|
94
62
|
|
|
95
63
|
def self.generate_workflow(name, output_path: nil, keep_log: false)
|
|
96
|
-
log = log_path(name)
|
|
64
|
+
log = LogWriter.log_path(name)
|
|
97
65
|
raise Browserctl::Error, "no recording found for '#{name}'" unless File.exist?(log)
|
|
98
66
|
|
|
99
|
-
raw =
|
|
100
|
-
verify_format_version!(raw, path: log)
|
|
67
|
+
raw = LogWriter.read_entries(name)
|
|
68
|
+
LogWriter.verify_format_version!(raw, path: log)
|
|
101
69
|
lines = raw.reject { |l| l[:cmd] == "_meta" }
|
|
102
|
-
ruby =
|
|
70
|
+
ruby = WorkflowRenderer.render(name, lines)
|
|
103
71
|
File.write(output_path, ruby) if output_path
|
|
104
72
|
warn_about_ref_interactions(lines)
|
|
105
73
|
ruby
|
|
106
74
|
ensure
|
|
107
|
-
|
|
75
|
+
LogWriter.delete_log(name) unless keep_log
|
|
108
76
|
end
|
|
109
77
|
|
|
110
78
|
def self.warn_about_ref_interactions(lines)
|
|
@@ -118,44 +86,19 @@ module Browserctl
|
|
|
118
86
|
class << self
|
|
119
87
|
private
|
|
120
88
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
140
|
-
def log_path(name)
|
|
141
|
-
File.join(RECORDINGS_DIR, "#{name}.jsonl")
|
|
89
|
+
def now
|
|
90
|
+
Time.now.utc.to_f
|
|
142
91
|
end
|
|
143
92
|
|
|
144
93
|
def record_ref_interaction(recording_name, cmd, attrs, response)
|
|
145
94
|
entry = { cmd: "_ref_interaction", ts: now, action: cmd, ref: attrs[:ref], name: attrs[:name] }
|
|
146
95
|
entry.merge!(replay_metadata(response)) if response
|
|
147
|
-
|
|
148
|
-
f.puts JSON.generate(entry)
|
|
149
|
-
end
|
|
96
|
+
LogWriter.append_entry(recording_name, entry)
|
|
150
97
|
end
|
|
151
98
|
|
|
152
99
|
# Pulls the replay-relevant fields out of a daemon response. Each
|
|
153
100
|
# is optional — older daemons or non-resolving commands may omit
|
|
154
101
|
# any of them.
|
|
155
|
-
def now
|
|
156
|
-
Time.now.utc.to_f
|
|
157
|
-
end
|
|
158
|
-
|
|
159
102
|
def replay_metadata(response)
|
|
160
103
|
meta = {}
|
|
161
104
|
meta[:ref] = response[:ref] if response[:ref]
|
|
@@ -166,228 +109,24 @@ module Browserctl
|
|
|
166
109
|
meta.transform_keys(&:to_s)
|
|
167
110
|
end
|
|
168
111
|
|
|
169
|
-
def build_workflow_ruby(name, commands)
|
|
170
|
-
steps = annotated_steps(commands).join("\n\n")
|
|
171
|
-
secrets = commands.map { |c| c[:secret_field] }.compact.uniq
|
|
172
|
-
header = secret_header(secrets)
|
|
173
|
-
<<~RUBY
|
|
174
|
-
# frozen_string_literal: true
|
|
175
|
-
# format_version: #{Browserctl::WORKFLOW_FORMAT_VERSION}
|
|
176
|
-
#{header}
|
|
177
|
-
Browserctl.workflow #{name.inspect} do
|
|
178
|
-
desc "Recorded on #{Date.today}"
|
|
179
|
-
#{secrets.map { |f| " param :secret_#{f}, secret: true" }.join("\n")}
|
|
180
|
-
#{steps.gsub(/^/, ' ')}
|
|
181
|
-
end
|
|
182
|
-
RUBY
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Walks the recorded events and emits the rendered step strings,
|
|
186
|
-
# interleaving inferred waits before selector-driven actions whose
|
|
187
|
-
# preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL
|
|
188
|
-
# postconditions after click/fill steps that triggered navigation.
|
|
189
|
-
def annotated_steps(commands)
|
|
190
|
-
last_url = {}
|
|
191
|
-
commands.each_with_index.flat_map do |cmd, i|
|
|
192
|
-
rendered = []
|
|
193
|
-
if i.positive? && (wait = inferred_wait_step(commands[i - 1], cmd))
|
|
194
|
-
rendered << wait
|
|
195
|
-
end
|
|
196
|
-
rendered << build_step(cmd)
|
|
197
|
-
if (post = url_postcondition_step(cmd, last_url))
|
|
198
|
-
rendered << post
|
|
199
|
-
end
|
|
200
|
-
if (snap = snapshot_postcondition_step(cmd))
|
|
201
|
-
rendered << snap
|
|
202
|
-
end
|
|
203
|
-
update_last_url!(cmd, last_url)
|
|
204
|
-
rendered
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Emits a postcondition assertion when a click/fill resulted in a URL
|
|
209
|
-
# change. Compares the canonical (scheme+host+path) form so query
|
|
210
|
-
# strings and fragments don't make every replay flaky.
|
|
211
|
-
def url_postcondition_step(cmd, last_url)
|
|
212
|
-
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
213
|
-
return nil unless cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
214
|
-
|
|
215
|
-
page = cmd[:name]
|
|
216
|
-
observed = cmd[:postcondition_hint][:url]
|
|
217
|
-
prior = last_url[page]
|
|
218
|
-
return nil if canonical_url(observed) == canonical_url(prior)
|
|
219
|
-
|
|
220
|
-
prefix = canonical_url(observed)
|
|
221
|
-
return nil unless prefix
|
|
222
|
-
|
|
223
|
-
<<~RUBY.chomp
|
|
224
|
-
step "assert url after #{cmd[:cmd]} on #{page}" do
|
|
225
|
-
current = page(:#{page}).url
|
|
226
|
-
assert current.start_with?(#{prefix.inspect}), "expected URL to start with #{prefix}, got \#{current}"
|
|
227
|
-
end
|
|
228
|
-
RUBY
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Emits an assert_snapshot_stable step when the recording captured a
|
|
232
|
-
# post-step DOM digest. Under workflow run --check the helper records
|
|
233
|
-
# drift on mismatch instead of raising, so a wiggly page surfaces in
|
|
234
|
-
# the report rather than failing the run outright.
|
|
235
|
-
def snapshot_postcondition_step(cmd)
|
|
236
|
-
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
237
|
-
return nil unless cmd[:post_snapshot_digest]
|
|
238
|
-
|
|
239
|
-
page = cmd[:name]
|
|
240
|
-
digest = cmd[:post_snapshot_digest]
|
|
241
|
-
<<~RUBY.chomp
|
|
242
|
-
step "assert post-snapshot stable on #{page}" do
|
|
243
|
-
assert_snapshot_stable(:#{page}, expected_digest: #{digest.inspect})
|
|
244
|
-
end
|
|
245
|
-
RUBY
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def update_last_url!(cmd, last_url)
|
|
249
|
-
case cmd[:cmd]
|
|
250
|
-
when "navigate", "page_open"
|
|
251
|
-
last_url[cmd[:name]] = cmd[:url] if cmd[:url]
|
|
252
|
-
when "click", "fill"
|
|
253
|
-
observed = cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
254
|
-
last_url[cmd[:name]] = observed if observed
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def canonical_url(url)
|
|
259
|
-
return nil if url.nil? || url.empty?
|
|
260
|
-
|
|
261
|
-
uri = URI.parse(url)
|
|
262
|
-
path = uri.path.to_s
|
|
263
|
-
path = "/" if path.empty?
|
|
264
|
-
"#{uri.scheme}://#{uri.host}#{path}"
|
|
265
|
-
rescue URI::InvalidURIError
|
|
266
|
-
nil
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def inferred_wait_step(prev, current)
|
|
270
|
-
return nil unless %w[fill click].include?(current[:cmd])
|
|
271
|
-
return nil unless current[:selector]
|
|
272
|
-
|
|
273
|
-
delta = elapsed(prev, current)
|
|
274
|
-
return nil unless delta && delta >= WAIT_THRESHOLD_SECONDS
|
|
275
|
-
|
|
276
|
-
timeout = [WAIT_FLOOR_SECONDS, delta.ceil + WAIT_PADDING_SECONDS].max
|
|
277
|
-
page = current[:name]
|
|
278
|
-
sel = current[:selector]
|
|
279
|
-
<<~RUBY.chomp
|
|
280
|
-
# inferred wait: prior step took ~#{format('%.1f', delta)}s
|
|
281
|
-
step "wait for #{sel} on #{page}" do
|
|
282
|
-
page(:#{page}).wait(#{sel.inspect}, timeout: #{timeout})
|
|
283
|
-
end
|
|
284
|
-
RUBY
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def elapsed(prev, current)
|
|
288
|
-
return nil unless prev && current && prev[:ts] && current[:ts]
|
|
289
|
-
|
|
290
|
-
current[:ts] - prev[:ts]
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def secret_header(secrets)
|
|
294
|
-
return "" if secrets.empty?
|
|
295
|
-
|
|
296
|
-
lines = ["# TODO: review the following secret-shaped fields detected during recording.",
|
|
297
|
-
"# Configure a secret_ref: source for each before running:"]
|
|
298
|
-
secrets.each { |f| lines << "# - secret_#{f}" }
|
|
299
|
-
"\n#{lines.join("\n")}\n"
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
def build_step(cmd)
|
|
303
|
-
label, body = step_parts(cmd)
|
|
304
|
-
|
|
305
|
-
if body.nil?
|
|
306
|
-
page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
307
|
-
action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
|
|
308
|
-
return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
|
|
309
|
-
"replace with a stable CSS selector\n" \
|
|
310
|
-
"# step #{label.inspect} do\n" \
|
|
311
|
-
"# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
|
|
312
|
-
"# end"
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
prefix = []
|
|
316
|
-
prefix << "# NOTE: sensitive query params were redacted during recording" \
|
|
317
|
-
if cmd[:url].to_s.include?("[REDACTED]")
|
|
318
|
-
prefix << "# fingerprint fallback: #{cmd[:fingerprint].to_json}" if cmd[:fingerprint]
|
|
319
|
-
|
|
320
|
-
head = prefix.empty? ? "" : "#{prefix.join("\n")}\n"
|
|
321
|
-
"#{head}step #{label.inspect} do\n #{body}\nend"
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def step_parts(cmd)
|
|
325
|
-
return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
|
|
326
|
-
return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
|
|
327
|
-
|
|
328
|
-
page = cmd[:name]
|
|
329
|
-
case cmd[:cmd]
|
|
330
|
-
when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
331
|
-
when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
332
|
-
when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
333
|
-
when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
334
|
-
else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def ref_interaction_parts(cmd)
|
|
339
|
-
["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def selector_parts(cmd)
|
|
343
|
-
page = cmd[:name]
|
|
344
|
-
case cmd[:cmd]
|
|
345
|
-
when "fill"
|
|
346
|
-
value_arg = cmd[:secret_field] ? "params[:secret_#{cmd[:secret_field]}]" : "params[:fill_value]"
|
|
347
|
-
["fill #{cmd[:selector]} on #{page}",
|
|
348
|
-
"page(:#{page}).fill(#{cmd[:selector].inspect}, #{value_arg})"]
|
|
349
|
-
when "click"
|
|
350
|
-
["click #{cmd[:selector]} on #{page}",
|
|
351
|
-
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
|
|
355
112
|
def prepare_attrs(cmd, attrs)
|
|
356
113
|
attrs = attrs.except(:capture_post_snapshot)
|
|
357
114
|
if cmd == "fill"
|
|
358
115
|
attrs = attrs.except(:value)
|
|
359
|
-
field = infer_secret_field(attrs[:selector])
|
|
116
|
+
field = Redactor.infer_secret_field(attrs[:selector])
|
|
360
117
|
if field
|
|
361
118
|
attrs[:secret_hint] = true
|
|
362
119
|
attrs[:secret_field] = field
|
|
363
120
|
end
|
|
364
121
|
end
|
|
365
|
-
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]
|
|
366
123
|
attrs
|
|
367
124
|
end
|
|
368
|
-
|
|
369
|
-
def infer_secret_field(selector)
|
|
370
|
-
return nil unless selector
|
|
371
|
-
|
|
372
|
-
match = selector.match(SECRET_FIELD_PATTERN)
|
|
373
|
-
return nil unless match
|
|
374
|
-
|
|
375
|
-
match[1].downcase.gsub(/[^a-z0-9]/, "_")
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
def redact_url(url)
|
|
379
|
-
uri = URI.parse(url)
|
|
380
|
-
return url if uri.query.nil?
|
|
381
|
-
|
|
382
|
-
uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
|
|
383
|
-
raw_key = ::Regexp.last_match(1)
|
|
384
|
-
key = URI.decode_www_form_component(raw_key)
|
|
385
|
-
key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
|
|
386
|
-
end
|
|
387
|
-
uri.to_s
|
|
388
|
-
rescue URI::InvalidURIError
|
|
389
|
-
url
|
|
390
|
-
end
|
|
391
125
|
end
|
|
392
126
|
end
|
|
393
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"
|
|
@@ -11,7 +11,6 @@ require_relative "handlers/hitl"
|
|
|
11
11
|
require_relative "handlers/devtools"
|
|
12
12
|
require_relative "handlers/daemon_control"
|
|
13
13
|
require_relative "handlers/storage"
|
|
14
|
-
require_relative "handlers/session"
|
|
15
14
|
require_relative "handlers/state"
|
|
16
15
|
require_relative "handlers/interaction"
|
|
17
16
|
require_relative "../detectors"
|
|
@@ -30,7 +29,6 @@ module Browserctl
|
|
|
30
29
|
include Handlers::DevTools
|
|
31
30
|
include Handlers::DaemonControl
|
|
32
31
|
include Handlers::Storage
|
|
33
|
-
include Handlers::Session
|
|
34
32
|
include Handlers::State
|
|
35
33
|
include Handlers::Interaction
|
|
36
34
|
|
|
@@ -70,10 +68,6 @@ module Browserctl
|
|
|
70
68
|
"select" => :cmd_select,
|
|
71
69
|
"dialog_accept" => :cmd_dialog_accept,
|
|
72
70
|
"dialog_dismiss" => :cmd_dialog_dismiss,
|
|
73
|
-
"session_save" => :cmd_session_save,
|
|
74
|
-
"session_load" => :cmd_session_load,
|
|
75
|
-
"session_list" => :cmd_session_list,
|
|
76
|
-
"session_delete" => :cmd_session_delete,
|
|
77
71
|
"state_save" => :cmd_state_save,
|
|
78
72
|
"state_load" => :cmd_state_load,
|
|
79
73
|
"state_list" => :cmd_state_list,
|
|
@@ -99,23 +93,38 @@ module Browserctl
|
|
|
99
93
|
# @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
|
|
100
94
|
# @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
|
|
101
95
|
def dispatch(req)
|
|
102
|
-
|
|
103
|
-
if
|
|
104
|
-
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
105
|
-
return send(handler, req)
|
|
106
|
-
end
|
|
96
|
+
builtin = dispatch_builtin(req)
|
|
97
|
+
return builtin if builtin
|
|
107
98
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
111
|
-
return plugin.call(session, req)
|
|
112
|
-
end
|
|
99
|
+
plugin = dispatch_plugin(req)
|
|
100
|
+
return plugin if plugin
|
|
113
101
|
|
|
114
102
|
{ error: "unknown command: #{req[:cmd]}" }
|
|
115
103
|
end
|
|
116
104
|
|
|
117
105
|
private
|
|
118
106
|
|
|
107
|
+
# Routes the request to a builtin handler if `req[:cmd]` is in COMMAND_MAP.
|
|
108
|
+
# Returns the handler response, or `nil` if the command isn't a builtin.
|
|
109
|
+
def dispatch_builtin(req)
|
|
110
|
+
handler = COMMAND_MAP[req[:cmd]]
|
|
111
|
+
return nil unless handler
|
|
112
|
+
|
|
113
|
+
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
114
|
+
send(handler, req)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Routes the request to a registered plugin command if one matches.
|
|
118
|
+
# Returns the plugin response, or `nil` if no plugin handles `req[:cmd]`.
|
|
119
|
+
def dispatch_plugin(req)
|
|
120
|
+
plugin = Browserctl.lookup_plugin_command(req[:cmd])
|
|
121
|
+
return nil unless plugin
|
|
122
|
+
|
|
123
|
+
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
124
|
+
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
125
|
+
plugin.call(session, req)
|
|
126
|
+
end
|
|
127
|
+
|
|
119
128
|
def with_page(name)
|
|
120
129
|
session = @global_mutex.synchronize { @pages[name] }
|
|
121
130
|
return { error: "no page named '#{name}'" } unless session
|
|
@@ -15,21 +15,23 @@ module Browserctl
|
|
|
15
15
|
first_session = @global_mutex.synchronize { @pages.values.first }
|
|
16
16
|
return { error: "no open pages — open a page before saving state" } unless first_session
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
captured, captured_origins = capture_state_payload
|
|
19
|
+
payload = Browserctl::State::Payload.build(
|
|
20
|
+
cookies: captured[:cookies],
|
|
21
|
+
local_storage: captured[:local_storage],
|
|
22
|
+
session_storage: captured[:session_storage],
|
|
22
23
|
origins: req[:origins] || captured_origins,
|
|
23
24
|
flow: req[:flow],
|
|
24
25
|
flow_version: req[:flow_version],
|
|
25
26
|
passphrase: req[:passphrase]
|
|
26
27
|
)
|
|
28
|
+
manifest = Browserctl::State.save(req[:name], payload)
|
|
27
29
|
|
|
28
30
|
{
|
|
29
31
|
ok: true,
|
|
30
32
|
path: Browserctl::State.path(req[:name]),
|
|
31
33
|
origins: manifest[:origins],
|
|
32
|
-
cookies: payload
|
|
34
|
+
cookies: payload.cookies.length,
|
|
33
35
|
encrypted: manifest[:encrypted]
|
|
34
36
|
}
|
|
35
37
|
rescue Browserctl::Error, ArgumentError => e
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -6,7 +6,8 @@ require "fileutils"
|
|
|
6
6
|
require "timeout"
|
|
7
7
|
require_relative "constants"
|
|
8
8
|
require_relative "logger"
|
|
9
|
-
require_relative "driver"
|
|
9
|
+
require_relative "driver/cdp_page"
|
|
10
|
+
require_relative "driver/cdp"
|
|
10
11
|
require_relative "server/command_dispatcher"
|
|
11
12
|
require_relative "server/idle_watcher"
|
|
12
13
|
require_relative "server/page_session"
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "openssl"
|
|
5
|
-
require "securerandom"
|
|
6
5
|
require_relative "../errors"
|
|
7
6
|
require_relative "../error/codes"
|
|
7
|
+
require_relative "../encryption_service"
|
|
8
8
|
|
|
9
9
|
module Browserctl
|
|
10
10
|
module State
|
|
11
|
-
# Single-file portable codec for browserctl
|
|
11
|
+
# Single-file portable codec for browserctl persisted state — the .bctl
|
|
12
12
|
# bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
|
|
13
13
|
# alongside a payload of cookies + storage. The manifest is always
|
|
14
14
|
# readable without a passphrase (so `state info` can show origins and
|
|
@@ -37,9 +37,11 @@ module Browserctl
|
|
|
37
37
|
# first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
|
|
38
38
|
# the HMAC-SHA-256 key.
|
|
39
39
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# AES-256-GCM cipher setup and PBKDF2 key derivation are delegated to
|
|
41
|
+
# `Browserctl::EncryptionService` so this class stays focused on the
|
|
42
|
+
# bundle wire format. The service translates `OpenSSL::Cipher` errors
|
|
43
|
+
# into `EncryptionService::DecryptionError`, which we map to
|
|
44
|
+
# `PassphraseError` for the public API.
|
|
43
45
|
class Bundle
|
|
44
46
|
MAGIC = "BCTL\x00".b.freeze
|
|
45
47
|
VERSION = 1
|
|
@@ -53,10 +55,12 @@ module Browserctl
|
|
|
53
55
|
HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
|
|
54
56
|
LEN_SIZE = 4
|
|
55
57
|
FOOTER_SIZE = 32
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
# Cryptographic primitive sizes are sourced from EncryptionService so
|
|
59
|
+
# there is exactly one source of truth for cipher parameters.
|
|
60
|
+
SALT_SIZE = Browserctl::EncryptionService::SALT_SIZE
|
|
61
|
+
NONCE_SIZE = Browserctl::EncryptionService::NONCE_SIZE
|
|
62
|
+
TAG_SIZE = Browserctl::EncryptionService::TAG_SIZE
|
|
63
|
+
PBKDF2_ITERS = Browserctl::EncryptionService::PBKDF2_ITERS
|
|
60
64
|
|
|
61
65
|
class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
|
|
62
66
|
class TamperError < BundleError; def self.default_code = "bundle_tampered" end
|
|
@@ -77,9 +81,9 @@ module Browserctl
|
|
|
77
81
|
hmac_key = nil
|
|
78
82
|
|
|
79
83
|
if passphrase
|
|
80
|
-
salt =
|
|
81
|
-
enc_key, hmac_key = derive_keys(passphrase, salt)
|
|
82
|
-
payload_bytes = salt +
|
|
84
|
+
salt = EncryptionService.random_salt
|
|
85
|
+
enc_key, hmac_key = EncryptionService.derive_keys(passphrase, salt)
|
|
86
|
+
payload_bytes = salt + EncryptionService.encrypt(payload_json, enc_key)
|
|
83
87
|
flags |= FLAG_ENCRYPTED
|
|
84
88
|
else
|
|
85
89
|
payload_bytes = payload_json
|
|
@@ -207,7 +211,7 @@ module Browserctl
|
|
|
207
211
|
# We need the HMAC key, which depends on the salt embedded in the
|
|
208
212
|
# payload. Pull the salt from the payload bytes inside `body`.
|
|
209
213
|
salt = extract_salt!(body)
|
|
210
|
-
_, hmac_key = derive_keys(passphrase, salt)
|
|
214
|
+
_, hmac_key = EncryptionService.derive_keys(passphrase, salt)
|
|
211
215
|
expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
|
|
212
216
|
raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
|
|
213
217
|
else
|
|
@@ -230,48 +234,17 @@ module Browserctl
|
|
|
230
234
|
if encrypted
|
|
231
235
|
salt = bytes.byteslice(0, SALT_SIZE)
|
|
232
236
|
ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
|
|
233
|
-
enc_key, = derive_keys(passphrase, salt)
|
|
234
|
-
plaintext =
|
|
237
|
+
enc_key, = EncryptionService.derive_keys(passphrase, salt)
|
|
238
|
+
plaintext = EncryptionService.decrypt(ciphertext, enc_key)
|
|
235
239
|
JSON.parse(plaintext)
|
|
236
240
|
else
|
|
237
241
|
JSON.parse(bytes)
|
|
238
242
|
end
|
|
239
|
-
rescue
|
|
243
|
+
rescue EncryptionService::DecryptionError
|
|
240
244
|
raise PassphraseError, "wrong passphrase — payload could not be decrypted"
|
|
241
245
|
end
|
|
242
246
|
private_class_method :decode_payload
|
|
243
247
|
|
|
244
|
-
def self.derive_keys(passphrase, salt)
|
|
245
|
-
material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, 64, "SHA256")
|
|
246
|
-
[material.byteslice(0, 32), material.byteslice(32, 32)]
|
|
247
|
-
end
|
|
248
|
-
private_class_method :derive_keys
|
|
249
|
-
|
|
250
|
-
def self.aes_gcm_encrypt(plaintext, key)
|
|
251
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
252
|
-
cipher.encrypt
|
|
253
|
-
cipher.key = key
|
|
254
|
-
nonce = SecureRandom.bytes(NONCE_SIZE)
|
|
255
|
-
cipher.iv = nonce
|
|
256
|
-
ct = cipher.update(plaintext) + cipher.final
|
|
257
|
-
nonce + ct + cipher.auth_tag
|
|
258
|
-
end
|
|
259
|
-
private_class_method :aes_gcm_encrypt
|
|
260
|
-
|
|
261
|
-
def self.aes_gcm_decrypt(blob, key)
|
|
262
|
-
nonce = blob.byteslice(0, NONCE_SIZE)
|
|
263
|
-
tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
|
|
264
|
-
ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
|
|
265
|
-
|
|
266
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
267
|
-
cipher.decrypt
|
|
268
|
-
cipher.key = key
|
|
269
|
-
cipher.iv = nonce
|
|
270
|
-
cipher.auth_tag = tag
|
|
271
|
-
cipher.update(ciphertext) + cipher.final
|
|
272
|
-
end
|
|
273
|
-
private_class_method :aes_gcm_decrypt
|
|
274
|
-
|
|
275
248
|
def self.secure_eq?(actual, expected)
|
|
276
249
|
return false if actual.bytesize != expected.bytesize
|
|
277
250
|
|