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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +3 -3
  4. data/bin/browserctl +39 -32
  5. data/lib/browserctl/callable_definition.rb +114 -0
  6. data/lib/browserctl/client.rb +0 -27
  7. data/lib/browserctl/commands/cli_output.rb +17 -3
  8. data/lib/browserctl/commands/daemon.rb +10 -6
  9. data/lib/browserctl/commands/flow.rb +7 -5
  10. data/lib/browserctl/commands/init.rb +20 -7
  11. data/lib/browserctl/commands/migrate.rb +56 -8
  12. data/lib/browserctl/commands/output_format.rb +144 -0
  13. data/lib/browserctl/commands/page.rb +9 -5
  14. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  15. data/lib/browserctl/commands/resume.rb +1 -1
  16. data/lib/browserctl/commands/screenshot.rb +2 -2
  17. data/lib/browserctl/commands/snapshot.rb +8 -3
  18. data/lib/browserctl/commands/state.rb +3 -2
  19. data/lib/browserctl/commands/trace.rb +40 -11
  20. data/lib/browserctl/commands/workflow.rb +9 -7
  21. data/lib/browserctl/contextual_persistence.rb +58 -0
  22. data/lib/browserctl/driver/cdp.rb +2 -3
  23. data/lib/browserctl/encryption_service.rb +84 -0
  24. data/lib/browserctl/flow.rb +35 -59
  25. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  26. data/lib/browserctl/recording/log_writer.rb +82 -0
  27. data/lib/browserctl/recording/redactor.rb +58 -0
  28. data/lib/browserctl/recording/state.rb +44 -0
  29. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  30. data/lib/browserctl/recording.rb +33 -294
  31. data/lib/browserctl/server/command_dispatcher.rb +25 -16
  32. data/lib/browserctl/server/handlers/state.rb +7 -5
  33. data/lib/browserctl/server.rb +2 -1
  34. data/lib/browserctl/state/bundle.rb +20 -47
  35. data/lib/browserctl/state.rb +46 -9
  36. data/lib/browserctl/version.rb +1 -1
  37. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  38. data/lib/browserctl/workflow.rb +61 -237
  39. metadata +11 -8
  40. data/examples/session_reuse.rb +0 -75
  41. data/lib/browserctl/commands/session.rb +0 -243
  42. data/lib/browserctl/driver/base.rb +0 -13
  43. data/lib/browserctl/driver.rb +0 -5
  44. data/lib/browserctl/server/handlers/session.rb +0 -94
  45. data/lib/browserctl/session.rb +0 -206
@@ -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
- 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
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. 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.
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
- FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
47
- FileUtils.mkdir_p(File.dirname(STATE_FILE))
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
- name = active
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
- File.exist?(STATE_FILE) ? File.read(STATE_FILE).strip : nil
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
- File.open(log_path(name), "a") do |f|
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 = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
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 = build_workflow_ruby(name, lines)
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
- FileUtils.rm_f(log) if log && !keep_log
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
- # 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
-
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
- File.open(log_path(recording_name), "a") do |f|
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
- handler = COMMAND_MAP[req[:cmd]]
103
- if handler
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
- if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
109
- Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
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
- payload, captured_origins = capture_state_payload
19
- manifest = Browserctl::State.save(
20
- req[:name],
21
- payload: payload,
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[:cookies].length,
34
+ cookies: payload.cookies.length,
33
35
  encrypted: manifest[:encrypted]
34
36
  }
35
37
  rescue Browserctl::Error, ArgumentError => e
@@ -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 session state — the .bctl
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
- # Reuses the same AES-256-GCM primitive as v0.8 session encryption
41
- # (lib/browserctl/session.rb). The two will share a Crypto module in a
42
- # follow-up; duplicated here to keep this PR focused.
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
- SALT_SIZE = 16
57
- NONCE_SIZE = 12
58
- TAG_SIZE = 16
59
- PBKDF2_ITERS = 200_000
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 = SecureRandom.bytes(SALT_SIZE)
81
- enc_key, hmac_key = derive_keys(passphrase, salt)
82
- payload_bytes = salt + aes_gcm_encrypt(payload_json, enc_key)
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 = aes_gcm_decrypt(ciphertext, enc_key)
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 OpenSSL::Cipher::CipherError
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