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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- 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 +142 -0
- 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 +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording/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 +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- 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
|
@@ -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 <
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
OutputFormat.current.emit({ ok: true, **result })
|
|
48
49
|
rescue Browserctl::Workflow::Promoter::IneligibleError => e
|
|
49
|
-
|
|
50
|
-
ok: false, error: "ineligible",
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
payload = runner.describe_workflow(name)
|
|
121
|
+
OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
|
|
120
122
|
end
|
|
121
123
|
end
|
|
122
124
|
end
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -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
|
|
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")
|
|
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
|