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 "json"
4
4
  require "optimist"
5
+ require_relative "output_format"
5
6
 
6
7
  module Browserctl
7
8
  module Commands
@@ -10,11 +11,11 @@ module Browserctl
10
11
 
11
12
  def self.run(client, args)
12
13
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl snapshot <page> [--format elements|html] [--diff]"
14
+ banner "Usage: browserctl page snapshot <name> [--format elements|html] [--diff]"
14
15
  opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
15
16
  opt :diff, "Return only changed elements", default: false, short: "-d"
16
17
  end
17
- name = args.shift or abort "usage: browserctl snapshot <page> [--format elements|html] [--diff]"
18
+ name = args.shift or abort "usage: browserctl page snapshot <name> [--format elements|html] [--diff]"
18
19
  unless VALID_FORMATS.include?(opts[:format])
19
20
  warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
21
  exit 1
@@ -31,7 +32,11 @@ module Browserctl
31
32
  warn "Error: #{res[:error]}"
32
33
  exit 1
33
34
  end
34
- puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
35
+ if format == "elements"
36
+ OutputFormat.current.emit(res[:snapshot], JSON.pretty_generate(res[:snapshot]))
37
+ else
38
+ OutputFormat.current.emit({ html: res[:html] }, res[:html])
39
+ end
35
40
  end
36
41
  end
37
42
  end
@@ -3,6 +3,7 @@
3
3
  require "io/console"
4
4
  require "json"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
 
7
8
  module Browserctl
8
9
  module Commands
@@ -131,7 +132,7 @@ module Browserctl
131
132
  destination = args.shift or abort "usage: browserctl state export <name> <destination>"
132
133
  require "browserctl/state"
133
134
  result = Browserctl::State.export(name, destination)
134
- puts result.to_json
135
+ OutputFormat.current.emit(result)
135
136
  rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
136
137
  warn "Error: #{e.message}"
137
138
  exit 1
@@ -142,7 +143,7 @@ module Browserctl
142
143
  source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
143
144
  require "browserctl/state"
144
145
  result = Browserctl::State.import(source, name: name_override)
145
- puts result.to_json
146
+ OutputFormat.current.emit(result)
146
147
  rescue Browserctl::State::Transport::TransportError,
147
148
  Browserctl::State::Bundle::BundleError,
148
149
  Browserctl::Error, ArgumentError => e
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../logger"
6
+ require_relative "../redactor"
7
+ require_relative "../secret_resolver_registry"
8
+ require_relative "output_format"
9
+
10
+ module Browserctl
11
+ module Commands
12
+ # `browserctl trace [<session>] [--no-redact]` — pretty timeline of
13
+ # structured log events across cli.log + daemon.log. Defaults to most
14
+ # recent session.
15
+ #
16
+ # Loose categorisation by inspecting common keys (event/snapshot/request/
17
+ # error). No schema is enforced — this command is tolerant of any JSONL
18
+ # produced by Browserctl::JsonlFormatter.
19
+ #
20
+ # Redaction: ON by default. Secret values are sourced from current ENV
21
+ # patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
22
+ # captured by `SecretResolverRegistry` during this process. Pass
23
+ # `--no-redact` to disable (local debugging only). Note: when replaying
24
+ # historical traces from a previous process, registry-captured values are
25
+ # gone — only current ENV patterns apply.
26
+ module Trace
27
+ USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
28
+ NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
29
+ "do not paste this output publicly."
30
+
31
+ LEVEL_COLORS = {
32
+ "DEBUG" => "\e[2;37m", # dim grey
33
+ "INFO" => "\e[36m", # cyan
34
+ "WARN" => "\e[33m", # yellow
35
+ "ERROR" => "\e[31m" # red
36
+ }.freeze
37
+ RESET = "\e[0m"
38
+
39
+ CATEGORY_ICONS = {
40
+ error: "!",
41
+ snapshot: "S",
42
+ network: "N",
43
+ event: "."
44
+ }.freeze
45
+
46
+ OMIT_KEYS = %w[ts level component event msg].freeze
47
+
48
+ def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
49
+ abort USAGE if args.include?("-h") || args.include?("--help")
50
+ args = args.dup
51
+ redact = !args.delete("--no-redact")
52
+ session_filter = args.shift
53
+ redactor = resolve_redactor(redact, err)
54
+ records = collect_records(log_dir, session_filter, out)
55
+ emit_records(records, redactor, out) if records
56
+ end
57
+
58
+ def self.resolve_redactor(redact, err)
59
+ return nil unless redact
60
+
61
+ build_redactor
62
+ ensure
63
+ warn_no_redact(err) unless redact
64
+ end
65
+
66
+ def self.collect_records(log_dir, session_filter, out)
67
+ records = load_records(log_dir)
68
+ if records.empty?
69
+ emit_empty("No log entries found in #{log_dir}", out)
70
+ return nil
71
+ end
72
+
73
+ records = filter_session(records, session_filter)
74
+ if records.empty?
75
+ emit_empty("No entries match session=#{session_filter}", out)
76
+ return nil
77
+ end
78
+
79
+ records
80
+ end
81
+
82
+ def self.emit_empty(message, out)
83
+ OutputFormat.current.emit({ records: [], message: message }, message, io: out)
84
+ end
85
+
86
+ def self.emit_records(records, redactor, out)
87
+ fmt = OutputFormat.current
88
+ if fmt.json?
89
+ fmt.emit({ records: records.map { |r| redact_record(r, redactor) } }, io: out)
90
+ elsif !fmt.silent?
91
+ render(records, out: out, redactor: redactor)
92
+ end
93
+ end
94
+
95
+ def self.redact_record(record, redactor)
96
+ return record unless redactor
97
+
98
+ line = JSON.generate(record)
99
+ JSON.parse(redactor.redact(line))
100
+ rescue JSON::ParserError
101
+ record
102
+ end
103
+
104
+ def self.build_redactor
105
+ extra = if defined?(Browserctl::SecretResolverRegistry)
106
+ Browserctl::SecretResolverRegistry.resolved_values
107
+ else
108
+ []
109
+ end
110
+ Browserctl::Redactor.from_env(extra: extra)
111
+ rescue StandardError
112
+ Browserctl::Redactor.new(secrets: [])
113
+ end
114
+
115
+ def self.warn_no_redact(err)
116
+ err&.puts NO_REDACT_WARNING
117
+ end
118
+
119
+ def self.load_records(log_dir)
120
+ paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
121
+ records = paths.flat_map do |path|
122
+ File.foreach(path).filter_map { |line| parse_line(line) }
123
+ end
124
+ records.sort_by { |r| r["ts"].to_s }
125
+ end
126
+
127
+ def self.parse_line(line)
128
+ line = line.strip
129
+ return nil if line.empty?
130
+
131
+ JSON.parse(line)
132
+ rescue JSON::ParserError
133
+ nil
134
+ end
135
+
136
+ # Session resolution. When session_id is stamped on records (future PR),
137
+ # filter/select by it. Otherwise, treat the entire merged stream as one
138
+ # session — caller can scope by tailing/rotating logs.
139
+ # TODO: stamp session_id on every log line so this scopes correctly.
140
+ def self.filter_session(records, session_filter)
141
+ if session_filter
142
+ records.select { |r| r["session_id"].to_s == session_filter }
143
+ else
144
+ ids = records.map { |r| r["session_id"] }.compact.uniq
145
+ if ids.empty?
146
+ records
147
+ else
148
+ recent = ids.last
149
+ records.select { |r| r["session_id"] == recent }
150
+ end
151
+ end
152
+ end
153
+
154
+ def self.render(records, out:, redactor: nil)
155
+ tty = out.respond_to?(:tty?) && out.tty?
156
+ records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
157
+ end
158
+
159
+ def self.format_line(record, tty:, redactor: nil)
160
+ level = (record["level"] || "INFO").to_s
161
+ line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
162
+ ts: format_ts(record["ts"]),
163
+ icon: CATEGORY_ICONS.fetch(categorise(record), "."),
164
+ level: level,
165
+ comp: (record["component"] || "?").to_s,
166
+ label: event_label(record),
167
+ ctx: context_snippet(record)).rstrip
168
+
169
+ line = redactor.redact(line) if redactor
170
+ tty ? colourise(line, level) : line
171
+ end
172
+
173
+ def self.format_ts(timestamp)
174
+ Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
175
+ rescue ArgumentError, TypeError
176
+ "??:??:??.???"
177
+ end
178
+
179
+ def self.categorise(record)
180
+ return :error if record["level"] == "ERROR" || record["error"]
181
+ return :snapshot if record["snapshot"]
182
+ return :network if record["request"] || record["response"] || record["url"]
183
+
184
+ :event
185
+ end
186
+
187
+ def self.event_label(record)
188
+ (record["event"] || record["snapshot"] || record["request"] ||
189
+ record["msg"] || "-").to_s.slice(0, 22)
190
+ end
191
+
192
+ # Compact "k=v k=v" snippet of remaining structured keys, capped to keep
193
+ # the timeline scannable. Skips fields already shown in fixed columns.
194
+ def self.context_snippet(record)
195
+ pairs = record.except(*OMIT_KEYS)
196
+ return "" if pairs.empty?
197
+
198
+ pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
199
+ end
200
+
201
+ def self.format_value(value)
202
+ case value
203
+ when String then value.length > 40 ? "#{value[0, 37]}..." : value
204
+ when Array then "[#{value.length}]"
205
+ when Hash then "{#{value.keys.length}}"
206
+ else value.to_s
207
+ end
208
+ end
209
+
210
+ def self.colourise(line, level)
211
+ colour = LEVEL_COLORS[level] || ""
212
+ "#{colour}#{line}#{RESET}"
213
+ end
214
+ end
215
+ end
216
+ end
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
  require_relative "../recording"
7
8
  require_relative "../workflow/promoter"
8
9
 
@@ -44,11 +45,11 @@ module Browserctl
44
45
  result = Browserctl::Workflow::Promoter.promote(
45
46
  workflow: name, force: force, threshold: threshold, as_flow: as_flow
46
47
  )
47
- puts JSON.generate(ok: true, **result)
48
+ OutputFormat.current.emit({ ok: true, **result })
48
49
  rescue Browserctl::Workflow::Promoter::IneligibleError => e
49
- puts JSON.generate(
50
- ok: false, error: "ineligible",
51
- message: e.message, streak: e.streak, threshold: e.threshold
50
+ OutputFormat.current.emit(
51
+ { ok: false, error: "ineligible",
52
+ message: e.message, streak: e.streak, threshold: e.threshold }
52
53
  )
53
54
  exit 1
54
55
  rescue Browserctl::Workflow::Promoter::NotFoundError => e
@@ -68,7 +69,7 @@ module Browserctl
68
69
  end
69
70
  FileUtils.mkdir_p(File.dirname(out))
70
71
  Browserctl::Recording.generate_workflow(name, output_path: out, keep_log: true)
71
- puts JSON.generate({ ok: true, name: name, path: out })
72
+ OutputFormat.current.emit({ ok: true, name: name, path: out })
72
73
  rescue StandardError => e
73
74
  abort "Error generating workflow: #{e.message}"
74
75
  end
@@ -111,12 +112,13 @@ module Browserctl
111
112
 
112
113
  def self.run_list(runner)
113
114
  list = runner.list_workflows
114
- puts JSON.generate({ workflows: list.map { |w| { name: w[:name], desc: w[:desc] } } })
115
+ OutputFormat.current.emit({ workflows: list.map { |w| { name: w[:name], desc: w[:desc] } } })
115
116
  end
116
117
 
117
118
  def self.run_describe(runner, args)
118
119
  name = args.shift or abort "usage: browserctl workflow describe <name>"
119
- puts JSON.pretty_generate(runner.describe_workflow(name))
120
+ payload = runner.describe_workflow(name)
121
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
120
122
  end
121
123
  end
122
124
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module Browserctl
4
6
  BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
7
  IDLE_TTL = 30 * 60
@@ -26,7 +28,7 @@ module Browserctl
26
28
  1.upto(99) do |i|
27
29
  return "d#{i}" unless File.exist?(socket_path("d#{i}"))
28
30
  end
29
- raise "too many running daemons (limit: 99)"
31
+ raise Browserctl::Error, "too many running daemons (limit: 99)"
30
32
  end
31
33
 
32
34
  def self.all_daemon_sockets
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow/recovery_manager"
4
+
5
+ module Browserctl
6
+ # Persistence-and-state DSL mixed into {WorkflowContext}. {FlowContext}
7
+ # does NOT mix this in — that absence is the structural enforcement of
8
+ # the doctrinal split: flows return state, workflows share state through
9
+ # the daemon-backed `store`/`fetch` and `.bctl` bundles.
10
+ #
11
+ # Hosts must expose `@client` and may expose `@replay_context` (for the
12
+ # selector-rematch fallback used elsewhere). Auth-required recovery is
13
+ # delegated to {Workflow::RecoveryManager}, which calls back into the
14
+ # host's `invoke` so flows bound to a saved bundle can rotate
15
+ # credentials transparently.
16
+ module ContextualPersistence
17
+ def store(key, value)
18
+ res = @client.store(key.to_s, value)
19
+ raise WorkflowError, res[:error] if res[:error]
20
+
21
+ value
22
+ end
23
+
24
+ def fetch(key)
25
+ res = @client.fetch(key.to_s)
26
+ raise WorkflowError, res[:error] if res[:error]
27
+
28
+ res[:value]
29
+ end
30
+
31
+ # Persists the daemon's current cookies + storage as a .bctl bundle.
32
+ # Optional flow binding lets `load_state` auto-rotate when the bundle
33
+ # is detected as needing authentication.
34
+ def save_state(name, flow: nil, origins: nil, encrypt: false)
35
+ passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
36
+ res = @client.state_save(name.to_s,
37
+ flow: flow&.to_s, origins: origins, passphrase: passphrase)
38
+ raise WorkflowError, res[:error] if res[:error]
39
+
40
+ res
41
+ end
42
+
43
+ # Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
44
+ # applying (e.g. expired cookies in the payload), this delegates to
45
+ # {Workflow::RecoveryManager}, which rotates the bound flow and retries
46
+ # — no caller code change required.
47
+ #
48
+ # @param on_auth_required [Proc, nil] override the auto-rotate path. The
49
+ # block runs in the workflow context, in lieu of invoking the manifest's
50
+ # bound flow. Use this when the recovery procedure is bespoke.
51
+ def load_state(name, on_auth_required: nil)
52
+ res = @client.state_load(name.to_s)
53
+ return res unless Workflow::RecoveryManager.auth_required?(res)
54
+
55
+ Workflow::RecoveryManager.new(self).recover(name.to_s, res, on_auth_required: on_auth_required)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "fileutils"
6
+ require "etc"
7
+
8
+ require_relative "version"
9
+ require_relative "logger"
10
+
11
+ module Browserctl
12
+ # Writes a local crash report JSON file when the daemon panics. No telemetry,
13
+ # no upload — purely a local artifact users can attach to bug reports.
14
+ #
15
+ # The writer is intentionally defensive: it must never raise. If anything
16
+ # goes wrong while writing the file, it falls back to a single
17
+ # `[crash-report-failed]` line on stderr and returns nil.
18
+ module CrashReport
19
+ SCHEMA_VERSION = 1
20
+ LAST_EVENTS_LIMIT = 50
21
+
22
+ # @param error [Exception] the unhandled exception that took the daemon down
23
+ # @param log_path [String, nil] path to the daemon's JSONL log; the last
24
+ # {LAST_EVENTS_LIMIT} valid records are tailed in
25
+ # @return [String, nil] the path of the written crash file, or nil on failure
26
+ def self.write(error:, log_path: nil)
27
+ ts = Time.now.utc
28
+ filename = "crash-#{ts.iso8601(3).gsub(':', '-')}.json"
29
+ dir = Browserctl.log_dir
30
+ FileUtils.mkdir_p(dir, mode: 0o700)
31
+ path = File.join(dir, filename)
32
+
33
+ payload = {
34
+ schema_version: SCHEMA_VERSION,
35
+ ts: ts.iso8601(3),
36
+ daemon_version: Browserctl::VERSION,
37
+ ruby_version: RUBY_VERSION,
38
+ os: os_info,
39
+ error: error_info(error),
40
+ backtrace: Array(error.backtrace),
41
+ last_events: tail_events(log_path)
42
+ }
43
+
44
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
45
+ f.write(JSON.pretty_generate(payload))
46
+ end
47
+ path
48
+ rescue StandardError => e
49
+ warn "[crash-report-failed] #{e.class}: #{e.message}"
50
+ nil
51
+ end
52
+
53
+ def self.os_info
54
+ info = { platform: RUBY_PLATFORM }
55
+ uname = Etc.uname
56
+ info[:version] = uname[:version] if uname.is_a?(Hash) && uname[:version]
57
+ info[:sysname] = uname[:sysname] if uname.is_a?(Hash) && uname[:sysname]
58
+ info
59
+ rescue StandardError
60
+ { platform: RUBY_PLATFORM }
61
+ end
62
+ private_class_method :os_info
63
+
64
+ def self.error_info(error)
65
+ info = {
66
+ class: error.class.name,
67
+ message: error.message.to_s
68
+ }
69
+ info[:code] = error.code if error.respond_to?(:code) && error.code
70
+ info
71
+ end
72
+ private_class_method :error_info
73
+
74
+ def self.tail_events(log_path)
75
+ return [] unless log_path && File.exist?(log_path)
76
+
77
+ lines = File.readlines(log_path).last(LAST_EVENTS_LIMIT * 2)
78
+ events = []
79
+ lines.reverse_each do |line|
80
+ line = line.strip
81
+ next if line.empty?
82
+
83
+ begin
84
+ events.unshift(JSON.parse(line))
85
+ rescue JSON::ParserError
86
+ next
87
+ end
88
+ break if events.length >= LAST_EVENTS_LIMIT
89
+ end
90
+ events
91
+ rescue StandardError
92
+ []
93
+ end
94
+ private_class_method :tail_events
95
+ end
96
+ end
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ferrum"
4
- require_relative "base"
5
4
  require_relative "cdp_page"
6
5
  require_relative "../errors"
7
6
 
8
7
  module Browserctl
9
8
  module Driver
10
- class CDP < Base
9
+ class CDP
11
10
  BRAVE_PATHS = {
12
11
  darwin: [
13
12
  "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
@@ -24,7 +23,7 @@ module Browserctl
24
23
  ]
25
24
  }.freeze
26
25
 
27
- def initialize(headless: true, browser: "chrome") # rubocop:disable Lint/MissingSuper
26
+ def initialize(headless: true, browser: "chrome")
28
27
  @headless = headless
29
28
  @browser = browser
30
29
  @ferrum = Ferrum::Browser.new(**ferrum_options)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module Browserctl
7
+ # Cryptographic primitives for browserctl-managed payloads.
8
+ #
9
+ # Raised when decryption fails (tampered ciphertext or wrong key). Callers
10
+ # should catch this rather than `OpenSSL::Cipher::CipherError` so they
11
+ # don't have to take a direct dependency on the underlying cipher library.
12
+ #
13
+ # Owns AES-256-GCM cipher setup and PBKDF2 key derivation so callers like
14
+ # `State::Bundle` don't reach into `OpenSSL::Cipher` directly. The contract
15
+ # is intentionally narrow:
16
+ #
17
+ # * `derive_keys(passphrase, salt)` returns a `[enc_key, hmac_key]` pair
18
+ # derived via PBKDF2-HMAC-SHA256 with `PBKDF2_ITERS` iterations and a
19
+ # 64-byte output split in half. The first half is the AES-256-GCM key,
20
+ # the second is the HMAC-SHA-256 key for the bundle footer.
21
+ # * `encrypt(plaintext, key)` returns `nonce || ciphertext || tag`.
22
+ # * `decrypt(blob, key)` reverses it; raises `OpenSSL::Cipher::CipherError`
23
+ # on tampered blobs / wrong key.
24
+ # * `random_salt` / `random_nonce` are exposed so callers can keep bundle
25
+ # wire-format assembly in one place without re-deriving sizes.
26
+ #
27
+ # The constants here are duplicated in `State::Bundle` only as named
28
+ # references to the wire format positions; the source of truth lives here.
29
+ module EncryptionService
30
+ class DecryptionError < StandardError; end
31
+
32
+ SALT_SIZE = 16
33
+ NONCE_SIZE = 12
34
+ TAG_SIZE = 16
35
+ KEY_SIZE = 32
36
+ PBKDF2_ITERS = 200_000
37
+ DIGEST = "SHA256"
38
+ CIPHER = "aes-256-gcm"
39
+
40
+ module_function
41
+
42
+ # Returns `[enc_key, hmac_key]`, each `KEY_SIZE` bytes.
43
+ def derive_keys(passphrase, salt)
44
+ material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, KEY_SIZE * 2, DIGEST)
45
+ [material.byteslice(0, KEY_SIZE), material.byteslice(KEY_SIZE, KEY_SIZE)]
46
+ end
47
+
48
+ # AES-256-GCM. Returns `nonce || ciphertext || tag`.
49
+ def encrypt(plaintext, key)
50
+ cipher = OpenSSL::Cipher.new(CIPHER)
51
+ cipher.encrypt
52
+ cipher.key = key
53
+ nonce = SecureRandom.bytes(NONCE_SIZE)
54
+ cipher.iv = nonce
55
+ ct = cipher.update(plaintext) + cipher.final
56
+ nonce + ct + cipher.auth_tag
57
+ end
58
+
59
+ # Inverse of `encrypt`. Raises `DecryptionError` on tampered ciphertext
60
+ # or wrong key so callers don't need to reach into `OpenSSL::Cipher`.
61
+ def decrypt(blob, key)
62
+ nonce = blob.byteslice(0, NONCE_SIZE)
63
+ tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
64
+ ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
65
+
66
+ cipher = OpenSSL::Cipher.new(CIPHER)
67
+ cipher.decrypt
68
+ cipher.key = key
69
+ cipher.iv = nonce
70
+ cipher.auth_tag = tag
71
+ cipher.update(ciphertext) + cipher.final
72
+ rescue OpenSSL::Cipher::CipherError => e
73
+ raise DecryptionError, e.message
74
+ end
75
+
76
+ def random_salt
77
+ SecureRandom.bytes(SALT_SIZE)
78
+ end
79
+
80
+ def random_nonce
81
+ SecureRandom.bytes(NONCE_SIZE)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class Error < StandardError
5
+ # Canonical enum of stable error code strings emitted on the wire and in
6
+ # CLI stderr payloads. Codes are SCREAMING_SNAKE_CASE and must remain
7
+ # stable across releases — agents branch on these deterministically.
8
+ #
9
+ # The full sweep that wires every raise site to one of these codes lands
10
+ # in PR #8 of the v0.12 "Solid" milestone. This module is the single
11
+ # source of truth those raises will reference.
12
+ module Codes
13
+ AUTH_REQUIRED = "AUTH_REQUIRED"
14
+ SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
15
+ STATE_EXPIRED = "STATE_EXPIRED"
16
+ SECRET_RESOLUTION_FAILED = "SECRET_RESOLUTION_FAILED"
17
+ DAEMON_UNREACHABLE = "DAEMON_UNREACHABLE"
18
+ PROTOCOL_MISMATCH = "PROTOCOL_MISMATCH"
19
+ DOMAIN_NOT_ALLOWED = "DOMAIN_NOT_ALLOWED"
20
+ KEY_NOT_FOUND = "KEY_NOT_FOUND"
21
+ GENERIC = "GENERIC"
22
+
23
+ ALL = [
24
+ AUTH_REQUIRED,
25
+ SELECTOR_NOT_FOUND,
26
+ STATE_EXPIRED,
27
+ SECRET_RESOLUTION_FAILED,
28
+ DAEMON_UNREACHABLE,
29
+ PROTOCOL_MISMATCH,
30
+ DOMAIN_NOT_ALLOWED,
31
+ KEY_NOT_FOUND,
32
+ GENERIC
33
+ ].freeze
34
+
35
+ def self.all
36
+ ALL
37
+ end
38
+
39
+ def self.valid?(code)
40
+ ALL.include?(code)
41
+ end
42
+ end
43
+ end
44
+ end