browserctl 0.10.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 +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -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 +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- 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 +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- 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 +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "json"
|
|
4
5
|
require_relative "cli_output"
|
|
6
|
+
require_relative "../recording"
|
|
7
|
+
require_relative "../workflow/promoter"
|
|
5
8
|
|
|
6
9
|
module Browserctl
|
|
7
10
|
module Commands
|
|
8
11
|
module Workflow
|
|
9
12
|
extend CliOutput
|
|
10
13
|
|
|
11
|
-
USAGE = "Usage: browserctl workflow <run|list|describe> [args]"
|
|
14
|
+
USAGE = "Usage: browserctl workflow <run|list|describe|generate|promote> [args]"
|
|
12
15
|
|
|
13
16
|
def self.run(runner, args)
|
|
14
17
|
sub = args.shift or abort USAGE
|
|
@@ -16,18 +19,73 @@ module Browserctl
|
|
|
16
19
|
when "run" then run_workflow(runner, args)
|
|
17
20
|
when "list" then run_list(runner)
|
|
18
21
|
when "describe" then run_describe(runner, args)
|
|
22
|
+
when "generate" then run_generate(args)
|
|
23
|
+
when "promote" then run_promote(args)
|
|
19
24
|
else abort "unknown workflow subcommand '#{sub}'\n#{USAGE}"
|
|
20
25
|
end
|
|
21
26
|
end
|
|
22
27
|
|
|
28
|
+
def self.run_promote(args)
|
|
29
|
+
name = args.shift or abort \
|
|
30
|
+
"usage: browserctl workflow promote <name> [--force] [--threshold N] [--as-flow]"
|
|
31
|
+
|
|
32
|
+
force = !args.delete("--force").nil?
|
|
33
|
+
as_flow = !args.delete("--as-flow").nil?
|
|
34
|
+
|
|
35
|
+
threshold_idx = args.index("--threshold")
|
|
36
|
+
threshold = if threshold_idx
|
|
37
|
+
val = args.delete_at(threshold_idx + 1)
|
|
38
|
+
args.delete_at(threshold_idx)
|
|
39
|
+
Integer(val)
|
|
40
|
+
else
|
|
41
|
+
Browserctl::Workflow::PromotionLedger::DEFAULT_THRESHOLD
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
result = Browserctl::Workflow::Promoter.promote(
|
|
45
|
+
workflow: name, force: force, threshold: threshold, as_flow: as_flow
|
|
46
|
+
)
|
|
47
|
+
puts JSON.generate(ok: true, **result)
|
|
48
|
+
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
|
|
52
|
+
)
|
|
53
|
+
exit 1
|
|
54
|
+
rescue Browserctl::Workflow::Promoter::NotFoundError => e
|
|
55
|
+
abort "Error: #{e.message}"
|
|
56
|
+
rescue ArgumentError => e
|
|
57
|
+
abort "Error: invalid --threshold value: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.run_generate(args)
|
|
61
|
+
name = args.shift or abort \
|
|
62
|
+
"usage: browserctl workflow generate <recording> [--out PATH]"
|
|
63
|
+
out_idx = args.index("--out")
|
|
64
|
+
out = if out_idx
|
|
65
|
+
args.delete_at(out_idx + 1).tap { args.delete_at(out_idx) }
|
|
66
|
+
else
|
|
67
|
+
File.join(".browserctl/workflows", "#{name}.rb")
|
|
68
|
+
end
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
70
|
+
Browserctl::Recording.generate_workflow(name, output_path: out, keep_log: true)
|
|
71
|
+
puts JSON.generate({ ok: true, name: name, path: out })
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
abort "Error generating workflow: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
EXIT_CODE = { clean: 0, drift: 2, fail: 1 }.freeze
|
|
77
|
+
|
|
23
78
|
def self.run_workflow(runner, args)
|
|
24
|
-
name = args.shift or abort
|
|
79
|
+
name = args.shift or abort \
|
|
80
|
+
"usage: browserctl workflow run <name|file> [--check] [--params file] [--key value ...]"
|
|
25
81
|
if File.exist?(name)
|
|
26
82
|
before = Browserctl.registry_snapshot.keys
|
|
27
83
|
load File.expand_path(name)
|
|
28
84
|
name = (Browserctl.registry_snapshot.keys - before).first || File.basename(name, ".rb")
|
|
29
85
|
end
|
|
30
86
|
|
|
87
|
+
check = !args.delete("--check").nil?
|
|
88
|
+
|
|
31
89
|
params_file_idx = args.index("--params")
|
|
32
90
|
file_params = {}
|
|
33
91
|
if params_file_idx
|
|
@@ -47,8 +105,8 @@ module Browserctl
|
|
|
47
105
|
end
|
|
48
106
|
|
|
49
107
|
params = file_params.merge(cli_params)
|
|
50
|
-
|
|
51
|
-
exit(
|
|
108
|
+
verdict = runner.run_workflow(name, check: check, **params)
|
|
109
|
+
exit(EXIT_CODE.fetch(verdict, 1))
|
|
52
110
|
end
|
|
53
111
|
|
|
54
112
|
def self.run_list(runner)
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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
|
|
6
8
|
# Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
|
|
7
9
|
# Clients read this from `ping` to verify compatibility before sending commands.
|
|
8
|
-
PROTOCOL_VERSION = "
|
|
10
|
+
PROTOCOL_VERSION = "3"
|
|
9
11
|
|
|
10
12
|
def self.socket_path(name = nil)
|
|
11
13
|
File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
|
|
@@ -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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects "you need to log in again" — the signal that flips a workflow's
|
|
6
|
+
# `load_state` into `state rotate` territory. Lives alongside the older
|
|
7
|
+
# `Detectors.cloudflare?` and follows the same `(page) -> Result` shape.
|
|
8
|
+
#
|
|
9
|
+
# Three independent checks, evaluated in order. The first to fire wins:
|
|
10
|
+
#
|
|
11
|
+
# 1. URL → login path. The page is currently sitting on /login,
|
|
12
|
+
# /signin, /auth/login, or a similar canonical login route. This
|
|
13
|
+
# catches redirect-based auth ("we noticed you're not logged in").
|
|
14
|
+
# 2. Recent HTTP 401 / 403. The most recent network response on this
|
|
15
|
+
# page was an auth challenge from the backend.
|
|
16
|
+
# 3. Cookie ledger. A caller-supplied list of cookies contains entries
|
|
17
|
+
# that have already expired. Useful when the daemon is preflighting
|
|
18
|
+
# a bundle before navigating.
|
|
19
|
+
#
|
|
20
|
+
# Each check is pure — no daemon dependencies — so the same detector
|
|
21
|
+
# runs server-side (handlers/observation.rb) and client-side (workflow
|
|
22
|
+
# `load_state` hook).
|
|
23
|
+
module AuthRequired
|
|
24
|
+
Result = Struct.new(:triggered, :code, :reason, :suggested_flow, keyword_init: true) do
|
|
25
|
+
def to_h
|
|
26
|
+
{ triggered: triggered, code: code, reason: reason, suggested_flow: suggested_flow }.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
LOGIN_PATH_RE = %r{(?:^|/)(login|signin|sign[-_]in|auth/(?:login|signin)|account/login)(?:/|$|\?)}i
|
|
31
|
+
|
|
32
|
+
AUTH_HTTP_STATUSES = [401, 403].freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Run every check; return the first triggered Result, or a non-
|
|
36
|
+
# triggered Result if all pass.
|
|
37
|
+
#
|
|
38
|
+
# @param page [#current_url] anything quacking like a Page
|
|
39
|
+
# @param recent_responses [Array<Hash>] each `{ status:, url: }`; the
|
|
40
|
+
# caller is responsible for collecting these (e.g. via Ferrum's
|
|
41
|
+
# network.traffic). Empty by default so callers without traffic
|
|
42
|
+
# instrumentation still get the URL/cookie checks.
|
|
43
|
+
# @param cookies [Array<Hash>, nil] each `{ name:, expires: }`;
|
|
44
|
+
# `expires` is a unix timestamp. nil to skip the cookie check.
|
|
45
|
+
# @param suggested_flow [String, nil] flow name to surface when the
|
|
46
|
+
# detector fires — populated by callers from the bundle manifest.
|
|
47
|
+
# @return [Result]
|
|
48
|
+
def detect(page, recent_responses: [], cookies: nil, suggested_flow: nil)
|
|
49
|
+
[
|
|
50
|
+
check_url(page, suggested_flow),
|
|
51
|
+
check_responses(recent_responses, suggested_flow),
|
|
52
|
+
check_cookies(cookies, suggested_flow)
|
|
53
|
+
].find(&:triggered) || negative
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Convenience predicate matching the existing `Detectors.cloudflare?`
|
|
57
|
+
# style. Callers that just need a boolean can use this.
|
|
58
|
+
def triggered?(page, **)
|
|
59
|
+
detect(page, **).triggered
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def check_url(page, suggested_flow)
|
|
65
|
+
url = safe_call(page, :current_url).to_s
|
|
66
|
+
match = LOGIN_PATH_RE.match(url)
|
|
67
|
+
return negative unless match
|
|
68
|
+
|
|
69
|
+
Result.new(triggered: true, code: "redirect_login",
|
|
70
|
+
reason: "url '#{url}' matches login path", suggested_flow: suggested_flow)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_responses(recent_responses, suggested_flow)
|
|
74
|
+
response = Array(recent_responses).reverse_each.find do |r|
|
|
75
|
+
AUTH_HTTP_STATUSES.include?(r[:status] || r["status"])
|
|
76
|
+
end
|
|
77
|
+
return negative unless response
|
|
78
|
+
|
|
79
|
+
status = response[:status] || response["status"]
|
|
80
|
+
url = response[:url] || response["url"]
|
|
81
|
+
Result.new(triggered: true, code: "http_#{status}",
|
|
82
|
+
reason: "recent #{status} from #{url}", suggested_flow: suggested_flow)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_cookies(cookies, suggested_flow)
|
|
86
|
+
return negative if cookies.nil?
|
|
87
|
+
|
|
88
|
+
expired = expired_cookies(cookies)
|
|
89
|
+
return negative if expired.empty?
|
|
90
|
+
|
|
91
|
+
names = expired.map { |c| c[:name] || c["name"] }.compact.join(", ")
|
|
92
|
+
Result.new(triggered: true, code: "cookie_expired",
|
|
93
|
+
reason: "expired cookies: #{names}", suggested_flow: suggested_flow)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def expired_cookies(cookies)
|
|
97
|
+
now = Time.now.to_f
|
|
98
|
+
Array(cookies).select { |c| cookie_expired?(c, now) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cookie_expired?(cookie, now)
|
|
102
|
+
exp = cookie[:expires] || cookie["expires"]
|
|
103
|
+
return false unless exp
|
|
104
|
+
|
|
105
|
+
float = exp.to_f
|
|
106
|
+
float.positive? && float < now
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def negative
|
|
110
|
+
Result.new(triggered: false, code: nil, reason: nil, suggested_flow: nil)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def safe_call(obj, method)
|
|
114
|
+
obj.respond_to?(method) ? obj.public_send(method) : nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Module-level shorthand so callers match the existing `Detectors.cloudflare?` style.
|
|
120
|
+
def self.auth_required?(page, **)
|
|
121
|
+
AuthRequired.triggered?(page, **)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.auth_required(page, **)
|
|
125
|
+
AuthRequired.detect(page, **)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/browserctl/detectors.rb
CHANGED
|
@@ -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,36 +1,96 @@
|
|
|
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
|
|
|
50
|
+
# Raised when the daemon detects that the current page needs authentication —
|
|
51
|
+
# the canonical signal that a workflow's `load_state` should rotate the bound
|
|
52
|
+
# flow. Carries the bundle name (when a state load was in progress) and a
|
|
53
|
+
# suggested flow (from the bundle manifest) so callers can recover without
|
|
54
|
+
# additional lookups. The CLI maps this code to exit status 7.
|
|
55
|
+
class AuthRequiredError < Error
|
|
56
|
+
def self.default_code = Codes::AUTH_REQUIRED
|
|
57
|
+
|
|
58
|
+
AUTH_REQUIRED_EXIT_CODE = 7
|
|
59
|
+
|
|
60
|
+
attr_reader :state, :suggested_flow, :reason
|
|
61
|
+
|
|
62
|
+
def initialize(msg = "authentication required", state: nil, suggested_flow: nil, reason: nil)
|
|
63
|
+
super(msg)
|
|
64
|
+
@state = state
|
|
65
|
+
@suggested_flow = suggested_flow
|
|
66
|
+
@reason = reason
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_response
|
|
70
|
+
{
|
|
71
|
+
error: message,
|
|
72
|
+
code: self.class.default_code,
|
|
73
|
+
state: state,
|
|
74
|
+
suggested_flow: suggested_flow,
|
|
75
|
+
reason: reason,
|
|
76
|
+
context: { state: state, suggested_flow: suggested_flow, reason: reason }.compact,
|
|
77
|
+
suggested_action: SuggestedActions.for(self.class.default_code)
|
|
78
|
+
}.compact
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
28
82
|
class WorkflowError < Error; def self.default_code = "workflow_error" end
|
|
29
|
-
class SecretResolverError < WorkflowError; def self.default_code =
|
|
83
|
+
class SecretResolverError < WorkflowError; def self.default_code = Codes::SECRET_RESOLUTION_FAILED end
|
|
30
84
|
|
|
31
85
|
class FlowError < WorkflowError; def self.default_code = "flow_error" end
|
|
32
86
|
class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
|
|
33
87
|
class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
|
|
34
88
|
class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
|
|
35
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
|
|
36
96
|
end
|
data/lib/browserctl/flow.rb
CHANGED
|
@@ -186,9 +186,30 @@ module Browserctl
|
|
|
186
186
|
end
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
@flow_registry_mutex = Mutex.new
|
|
190
|
+
@flow_registry = {}
|
|
191
|
+
|
|
189
192
|
def self.flow(name, &block)
|
|
190
193
|
raise ArgumentError, "Browserctl.flow requires a block" unless block
|
|
191
194
|
|
|
192
|
-
Flow.new(name).tap { |f| f.instance_exec(&block) }
|
|
195
|
+
flow = Flow.new(name).tap { |f| f.instance_exec(&block) }
|
|
196
|
+
register_flow(flow)
|
|
197
|
+
flow
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.register_flow(flow)
|
|
201
|
+
@flow_registry_mutex.synchronize { @flow_registry[flow.name] = flow }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.lookup_flow(name)
|
|
205
|
+
@flow_registry_mutex.synchronize { @flow_registry[name.to_s] }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.flow_registry_snapshot
|
|
209
|
+
@flow_registry_mutex.synchronize { @flow_registry.dup }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.flow_registry_reset!
|
|
213
|
+
@flow_registry_mutex.synchronize { @flow_registry.clear }
|
|
193
214
|
end
|
|
194
215
|
end
|