browserctl 0.11.0 → 0.12.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 +28 -0
- data/README.md +1 -0
- data/bin/browserctl +143 -94
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +3 -3
- data/lib/browserctl/commands/cli_output.rb +21 -1
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/crash_report.rb +96 -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/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +34 -2
- 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 +3 -0
- 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/session.rb +1 -1
- data/lib/browserctl/state/bundle.rb +43 -2
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow.rb +56 -1
- metadata +15 -7
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
|
|
9
|
+
module Browserctl
|
|
10
|
+
module Commands
|
|
11
|
+
# `browserctl trace [<session>] [--no-redact]` — pretty timeline of
|
|
12
|
+
# structured log events across cli.log + daemon.log. Defaults to most
|
|
13
|
+
# recent session.
|
|
14
|
+
#
|
|
15
|
+
# Loose categorisation by inspecting common keys (event/snapshot/request/
|
|
16
|
+
# error). No schema is enforced — this command is tolerant of any JSONL
|
|
17
|
+
# produced by Browserctl::JsonlFormatter.
|
|
18
|
+
#
|
|
19
|
+
# Redaction: ON by default. Secret values are sourced from current ENV
|
|
20
|
+
# patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
|
|
21
|
+
# captured by `SecretResolverRegistry` during this process. Pass
|
|
22
|
+
# `--no-redact` to disable (local debugging only). Note: when replaying
|
|
23
|
+
# historical traces from a previous process, registry-captured values are
|
|
24
|
+
# gone — only current ENV patterns apply.
|
|
25
|
+
module Trace
|
|
26
|
+
USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
|
|
27
|
+
NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
|
|
28
|
+
"do not paste this output publicly."
|
|
29
|
+
|
|
30
|
+
LEVEL_COLORS = {
|
|
31
|
+
"DEBUG" => "\e[2;37m", # dim grey
|
|
32
|
+
"INFO" => "\e[36m", # cyan
|
|
33
|
+
"WARN" => "\e[33m", # yellow
|
|
34
|
+
"ERROR" => "\e[31m" # red
|
|
35
|
+
}.freeze
|
|
36
|
+
RESET = "\e[0m"
|
|
37
|
+
|
|
38
|
+
CATEGORY_ICONS = {
|
|
39
|
+
error: "!",
|
|
40
|
+
snapshot: "S",
|
|
41
|
+
network: "N",
|
|
42
|
+
event: "."
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
OMIT_KEYS = %w[ts level component event msg].freeze
|
|
46
|
+
|
|
47
|
+
def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
|
|
48
|
+
abort USAGE if args.include?("-h") || args.include?("--help")
|
|
49
|
+
args = args.dup
|
|
50
|
+
redact = !args.delete("--no-redact")
|
|
51
|
+
session_filter = args.shift
|
|
52
|
+
|
|
53
|
+
if redact
|
|
54
|
+
redactor = build_redactor
|
|
55
|
+
else
|
|
56
|
+
redactor = nil
|
|
57
|
+
warn_no_redact(err)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
records = load_records(log_dir)
|
|
61
|
+
if records.empty?
|
|
62
|
+
out.puts "No log entries found in #{log_dir}"
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
records = filter_session(records, session_filter)
|
|
67
|
+
if records.empty?
|
|
68
|
+
out.puts "No entries match session=#{session_filter}"
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
render(records, out: out, redactor: redactor)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.build_redactor
|
|
76
|
+
extra = if defined?(Browserctl::SecretResolverRegistry)
|
|
77
|
+
Browserctl::SecretResolverRegistry.resolved_values
|
|
78
|
+
else
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
Browserctl::Redactor.from_env(extra: extra)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
Browserctl::Redactor.new(secrets: [])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.warn_no_redact(err)
|
|
87
|
+
err&.puts NO_REDACT_WARNING
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.load_records(log_dir)
|
|
91
|
+
paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
|
|
92
|
+
records = paths.flat_map do |path|
|
|
93
|
+
File.foreach(path).filter_map { |line| parse_line(line) }
|
|
94
|
+
end
|
|
95
|
+
records.sort_by { |r| r["ts"].to_s }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.parse_line(line)
|
|
99
|
+
line = line.strip
|
|
100
|
+
return nil if line.empty?
|
|
101
|
+
|
|
102
|
+
JSON.parse(line)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Session resolution. When session_id is stamped on records (future PR),
|
|
108
|
+
# filter/select by it. Otherwise, treat the entire merged stream as one
|
|
109
|
+
# session — caller can scope by tailing/rotating logs.
|
|
110
|
+
# TODO: stamp session_id on every log line so this scopes correctly.
|
|
111
|
+
def self.filter_session(records, session_filter)
|
|
112
|
+
if session_filter
|
|
113
|
+
records.select { |r| r["session_id"].to_s == session_filter }
|
|
114
|
+
else
|
|
115
|
+
ids = records.map { |r| r["session_id"] }.compact.uniq
|
|
116
|
+
if ids.empty?
|
|
117
|
+
records
|
|
118
|
+
else
|
|
119
|
+
recent = ids.last
|
|
120
|
+
records.select { |r| r["session_id"] == recent }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.render(records, out:, redactor: nil)
|
|
126
|
+
tty = out.respond_to?(:tty?) && out.tty?
|
|
127
|
+
records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.format_line(record, tty:, redactor: nil)
|
|
131
|
+
level = (record["level"] || "INFO").to_s
|
|
132
|
+
line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
|
|
133
|
+
ts: format_ts(record["ts"]),
|
|
134
|
+
icon: CATEGORY_ICONS.fetch(categorise(record), "."),
|
|
135
|
+
level: level,
|
|
136
|
+
comp: (record["component"] || "?").to_s,
|
|
137
|
+
label: event_label(record),
|
|
138
|
+
ctx: context_snippet(record)).rstrip
|
|
139
|
+
|
|
140
|
+
line = redactor.redact(line) if redactor
|
|
141
|
+
tty ? colourise(line, level) : line
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.format_ts(timestamp)
|
|
145
|
+
Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
|
|
146
|
+
rescue ArgumentError, TypeError
|
|
147
|
+
"??:??:??.???"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.categorise(record)
|
|
151
|
+
return :error if record["level"] == "ERROR" || record["error"]
|
|
152
|
+
return :snapshot if record["snapshot"]
|
|
153
|
+
return :network if record["request"] || record["response"] || record["url"]
|
|
154
|
+
|
|
155
|
+
:event
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.event_label(record)
|
|
159
|
+
(record["event"] || record["snapshot"] || record["request"] ||
|
|
160
|
+
record["msg"] || "-").to_s.slice(0, 22)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Compact "k=v k=v" snippet of remaining structured keys, capped to keep
|
|
164
|
+
# the timeline scannable. Skips fields already shown in fixed columns.
|
|
165
|
+
def self.context_snippet(record)
|
|
166
|
+
pairs = record.except(*OMIT_KEYS)
|
|
167
|
+
return "" if pairs.empty?
|
|
168
|
+
|
|
169
|
+
pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.format_value(value)
|
|
173
|
+
case value
|
|
174
|
+
when String then value.length > 40 ? "#{value[0, 37]}..." : value
|
|
175
|
+
when Array then "[#{value.length}]"
|
|
176
|
+
when Hash then "{#{value.keys.length}}"
|
|
177
|
+
else value.to_s
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.colourise(line, level)
|
|
182
|
+
colour = LEVEL_COLORS[level] || ""
|
|
183
|
+
"#{colour}#{line}#{RESET}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
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,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
|
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# Stable mapping from a canonical {Browserctl::Error::Codes} string to a
|
|
8
|
+
# process exit status. The CLI's top-level rescue uses this so AI agents
|
|
9
|
+
# and shell scripts can branch on `$?` deterministically without parsing
|
|
10
|
+
# stderr.
|
|
11
|
+
#
|
|
12
|
+
# Stability contract:
|
|
13
|
+
# - Exit code integers are part of the v0.12 stable surface and will not
|
|
14
|
+
# be renumbered without a major version bump.
|
|
15
|
+
# - Unknown / unmapped codes intentionally fall through to {GENERIC} (1)
|
|
16
|
+
# so an unfamiliar code never silently surfaces as a non-error (0).
|
|
17
|
+
#
|
|
18
|
+
# See docs/reference/exit-codes.md for the operator-facing table.
|
|
19
|
+
module ExitCodes
|
|
20
|
+
OK = 0
|
|
21
|
+
GENERIC = 1
|
|
22
|
+
DRIFT = 2
|
|
23
|
+
AUTH_REQUIRED = 3
|
|
24
|
+
DAEMON_UNREACHABLE = 4
|
|
25
|
+
PROTOCOL_MISMATCH = 5
|
|
26
|
+
SELECTOR_NOT_FOUND = 6
|
|
27
|
+
STATE_EXPIRED = 7
|
|
28
|
+
|
|
29
|
+
# Canonical Codes string → exit status integer. Codes without an entry
|
|
30
|
+
# (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
|
|
31
|
+
# GENERIC) collapse to {GENERIC} for now; they may earn dedicated exit
|
|
32
|
+
# codes in a future milestone.
|
|
33
|
+
#
|
|
34
|
+
# DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
|
|
35
|
+
# entry in this table — drift-related raises fall through to GENERIC
|
|
36
|
+
# until that code is introduced.
|
|
37
|
+
TABLE = {
|
|
38
|
+
Codes::AUTH_REQUIRED => AUTH_REQUIRED,
|
|
39
|
+
Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
|
|
40
|
+
Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
|
|
41
|
+
Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
|
|
42
|
+
Codes::STATE_EXPIRED => STATE_EXPIRED
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
|
|
46
|
+
# @return [Integer] mapped exit status; {GENERIC} for nil or unknown codes
|
|
47
|
+
def self.for(code)
|
|
48
|
+
return GENERIC if code.nil?
|
|
49
|
+
|
|
50
|
+
TABLE.fetch(code, GENERIC)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# Maps a stable error code (see {Browserctl::Error::Codes}) to a short
|
|
8
|
+
# imperative sentence telling the operator (or AI agent) what to try
|
|
9
|
+
# next. Codes without an explicit entry fall back to a generic pointer
|
|
10
|
+
# to the error reference doc (added in PR #11 of v0.12).
|
|
11
|
+
module SuggestedActions
|
|
12
|
+
DEFAULT = "See docs/reference/errors.md for guidance."
|
|
13
|
+
|
|
14
|
+
TABLE = {
|
|
15
|
+
Codes::AUTH_REQUIRED =>
|
|
16
|
+
"Run the suggested flow to refresh credentials, then retry.",
|
|
17
|
+
Codes::SELECTOR_NOT_FOUND =>
|
|
18
|
+
"Re-run snapshot to get fresh refs, then retry with a stable ref or selector.",
|
|
19
|
+
Codes::STATE_EXPIRED =>
|
|
20
|
+
"Re-save the state bundle (state save) or rotate it (state rotate).",
|
|
21
|
+
Codes::SECRET_RESOLUTION_FAILED =>
|
|
22
|
+
"Verify the secret resolver config and that the underlying secret exists.",
|
|
23
|
+
Codes::DAEMON_UNREACHABLE =>
|
|
24
|
+
"Start the daemon with 'browserctl daemon start', then retry.",
|
|
25
|
+
Codes::PROTOCOL_MISMATCH =>
|
|
26
|
+
"Upgrade browserctl to a version that supports this artifact's format version.",
|
|
27
|
+
Codes::DOMAIN_NOT_ALLOWED =>
|
|
28
|
+
"Add the domain to your policy allowlist or use an allowed URL.",
|
|
29
|
+
Codes::KEY_NOT_FOUND =>
|
|
30
|
+
"Verify the key was stored in this daemon session before fetching.",
|
|
31
|
+
Codes::GENERIC => DEFAULT
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# @param code [String, nil] a SCREAMING_SNAKE code from {Codes}
|
|
35
|
+
# @return [String] suggested action sentence; never nil
|
|
36
|
+
def self.for(code)
|
|
37
|
+
TABLE.fetch(code, DEFAULT)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error/codes"
|
|
4
|
+
require_relative "error/exit_codes"
|
|
5
|
+
require_relative "error/suggested_actions"
|
|
6
|
+
|
|
3
7
|
module Browserctl
|
|
4
8
|
# Base error class for all browserctl daemon errors.
|
|
5
9
|
# Subclasses carry a machine-readable `code` that appears in wire responses.
|
|
10
|
+
# The canonical enum of stable codes lives in {Browserctl::Error::Codes};
|
|
11
|
+
# the sweep that retrofits every raise to use those codes lands in a later
|
|
12
|
+
# v0.12 PR.
|
|
6
13
|
# @attr_reader code [String] machine-readable error code
|
|
14
|
+
# @attr_reader context [Hash] free-form structured fields (selector, path, ...)
|
|
7
15
|
class Error < StandardError
|
|
8
16
|
def self.default_code = "error"
|
|
9
17
|
|
|
10
|
-
attr_reader :code
|
|
18
|
+
attr_reader :code, :context
|
|
11
19
|
|
|
12
|
-
def initialize(msg = nil, code: self.class.default_code)
|
|
13
|
-
@code
|
|
20
|
+
def initialize(msg = nil, code: self.class.default_code, context: {})
|
|
21
|
+
@code = code
|
|
22
|
+
@context = context || {}
|
|
14
23
|
super(msg)
|
|
15
24
|
end
|
|
25
|
+
|
|
26
|
+
# Returns the canonical structured payload emitted on the daemon wire and
|
|
27
|
+
# on CLI stderr. Shape is stable across releases — agents branch on `code`
|
|
28
|
+
# without parsing prose.
|
|
29
|
+
# @return [Hash{Symbol => Object}]
|
|
30
|
+
def to_payload
|
|
31
|
+
{
|
|
32
|
+
code: code,
|
|
33
|
+
message: message,
|
|
34
|
+
context: context,
|
|
35
|
+
suggested_action: SuggestedActions.for(code)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
16
38
|
end
|
|
17
39
|
|
|
18
|
-
class PageNotFound < Error; def self.default_code = "page_not_found"
|
|
19
|
-
class SelectorNotFound < Error; def self.default_code =
|
|
20
|
-
class RefNotFound < Error; def self.default_code = "ref_not_found"
|
|
21
|
-
class PathNotAllowed < Error; def self.default_code = "path_not_allowed"
|
|
22
|
-
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed"
|
|
23
|
-
class TimeoutError < Error; def self.default_code = "timeout"
|
|
24
|
-
class KeyNotFound
|
|
25
|
-
class DaemonUnavailableError < Error; def self.default_code =
|
|
40
|
+
class PageNotFound < Error; def self.default_code = "page_not_found" end
|
|
41
|
+
class SelectorNotFound < Error; def self.default_code = Codes::SELECTOR_NOT_FOUND end
|
|
42
|
+
class RefNotFound < Error; def self.default_code = "ref_not_found" end
|
|
43
|
+
class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
|
|
44
|
+
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
45
|
+
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
46
|
+
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
47
|
+
class DaemonUnavailableError < Error; def self.default_code = Codes::DAEMON_UNREACHABLE end
|
|
26
48
|
class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
|
|
27
49
|
|
|
28
50
|
# Raised when the daemon detects that the current page needs authentication —
|
|
@@ -31,7 +53,7 @@ module Browserctl
|
|
|
31
53
|
# suggested flow (from the bundle manifest) so callers can recover without
|
|
32
54
|
# additional lookups. The CLI maps this code to exit status 7.
|
|
33
55
|
class AuthRequiredError < Error
|
|
34
|
-
def self.default_code =
|
|
56
|
+
def self.default_code = Codes::AUTH_REQUIRED
|
|
35
57
|
|
|
36
58
|
AUTH_REQUIRED_EXIT_CODE = 7
|
|
37
59
|
|
|
@@ -50,17 +72,25 @@ module Browserctl
|
|
|
50
72
|
code: self.class.default_code,
|
|
51
73
|
state: state,
|
|
52
74
|
suggested_flow: suggested_flow,
|
|
53
|
-
reason: reason
|
|
75
|
+
reason: reason,
|
|
76
|
+
context: { state: state, suggested_flow: suggested_flow, reason: reason }.compact,
|
|
77
|
+
suggested_action: SuggestedActions.for(self.class.default_code)
|
|
54
78
|
}.compact
|
|
55
79
|
end
|
|
56
80
|
end
|
|
57
81
|
|
|
58
82
|
class WorkflowError < Error; def self.default_code = "workflow_error" end
|
|
59
|
-
class SecretResolverError < WorkflowError; def self.default_code =
|
|
83
|
+
class SecretResolverError < WorkflowError; def self.default_code = Codes::SECRET_RESOLUTION_FAILED end
|
|
60
84
|
|
|
61
85
|
class FlowError < WorkflowError; def self.default_code = "flow_error" end
|
|
62
86
|
class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
|
|
63
87
|
class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
|
|
64
88
|
class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
|
|
65
89
|
class FlowPostconditionError < FlowError; def self.default_code = "flow_postcondition_failed" end
|
|
90
|
+
|
|
91
|
+
# Raised when a persisted artifact (bundle, recording, workflow, etc.) has a
|
|
92
|
+
# `version:` header that this build does not know how to read. The full error
|
|
93
|
+
# code taxonomy lands in WS-2 (PR #7); this class is a forward-reference stub
|
|
94
|
+
# so WS-1 PRs can already raise the canonical code.
|
|
95
|
+
class ProtocolMismatch < Error; def self.default_code = Codes::PROTOCOL_MISMATCH end
|
|
66
96
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
# Format-version header convention.
|
|
7
|
+
#
|
|
8
|
+
# Every persisted browserctl artifact declares its format version on the very
|
|
9
|
+
# first line as `version: <int>`. This module is the convention's single
|
|
10
|
+
# source of truth; per-format adoption (bundle, recording, workflow) lands in
|
|
11
|
+
# later WS-1 PRs. See `docs/reference/format-versions.md`.
|
|
12
|
+
module FormatVersion
|
|
13
|
+
HEADER_RE = /\Aversion:\s*(\d+)\s*\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Parse the version header from an IO or String. Returns the Integer
|
|
18
|
+
# version. Raises Browserctl::ProtocolMismatch if the header is missing or
|
|
19
|
+
# malformed.
|
|
20
|
+
def parse(io_or_string)
|
|
21
|
+
first_line = io_or_string.respond_to?(:gets) ? io_or_string.gets : io_or_string.to_s.each_line.first
|
|
22
|
+
raise ProtocolMismatch, "missing version header" if first_line.nil?
|
|
23
|
+
|
|
24
|
+
match = HEADER_RE.match(first_line.chomp)
|
|
25
|
+
raise ProtocolMismatch, "malformed version header: #{first_line.inspect}" unless match
|
|
26
|
+
|
|
27
|
+
Integer(match[1])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the canonical header string for a given integer version.
|
|
31
|
+
def stamp(version:)
|
|
32
|
+
raise ArgumentError, "version must be a non-negative Integer" unless version.is_a?(Integer) && version >= 0
|
|
33
|
+
|
|
34
|
+
"version: #{version}\n"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|