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
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "constants"
7
+ require_relative "errors"
8
+ require_relative "version"
9
+ require_relative "state/bundle"
10
+ require_relative "state/transport"
11
+ require_relative "state/transports/file"
12
+ require_relative "state/transports/s3"
13
+ require_relative "state/transports/one_password"
14
+
15
+ module Browserctl
16
+ # Top-level state store: a single .bctl bundle per name under
17
+ # ~/.browserctl/state/<name>.bctl. Wraps the Bundle codec with on-disk
18
+ # naming, validation, and a small inventory API used by `state list/info`.
19
+ #
20
+ # Data shape inside a bundle:
21
+ #
22
+ # manifest = {
23
+ # name: String,
24
+ # version: 1, # bundle schema version
25
+ # producer: "browserctl/<gem-ver>",
26
+ # created_at: ISO-8601,
27
+ # origins: [String, ...],
28
+ # flow: String | nil, # bound flow name, for `state rotate`
29
+ # flow_version: String | nil,
30
+ # expires_at: ISO-8601 | nil, # earliest cookie expiry
31
+ # encrypted: Boolean
32
+ # }
33
+ #
34
+ # payload = {
35
+ # cookies: [Hash, ...],
36
+ # local_storage: { origin => { key => value } },
37
+ # session_storage: { origin => { key => value } }
38
+ # }
39
+ module State
40
+ BASE_DIR = File.join(BROWSERCTL_DIR, "state")
41
+ SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
42
+ EXTENSION = ".bctl"
43
+ MANIFEST_VERSION = 1
44
+
45
+ def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
46
+ def self.exist?(name) = File.exist?(path(name))
47
+
48
+ def self.validate_name!(name)
49
+ return if SAFE_NAME.match?(name.to_s)
50
+
51
+ raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
52
+ end
53
+
54
+ # Persist a bundle. `payload` is { cookies:, local_storage:, session_storage: }.
55
+ # `manifest_extras` may carry origins (override), flow, flow_version.
56
+ def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
57
+ validate_name!(name)
58
+ FileUtils.mkdir_p(BASE_DIR)
59
+
60
+ manifest = build_manifest(
61
+ name: name,
62
+ origins: origins || derive_origins(payload),
63
+ flow: flow,
64
+ flow_version: flow_version,
65
+ cookies: payload[:cookies] || payload["cookies"] || [],
66
+ encrypted: !passphrase.nil?
67
+ )
68
+
69
+ blob = Bundle.encode(manifest: manifest, payload: payload, passphrase: passphrase)
70
+ File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
71
+ manifest
72
+ end
73
+
74
+ # Load and decode a bundle. Returns { manifest:, payload:, encrypted: }.
75
+ def self.load(name, passphrase: nil)
76
+ validate_name!(name)
77
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
78
+
79
+ Bundle.decode(File.binread(path(name)), passphrase: passphrase)
80
+ end
81
+
82
+ def self.delete(name)
83
+ validate_name!(name)
84
+ FileUtils.rm_f(path(name))
85
+ end
86
+
87
+ # Copies the on-disk .bctl bundle to a transport-addressable destination
88
+ # (file path, s3://bucket/key, op://Vault/Item, or any registered scheme).
89
+ # Bundle bytes are written verbatim — no re-encoding — so the receiving
90
+ # side can verify the manifest/payload exactly as produced.
91
+ def self.export(name, destination)
92
+ validate_name!(name)
93
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
94
+
95
+ transport, parsed = Transport.for(destination)
96
+ blob = ::File.binread(path(name))
97
+ transport.write(parsed, blob)
98
+ { name: name, destination: destination, bytes: blob.bytesize }
99
+ end
100
+
101
+ # Pulls a bundle from a transport-addressable source and stores it as a
102
+ # local state. Validates the magic header before persisting so we never
103
+ # leave a corrupt bundle in the state directory. `name` defaults to the
104
+ # source's basename without `.bctl`.
105
+ def self.import(source, name: nil)
106
+ transport, parsed = Transport.for(source)
107
+ blob = transport.read(parsed)
108
+ raise Bundle::BundleError, "imported blob is not a .bctl bundle" unless blob.start_with?(Bundle::MAGIC)
109
+
110
+ manifest = Bundle.peek_manifest(blob)
111
+ target_name = name || derive_name(source) || manifest[:name]
112
+ validate_name!(target_name)
113
+
114
+ FileUtils.mkdir_p(BASE_DIR)
115
+ ::File.open(path(target_name), "wb", 0o600) { |f| f.write(blob) }
116
+ { name: target_name, source: source, bytes: blob.bytesize, encrypted: manifest[:encrypted] }
117
+ end
118
+
119
+ def self.derive_name(uri)
120
+ base = ::File.basename(uri.to_s.split("?").first.to_s, EXTENSION)
121
+ return nil if base.empty?
122
+
123
+ base
124
+ end
125
+ private_class_method :derive_name
126
+
127
+ # Read manifests for all stored bundles. Errors on a single file are
128
+ # surfaced via { error: "...", path: "..." } rather than aborting the list.
129
+ def self.all
130
+ return [] unless Dir.exist?(BASE_DIR)
131
+
132
+ Dir[File.join(BASE_DIR, "*#{EXTENSION}")].map do |file|
133
+ info_for(file)
134
+ end
135
+ end
136
+
137
+ # Inspect a single bundle without decrypting the payload.
138
+ def self.info(name)
139
+ validate_name!(name)
140
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
141
+
142
+ info_for(path(name))
143
+ end
144
+
145
+ def self.info_for(file)
146
+ blob = File.binread(file)
147
+ manifest = Bundle.peek_manifest(blob)
148
+ manifest.merge(
149
+ path: file,
150
+ size: blob.bytesize
151
+ )
152
+ rescue Bundle::BundleError => e
153
+ { name: File.basename(file, EXTENSION), path: file, error: e.message }
154
+ end
155
+ private_class_method :info_for
156
+
157
+ def self.build_manifest(name:, origins:, flow:, flow_version:, cookies:, encrypted:) # rubocop:disable Metrics/ParameterLists
158
+ {
159
+ name: name,
160
+ version: MANIFEST_VERSION,
161
+ producer: "browserctl/#{Browserctl::VERSION}",
162
+ created_at: Time.now.utc.iso8601,
163
+ origins: Array(origins).compact.uniq,
164
+ flow: flow,
165
+ flow_version: flow_version,
166
+ expires_at: earliest_expiry(cookies),
167
+ encrypted: encrypted
168
+ }
169
+ end
170
+ private_class_method :build_manifest
171
+
172
+ # Origins captured from the payload itself when not overridden. Pulls from
173
+ # cookie domains and storage keys to cover both navigation-tracked and
174
+ # cookie-only auth.
175
+ def self.derive_origins(payload)
176
+ cookies = fetch_either(payload, :cookies, "cookies", default: [])
177
+ ls = fetch_either(payload, :local_storage, "local_storage", default: {})
178
+ ss = fetch_either(payload, :session_storage, "session_storage", default: {})
179
+
180
+ (origins_from_cookies(cookies) + ls.keys.map(&:to_s) + ss.keys.map(&:to_s)).uniq
181
+ end
182
+ private_class_method :derive_origins
183
+
184
+ def self.origins_from_cookies(cookies)
185
+ cookies.filter_map do |c|
186
+ domain = (c[:domain] || c["domain"]).to_s
187
+ next if domain.empty?
188
+
189
+ domain.start_with?(".") ? "https://#{domain[1..]}" : "https://#{domain}"
190
+ end
191
+ end
192
+ private_class_method :origins_from_cookies
193
+
194
+ def self.fetch_either(hash, sym_key, str_key, default:)
195
+ hash[sym_key] || hash[str_key] || default
196
+ end
197
+ private_class_method :fetch_either
198
+
199
+ def self.earliest_expiry(cookies)
200
+ times = cookies.filter_map do |c|
201
+ v = c[:expires] || c["expires"] || c[:expiresAt] || c["expiresAt"]
202
+ v&.to_f&.positive? ? Time.at(v.to_f).utc.iso8601 : nil
203
+ end
204
+ times.min
205
+ end
206
+ private_class_method :earliest_expiry
207
+ end
208
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../constants"
5
+
6
+ module Browserctl
7
+ module Workflow
8
+ # Renders a `Browserctl.flow` definition that wraps a promoted workflow.
9
+ # The flow becomes a globally-registered, parameterised handle that runs
10
+ # the underlying workflow via `Runner#run_workflow`. Params are inferred
11
+ # from the workflow's `param_defs` so callers see the same surface area
12
+ # they would on the workflow itself.
13
+ #
14
+ # Wrapping (rather than translating step-by-step) keeps the workflow as
15
+ # the single source of truth: edits to the workflow file flow through
16
+ # to the wrapper without regeneration.
17
+ module FlowWrapper
18
+ module_function
19
+
20
+ def target_dir
21
+ File.join(Browserctl::BROWSERCTL_DIR, "flows")
22
+ end
23
+
24
+ def target_path(name)
25
+ File.join(target_dir, "#{name}.rb")
26
+ end
27
+
28
+ # @param defn [Browserctl::WorkflowDefinition]
29
+ # @return [String] Ruby source for a flow file
30
+ def render(defn)
31
+ params = defn.param_defs.values.map { |p| render_param(p) }.join("\n")
32
+ desc = defn.description || "Promoted from workflow '#{defn.name}'"
33
+ <<~RUBY
34
+ # frozen_string_literal: true
35
+
36
+ require "browserctl/flow"
37
+ require "browserctl/runner"
38
+
39
+ # Auto-generated flow wrapper for workflow '#{defn.name}'.
40
+ # Edit the underlying workflow file rather than this wrapper.
41
+ Browserctl.flow(#{defn.name.inspect}) do
42
+ version "1.0.0"
43
+ requires_browserctl "0.11.0"
44
+ desc #{desc.inspect}
45
+
46
+ #{params.gsub(/^/, ' ') unless params.empty?}
47
+
48
+ step("run workflow #{defn.name}") do
49
+ Browserctl::Runner.new.run_workflow(#{defn.name.inspect}, **params)
50
+ end
51
+ end
52
+ RUBY
53
+ end
54
+
55
+ # @param defn [Browserctl::WorkflowDefinition]
56
+ # @param overwrite [Boolean]
57
+ # @param dir [String, nil] override target dir (testing)
58
+ # @return [String] path written
59
+ def write(defn, overwrite: true, dir: nil)
60
+ path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
61
+ if File.exist?(path) && !overwrite
62
+ raise Browserctl::WorkflowError, "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
63
+ end
64
+
65
+ FileUtils.mkdir_p(File.dirname(path))
66
+ File.write(path, render(defn))
67
+ path
68
+ end
69
+
70
+ def render_param(param)
71
+ opts = []
72
+ opts << "required: true" if param.required
73
+ opts << "secret: true" if param.secret && !param.secret_ref
74
+ opts << "secret_ref: #{param.secret_ref.inspect}" if param.secret_ref
75
+ opts << "default: #{param.default.inspect}" unless param.default.nil?
76
+ suffix = opts.empty? ? "" : ", #{opts.join(', ')}"
77
+ "param :#{param.name}#{suffix}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../constants"
5
+ require_relative "promotion_ledger"
6
+ require_relative "flow_wrapper"
7
+
8
+ module Browserctl
9
+ module Workflow
10
+ # Promotes a workflow file from the project-local `.browserctl/workflows/`
11
+ # directory to the user-global `~/.browserctl/workflows/` directory, where
12
+ # it is invocable from any project.
13
+ #
14
+ # Promotion is gated by `PromotionLedger.clean_streak`: a workflow must
15
+ # have at least `threshold` consecutive clean `--check` runs before it
16
+ # can be promoted. `--force` overrides the gate.
17
+ module Promoter
18
+ class IneligibleError < StandardError
19
+ attr_reader :streak, :threshold
20
+
21
+ def initialize(workflow:, streak:, threshold:)
22
+ @streak = streak
23
+ @threshold = threshold
24
+ super(
25
+ "workflow '#{workflow}' has #{streak} clean --check run(s); " \
26
+ "needs #{threshold}. Run `browserctl workflow run #{workflow} --check` " \
27
+ "until clean, or pass --force to override."
28
+ )
29
+ end
30
+ end
31
+
32
+ class NotFoundError < StandardError; end
33
+
34
+ module_function
35
+
36
+ DEFAULT_SOURCE_DIR = ".browserctl/workflows"
37
+
38
+ def target_dir
39
+ File.join(Browserctl::BROWSERCTL_DIR, "workflows")
40
+ end
41
+
42
+ def source_path(workflow, source_dir: DEFAULT_SOURCE_DIR)
43
+ File.join(source_dir, "#{workflow}.rb")
44
+ end
45
+
46
+ def target_path(workflow)
47
+ File.join(target_dir, "#{workflow}.rb")
48
+ end
49
+
50
+ # @param workflow [String]
51
+ # @param force [Boolean]
52
+ # @param threshold [Integer]
53
+ # @param source_dir [String] override the source directory (testing)
54
+ # @param ledger_path [String] override the ledger path (testing)
55
+ # @return [Hash] `{ workflow:, source:, target:, streak:, threshold:, forced: }`
56
+ def promote(workflow:, force: false, threshold: PromotionLedger::DEFAULT_THRESHOLD, # rubocop:disable Metrics/ParameterLists
57
+ as_flow: false, source_dir: DEFAULT_SOURCE_DIR,
58
+ ledger_path: PromotionLedger.ledger_path, flow_dir: nil)
59
+ src = source_path(workflow, source_dir: source_dir)
60
+ raise NotFoundError, "workflow file not found: #{src}" unless File.exist?(src)
61
+
62
+ streak = PromotionLedger.clean_streak(workflow: workflow, path: ledger_path)
63
+ unless force || streak >= threshold
64
+ raise IneligibleError.new(workflow: workflow, streak: streak, threshold: threshold)
65
+ end
66
+
67
+ dst = target_path(workflow)
68
+ FileUtils.mkdir_p(File.dirname(dst))
69
+ FileUtils.cp(src, dst)
70
+
71
+ result = {
72
+ workflow: workflow,
73
+ source: src,
74
+ target: dst,
75
+ streak: streak,
76
+ threshold: threshold,
77
+ forced: force && streak < threshold
78
+ }
79
+
80
+ result[:flow] = wrap_as_flow(workflow, dst, flow_dir) if as_flow
81
+ result
82
+ end
83
+
84
+ # Loads the just-promoted workflow file, infers params from its
85
+ # WorkflowDefinition, and writes a flow wrapper at `flow_dir`
86
+ # (defaults to `~/.browserctl/flows/<name>.rb`).
87
+ def wrap_as_flow(workflow, workflow_path, flow_dir)
88
+ load workflow_path
89
+ defn = Browserctl.lookup_workflow(workflow.to_s) or
90
+ raise NotFoundError, "workflow '#{workflow}' did not register after load"
91
+
92
+ FlowWrapper.write(defn, overwrite: true, dir: flow_dir)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "../constants"
7
+
8
+ module Browserctl
9
+ module Workflow
10
+ # Append-only JSONL ledger of `workflow run --check` outcomes per workflow.
11
+ # Used as the gate for `workflow promote`: only workflows with a sufficient
12
+ # streak of clean runs are eligible for promotion to `~/.browserctl/workflows/`.
13
+ #
14
+ # Record schema (one JSONL line):
15
+ # { "ts": "2026-05-10T12:00:00Z", "workflow": "name", "verdict": "clean" }
16
+ module PromotionLedger
17
+ LEDGER_BASENAME = "check_ledger.jsonl"
18
+ DEFAULT_THRESHOLD = 3
19
+ VALID_VERDICTS = %i[clean drift fail].freeze
20
+
21
+ module_function
22
+
23
+ def ledger_path
24
+ File.join(Browserctl::BROWSERCTL_DIR, LEDGER_BASENAME)
25
+ end
26
+
27
+ # Append a verdict for a workflow run.
28
+ # @param workflow [String]
29
+ # @param verdict [Symbol] :clean, :drift, or :fail
30
+ # @param path [String] override (testing)
31
+ def record(workflow:, verdict:, path: ledger_path, at: Time.now.utc)
32
+ return unless VALID_VERDICTS.include?(verdict)
33
+
34
+ FileUtils.mkdir_p(File.dirname(path))
35
+ File.open(path, "a") do |f|
36
+ f.puts JSON.generate(
37
+ ts: at.iso8601,
38
+ workflow: workflow.to_s,
39
+ verdict: verdict.to_s
40
+ )
41
+ end
42
+ end
43
+
44
+ # Count the trailing streak of :clean verdicts for a workflow.
45
+ # A non-clean verdict resets the streak. Drift and fail both break it
46
+ # — the gate is intentionally strict; users can override with --force.
47
+ # @return [Integer]
48
+ def clean_streak(workflow:, path: ledger_path)
49
+ return 0 unless File.exist?(path)
50
+
51
+ streak = 0
52
+ File.foreach(path) do |line|
53
+ entry = parse(line) or next
54
+ next unless entry["workflow"] == workflow.to_s
55
+
56
+ if entry["verdict"] == "clean"
57
+ streak += 1
58
+ else
59
+ streak = 0
60
+ end
61
+ end
62
+ streak
63
+ end
64
+
65
+ def parse(line)
66
+ JSON.parse(line)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end