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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. 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 "usage: browserctl workflow run <name|file> [--params file] [--key value ...]"
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
- success = runner.run_workflow(name, **params)
51
- exit(success ? 0 : 1)
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)
@@ -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 = "2"
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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "detectors/auth_required"
4
+
3
5
  module Browserctl
4
6
  module Detectors
5
7
  CLOUDFLARE_SIGNALS = [
@@ -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
@@ -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 = 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" end
19
- class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
- class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
- class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
- class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
- class TimeoutError < Error; def self.default_code = "timeout" end
24
- class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
- class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
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 = "secret_resolver_error" end
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
@@ -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