browserctl 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -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"
@@ -10,15 +11,16 @@ require_relative "handlers/hitl"
10
11
  require_relative "handlers/devtools"
11
12
  require_relative "handlers/daemon_control"
12
13
  require_relative "handlers/storage"
13
- require_relative "handlers/session"
14
14
  require_relative "handlers/state"
15
15
  require_relative "handlers/interaction"
16
16
  require_relative "../detectors"
17
17
  require_relative "../policy"
18
+ require_relative "../errors"
18
19
  require_relative "../replay/snapshot_diff"
19
20
 
20
21
  module Browserctl
21
22
  class CommandDispatcher
23
+ include Handlers::ErrorPayload
22
24
  include Handlers::PageLifecycle
23
25
  include Handlers::Navigation
24
26
  include Handlers::Observation
@@ -27,7 +29,6 @@ module Browserctl
27
29
  include Handlers::DevTools
28
30
  include Handlers::DaemonControl
29
31
  include Handlers::Storage
30
- include Handlers::Session
31
32
  include Handlers::State
32
33
  include Handlers::Interaction
33
34
 
@@ -67,10 +68,6 @@ module Browserctl
67
68
  "select" => :cmd_select,
68
69
  "dialog_accept" => :cmd_dialog_accept,
69
70
  "dialog_dismiss" => :cmd_dialog_dismiss,
70
- "session_save" => :cmd_session_save,
71
- "session_load" => :cmd_session_load,
72
- "session_list" => :cmd_session_list,
73
- "session_delete" => :cmd_session_delete,
74
71
  "state_save" => :cmd_state_save,
75
72
  "state_load" => :cmd_state_load,
76
73
  "state_list" => :cmd_state_list,
@@ -96,23 +93,38 @@ module Browserctl
96
93
  # @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
97
94
  # @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
98
95
  def dispatch(req)
99
- handler = COMMAND_MAP[req[:cmd]]
100
- if handler
101
- Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
102
- return send(handler, req)
103
- end
96
+ builtin = dispatch_builtin(req)
97
+ return builtin if builtin
104
98
 
105
- if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
106
- Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
107
- session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
108
- return plugin.call(session, req)
109
- end
99
+ plugin = dispatch_plugin(req)
100
+ return plugin if plugin
110
101
 
111
102
  { error: "unknown command: #{req[:cmd]}" }
112
103
  end
113
104
 
114
105
  private
115
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
+
116
128
  def with_page(name)
117
129
  session = @global_mutex.synchronize { @pages[name] }
118
130
  return { error: "no page named '#{name}'" } unless session
@@ -21,7 +21,11 @@ module Browserctl
21
21
  def cmd_fetch(req)
22
22
  key = req[:key].to_s
23
23
  found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
24
- found || { error: "key '#{key}' not found", code: "key_not_found" }
24
+ found || error_payload(
25
+ code: Browserctl::Error::Codes::KEY_NOT_FOUND,
26
+ message: "key '#{key}' not found",
27
+ context: { key: key }
28
+ )
25
29
  end
26
30
  end
27
31
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ # Centralised structured-error builder for daemon JSON-RPC responses.
9
+ # Each handler returns `{ error:, code:, context:, suggested_action: }`
10
+ # for any failure carrying a stable {Browserctl::Error::Codes} code.
11
+ module ErrorPayload
12
+ # @param code [String] a SCREAMING_SNAKE code from {Codes}
13
+ # @param message [String] human-readable error
14
+ # @param context [Hash] free-form structured fields (selector, path, ...)
15
+ # @return [Hash{Symbol => Object}]
16
+ def error_payload(code:, message:, context: {})
17
+ {
18
+ error: message,
19
+ code: code,
20
+ context: context,
21
+ suggested_action: Browserctl::Error::SuggestedActions.for(code)
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -27,7 +27,13 @@ module Browserctl
27
27
  "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
28
28
  "})(#{sel.to_json})"
29
29
  )
30
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless coords
30
+ unless coords
31
+ return error_payload(
32
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
33
+ message: "selector not found: #{sel}",
34
+ context: { selector: sel }
35
+ )
36
+ end
31
37
 
32
38
  session.page.mouse.move(x: coords["x"], y: coords["y"])
33
39
  { ok: true }
@@ -43,7 +49,13 @@ module Browserctl
43
49
  return sel if sel.is_a?(Hash)
44
50
 
45
51
  el = session.page.at_css(sel)
46
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless el
52
+ unless el
53
+ return error_payload(
54
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
55
+ message: "selector not found: #{sel}",
56
+ context: { selector: sel }
57
+ )
58
+ end
47
59
 
48
60
  el.select_file(path)
49
61
  { ok: true }
@@ -56,7 +68,13 @@ module Browserctl
56
68
  return sel if sel.is_a?(Hash)
57
69
 
58
70
  el = session.page.at_css(sel)
59
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless el
71
+ unless el
72
+ return error_payload(
73
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
74
+ message: "selector not found: #{sel}",
75
+ context: { selector: sel }
76
+ )
77
+ end
60
78
 
61
79
  el.evaluate(
62
80
  "this.value = #{req[:value].to_json}; " \
@@ -8,7 +8,11 @@ module Browserctl
8
8
 
9
9
  def cmd_navigate(req)
10
10
  unless Policy.allowed_navigation?(req[:url].to_s)
11
- return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
11
+ return error_payload(
12
+ code: Browserctl::Error::Codes::DOMAIN_NOT_ALLOWED,
13
+ message: "navigation to '#{req[:url]}' blocked by domain policy",
14
+ context: { url: req[:url].to_s }
15
+ )
12
16
  end
13
17
 
14
18
  with_page(req[:name]) do |session|
@@ -81,7 +85,13 @@ module Browserctl
81
85
 
82
86
  def type_into(page, selector, value)
83
87
  el = page.at_css(selector)
84
- return { error: "selector not found: #{selector}", code: "selector_not_found" } unless el
88
+ unless el
89
+ return error_payload(
90
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
91
+ message: "selector not found: #{selector}",
92
+ context: { selector: selector }
93
+ )
94
+ end
85
95
 
86
96
  el.focus
87
97
  el.evaluate("this.select()")
@@ -91,7 +101,13 @@ module Browserctl
91
101
 
92
102
  def click_element(page, selector)
93
103
  el = page.at_css(selector)
94
- return { error: "selector not found: #{selector}", code: "selector_not_found" } unless el
104
+ unless el
105
+ return error_payload(
106
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
107
+ message: "selector not found: #{selector}",
108
+ context: { selector: selector }
109
+ )
110
+ end
95
111
 
96
112
  # Use the DOM native click() so JS-only event listeners fire.
97
113
  # CDP mouse simulation (el.click) dispatches events at screen coordinates
@@ -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,12 +2,13 @@
2
2
 
3
3
  require "json"
4
4
  require "openssl"
5
- require "securerandom"
6
5
  require_relative "../errors"
6
+ require_relative "../error/codes"
7
+ require_relative "../encryption_service"
7
8
 
8
9
  module Browserctl
9
10
  module State
10
- # Single-file portable codec for browserctl session state — the .bctl
11
+ # Single-file portable codec for browserctl persisted state — the .bctl
11
12
  # bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
12
13
  # alongside a payload of cookies + storage. The manifest is always
13
14
  # readable without a passphrase (so `state info` can show origins and
@@ -36,20 +37,30 @@ module Browserctl
36
37
  # first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
37
38
  # the HMAC-SHA-256 key.
38
39
  #
39
- # Reuses the same AES-256-GCM primitive as v0.8 session encryption
40
- # (lib/browserctl/session.rb). The two will share a Crypto module in a
41
- # 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.
42
45
  class Bundle
43
46
  MAGIC = "BCTL\x00".b.freeze
44
47
  VERSION = 1
48
+ # Manifest-level format version, written as `format_version` and
49
+ # validated on decode. Distinct from the wire-format byte `VERSION`
50
+ # above (which gates the binary envelope shape) — this gates the
51
+ # manifest schema. See docs/reference/format-versions.md.
52
+ BUNDLE_FORMAT_VERSION = 1
53
+ SUPPORTED_FORMAT_VERSIONS = [BUNDLE_FORMAT_VERSION].freeze
45
54
  FLAG_ENCRYPTED = 0x01
46
55
  HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
47
56
  LEN_SIZE = 4
48
57
  FOOTER_SIZE = 32
49
- SALT_SIZE = 16
50
- NONCE_SIZE = 12
51
- TAG_SIZE = 16
52
- 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
53
64
 
54
65
  class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
55
66
  class TamperError < BundleError; def self.default_code = "bundle_tampered" end
@@ -63,15 +74,16 @@ module Browserctl
63
74
  # the footer is an HMAC. When nil, payload is plaintext and the
64
75
  # footer is a SHA-256 digest.
65
76
  def self.encode(manifest:, payload:, passphrase: nil)
77
+ manifest = stamp_format_version(manifest)
66
78
  manifest_bytes = JSON.generate(manifest).b
67
79
  payload_json = JSON.generate(payload).b
68
80
  flags = 0
69
81
  hmac_key = nil
70
82
 
71
83
  if passphrase
72
- salt = SecureRandom.bytes(SALT_SIZE)
73
- enc_key, hmac_key = derive_keys(passphrase, salt)
74
- 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)
75
87
  flags |= FLAG_ENCRYPTED
76
88
  else
77
89
  payload_bytes = payload_json
@@ -96,7 +108,8 @@ module Browserctl
96
108
  verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
97
109
 
98
110
  manifest = JSON.parse(manifest_bytes, symbolize_names: true)
99
- payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
111
+ verify_format_version!(manifest)
112
+ payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
100
113
 
101
114
  { manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
102
115
  end
@@ -108,8 +121,40 @@ module Browserctl
108
121
  raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
109
122
 
110
123
  manifest_bytes, = read_sections!(blob)
111
- JSON.parse(manifest_bytes, symbolize_names: true)
124
+ manifest = JSON.parse(manifest_bytes, symbolize_names: true)
125
+ verify_format_version!(manifest)
126
+ manifest
127
+ end
128
+
129
+ # Returns the manifest with `format_version` set as the first key. When
130
+ # the caller already provided a value we keep it (so encoders can stamp
131
+ # a future version explicitly); otherwise we stamp the current one.
132
+ def self.stamp_format_version(manifest)
133
+ existing = manifest[:format_version] || manifest["format_version"]
134
+ version = existing || BUNDLE_FORMAT_VERSION
135
+ rest = manifest.except(:format_version, "format_version")
136
+ { format_version: version }.merge(rest)
137
+ end
138
+ private_class_method :stamp_format_version
139
+
140
+ # Raises Browserctl::ProtocolMismatch when the manifest declares no
141
+ # format_version or one this build does not support. The error carries
142
+ # the canonical PROTOCOL_MISMATCH code from the v0.12 error taxonomy.
143
+ def self.verify_format_version!(manifest, path: nil)
144
+ version = manifest[:format_version] || manifest["format_version"]
145
+ return if version && SUPPORTED_FORMAT_VERSIONS.include?(version)
146
+
147
+ where = path ? " at #{path}" : ""
148
+ msg = if version.nil?
149
+ "bundle manifest#{where} is missing format_version " \
150
+ "(supported: #{SUPPORTED_FORMAT_VERSIONS.inspect})"
151
+ else
152
+ "bundle manifest#{where} declares format_version=#{version.inspect}, " \
153
+ "this build supports #{SUPPORTED_FORMAT_VERSIONS.inspect}"
154
+ end
155
+ raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
112
156
  end
157
+ private_class_method :verify_format_version!
113
158
 
114
159
  def self.build_body(flags, manifest_bytes, payload_bytes)
115
160
  header = MAGIC + [VERSION, flags, 0].pack("CCC")
@@ -166,7 +211,7 @@ module Browserctl
166
211
  # We need the HMAC key, which depends on the salt embedded in the
167
212
  # payload. Pull the salt from the payload bytes inside `body`.
168
213
  salt = extract_salt!(body)
169
- _, hmac_key = derive_keys(passphrase, salt)
214
+ _, hmac_key = EncryptionService.derive_keys(passphrase, salt)
170
215
  expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
171
216
  raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
172
217
  else
@@ -189,48 +234,17 @@ module Browserctl
189
234
  if encrypted
190
235
  salt = bytes.byteslice(0, SALT_SIZE)
191
236
  ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
192
- enc_key, = derive_keys(passphrase, salt)
193
- plaintext = aes_gcm_decrypt(ciphertext, enc_key)
237
+ enc_key, = EncryptionService.derive_keys(passphrase, salt)
238
+ plaintext = EncryptionService.decrypt(ciphertext, enc_key)
194
239
  JSON.parse(plaintext)
195
240
  else
196
241
  JSON.parse(bytes)
197
242
  end
198
- rescue OpenSSL::Cipher::CipherError
243
+ rescue EncryptionService::DecryptionError
199
244
  raise PassphraseError, "wrong passphrase — payload could not be decrypted"
200
245
  end
201
246
  private_class_method :decode_payload
202
247
 
203
- def self.derive_keys(passphrase, salt)
204
- material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, 64, "SHA256")
205
- [material.byteslice(0, 32), material.byteslice(32, 32)]
206
- end
207
- private_class_method :derive_keys
208
-
209
- def self.aes_gcm_encrypt(plaintext, key)
210
- cipher = OpenSSL::Cipher.new("aes-256-gcm")
211
- cipher.encrypt
212
- cipher.key = key
213
- nonce = SecureRandom.bytes(NONCE_SIZE)
214
- cipher.iv = nonce
215
- ct = cipher.update(plaintext) + cipher.final
216
- nonce + ct + cipher.auth_tag
217
- end
218
- private_class_method :aes_gcm_encrypt
219
-
220
- def self.aes_gcm_decrypt(blob, key)
221
- nonce = blob.byteslice(0, NONCE_SIZE)
222
- tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
223
- ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
224
-
225
- cipher = OpenSSL::Cipher.new("aes-256-gcm")
226
- cipher.decrypt
227
- cipher.key = key
228
- cipher.iv = nonce
229
- cipher.auth_tag = tag
230
- cipher.update(ciphertext) + cipher.final
231
- end
232
- private_class_method :aes_gcm_decrypt
233
-
234
248
  def self.secure_eq?(actual, expected)
235
249
  return false if actual.bytesize != expected.bytesize
236
250
 
@@ -42,6 +42,41 @@ module Browserctl
42
42
  EXTENSION = ".bctl"
43
43
  MANIFEST_VERSION = 1
44
44
 
45
+ # Value object bundling everything needed to persist a state bundle. The
46
+ # browser-side data lives in `cookies`, `local_storage`, and
47
+ # `session_storage`; the manifest extras live in `origins`, `flow`, and
48
+ # `flow_version`; `passphrase` flips the bundle into an encrypted variant.
49
+ Payload = Data.define(
50
+ :cookies,
51
+ :local_storage,
52
+ :session_storage,
53
+ :origins,
54
+ :flow,
55
+ :flow_version,
56
+ :passphrase
57
+ ) do
58
+ def self.build(cookies: [], local_storage: {}, session_storage: {}, # rubocop:disable Metrics/ParameterLists
59
+ origins: nil, flow: nil, flow_version: nil, passphrase: nil)
60
+ new(
61
+ cookies: cookies,
62
+ local_storage: local_storage,
63
+ session_storage: session_storage,
64
+ origins: origins,
65
+ flow: flow,
66
+ flow_version: flow_version,
67
+ passphrase: passphrase
68
+ )
69
+ end
70
+
71
+ def to_bundle_payload
72
+ {
73
+ cookies: cookies,
74
+ local_storage: local_storage,
75
+ session_storage: session_storage
76
+ }
77
+ end
78
+ end
79
+
45
80
  def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
46
81
  def self.exist?(name) = File.exist?(path(name))
47
82
 
@@ -51,22 +86,24 @@ module Browserctl
51
86
  raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
52
87
  end
53
88
 
54
- # Persist a bundle. `payload` is { cookies:, local_storage:, session_storage: }.
55
- # `manifest_extras` may carry origins (override), flow, flow_version.
56
- def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
89
+ # Persist a bundle. `payload` is a `State::Payload` value object carrying
90
+ # cookies, local/session storage, and the manifest extras (origins, flow,
91
+ # flow_version, passphrase).
92
+ def self.save(name, payload)
57
93
  validate_name!(name)
58
94
  FileUtils.mkdir_p(BASE_DIR)
59
95
 
96
+ bundle_payload = payload.to_bundle_payload
60
97
  manifest = build_manifest(
61
98
  name: name,
62
- origins: origins || derive_origins(payload),
63
- flow: flow,
64
- flow_version: flow_version,
65
- cookies: payload[:cookies] || payload["cookies"] || [],
66
- encrypted: !passphrase.nil?
99
+ origins: payload.origins || derive_origins(bundle_payload),
100
+ flow: payload.flow,
101
+ flow_version: payload.flow_version,
102
+ cookies: payload.cookies || [],
103
+ encrypted: !payload.passphrase.nil?
67
104
  )
68
105
 
69
- blob = Bundle.encode(manifest: manifest, payload: payload, passphrase: passphrase)
106
+ blob = Bundle.encode(manifest: manifest, payload: bundle_payload, passphrase: payload.passphrase)
70
107
  File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
71
108
  manifest
72
109
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.11.0"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -59,7 +59,7 @@ module Browserctl
59
59
  def write(defn, overwrite: true, dir: nil)
60
60
  path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
61
61
  if File.exist?(path) && !overwrite
62
- raise "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
62
+ raise Browserctl::WorkflowError, "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
63
63
  end
64
64
 
65
65
  FileUtils.mkdir_p(File.dirname(path))
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Workflow
5
+ # Owns the AUTH_REQUIRED recovery state machine for `load_state`.
6
+ #
7
+ # When the daemon reports AUTH_REQUIRED on a `state_load` (e.g. expired
8
+ # cookies in the bundle), the manager either runs the bound flow or
9
+ # the caller-provided override, re-saves the bundle, and reloads it
10
+ # with `skip_auth_check: true`.
11
+ #
12
+ # Decoupled from {ContextualPersistence} so the multi-step recovery
13
+ # logic has a dedicated home and a dedicated spec. The host context
14
+ # only needs to expose `client` (for daemon RPCs) and `invoke` (for
15
+ # running the bound flow); see {ContextualPersistence#load_state}.
16
+ class RecoveryManager
17
+ AUTH_REQUIRED_CODE = "AUTH_REQUIRED"
18
+
19
+ # @param context [#client, #invoke] a workflow context exposing
20
+ # the daemon client and an `invoke(flow_name, page:)` entry point.
21
+ def initialize(context)
22
+ @context = context
23
+ end
24
+
25
+ # True when `res` is the daemon's AUTH_REQUIRED preflight signal.
26
+ def self.auth_required?(res)
27
+ (res[:code] || res["code"]) == AUTH_REQUIRED_CODE
28
+ end
29
+
30
+ # Run recovery for `state_name` given the daemon's initial AUTH_REQUIRED
31
+ # response. Returns the merged retry result (with `rotated: true`) or
32
+ # raises {WorkflowError} when no flow is bound and no override is
33
+ # supplied, or when the post-rotation reload still fails.
34
+ #
35
+ # @param state_name [String] the bundle name being loaded
36
+ # @param initial_res [Hash] the original AUTH_REQUIRED response
37
+ # @param on_auth_required [Proc, nil] optional override; when given,
38
+ # it runs in lieu of invoking the suggested flow.
39
+ def recover(state_name, initial_res, on_auth_required: nil)
40
+ if on_auth_required
41
+ on_auth_required.call
42
+ else
43
+ invoke_bound_flow(state_name, initial_res)
44
+ end
45
+
46
+ rotate_and_reload(state_name)
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :context
52
+
53
+ def invoke_bound_flow(state_name, initial_res)
54
+ flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
55
+ if flow_name.nil? || flow_name.to_s.empty?
56
+ raise WorkflowError,
57
+ "state '#{state_name}' needs auth but bundle has no bound flow — " \
58
+ "save with `save_state('#{state_name}', flow: :NAME)` or pass on_auth_required:"
59
+ end
60
+
61
+ # Match the daemon's `state load` preflight: it auth-checks the first
62
+ # open page (insertion order). Passing that same name to the flow
63
+ # gives stdlib flows a `page` proxy to drive (oauth_github reads
64
+ # `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
65
+ # page only when nothing is open — `state_save` would have errored
66
+ # earlier in that case, so this is a defence-in-depth nil.
67
+ context.invoke(flow_name, page: first_open_page)
68
+ end
69
+
70
+ def rotate_and_reload(state_name)
71
+ after_save = context.client.state_save(state_name)
72
+ raise WorkflowError, after_save[:error] if after_save[:error]
73
+
74
+ retry_res = context.client.state_load(state_name, skip_auth_check: true)
75
+ raise WorkflowError, retry_res[:error] if retry_res[:error]
76
+
77
+ retry_res.merge(rotated: true)
78
+ end
79
+
80
+ def first_open_page
81
+ res = context.client.page_list
82
+ pages = res[:pages] || res["pages"] || []
83
+ pages.first
84
+ end
85
+ end
86
+ end
87
+ end