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.
@@ -21,7 +21,11 @@ module Browserctl
21
21
  def cmd_fetch(req)
22
22
  key = req[:key].to_s
23
23
  found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
24
- found || { error: "key '#{key}' not found", code: "key_not_found" }
24
+ found || error_payload(
25
+ code: Browserctl::Error::Codes::KEY_NOT_FOUND,
26
+ message: "key '#{key}' not found",
27
+ context: { key: key }
28
+ )
25
29
  end
26
30
  end
27
31
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ # Centralised structured-error builder for daemon JSON-RPC responses.
9
+ # Each handler returns `{ error:, code:, context:, suggested_action: }`
10
+ # for any failure carrying a stable {Browserctl::Error::Codes} code.
11
+ module ErrorPayload
12
+ # @param code [String] a SCREAMING_SNAKE code from {Codes}
13
+ # @param message [String] human-readable error
14
+ # @param context [Hash] free-form structured fields (selector, path, ...)
15
+ # @return [Hash{Symbol => Object}]
16
+ def error_payload(code:, message:, context: {})
17
+ {
18
+ error: message,
19
+ code: code,
20
+ context: context,
21
+ suggested_action: Browserctl::Error::SuggestedActions.for(code)
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -27,7 +27,13 @@ module Browserctl
27
27
  "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
28
28
  "})(#{sel.to_json})"
29
29
  )
30
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless coords
30
+ unless coords
31
+ return error_payload(
32
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
33
+ message: "selector not found: #{sel}",
34
+ context: { selector: sel }
35
+ )
36
+ end
31
37
 
32
38
  session.page.mouse.move(x: coords["x"], y: coords["y"])
33
39
  { ok: true }
@@ -43,7 +49,13 @@ module Browserctl
43
49
  return sel if sel.is_a?(Hash)
44
50
 
45
51
  el = session.page.at_css(sel)
46
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless el
52
+ unless el
53
+ return error_payload(
54
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
55
+ message: "selector not found: #{sel}",
56
+ context: { selector: sel }
57
+ )
58
+ end
47
59
 
48
60
  el.select_file(path)
49
61
  { ok: true }
@@ -56,7 +68,13 @@ module Browserctl
56
68
  return sel if sel.is_a?(Hash)
57
69
 
58
70
  el = session.page.at_css(sel)
59
- return { error: "selector not found: #{sel}", code: "selector_not_found" } unless el
71
+ unless el
72
+ return error_payload(
73
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
74
+ message: "selector not found: #{sel}",
75
+ context: { selector: sel }
76
+ )
77
+ end
60
78
 
61
79
  el.evaluate(
62
80
  "this.value = #{req[:value].to_json}; " \
@@ -8,7 +8,11 @@ module Browserctl
8
8
 
9
9
  def cmd_navigate(req)
10
10
  unless Policy.allowed_navigation?(req[:url].to_s)
11
- return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
11
+ return error_payload(
12
+ code: Browserctl::Error::Codes::DOMAIN_NOT_ALLOWED,
13
+ message: "navigation to '#{req[:url]}' blocked by domain policy",
14
+ context: { url: req[:url].to_s }
15
+ )
12
16
  end
13
17
 
14
18
  with_page(req[:name]) do |session|
@@ -81,7 +85,13 @@ module Browserctl
81
85
 
82
86
  def type_into(page, selector, value)
83
87
  el = page.at_css(selector)
84
- return { error: "selector not found: #{selector}", code: "selector_not_found" } unless el
88
+ unless el
89
+ return error_payload(
90
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
91
+ message: "selector not found: #{selector}",
92
+ context: { selector: selector }
93
+ )
94
+ end
85
95
 
86
96
  el.focus
87
97
  el.evaluate("this.select()")
@@ -91,7 +101,13 @@ module Browserctl
91
101
 
92
102
  def click_element(page, selector)
93
103
  el = page.at_css(selector)
94
- return { error: "selector not found: #{selector}", code: "selector_not_found" } unless el
104
+ unless el
105
+ return error_payload(
106
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
107
+ message: "selector not found: #{selector}",
108
+ context: { selector: selector }
109
+ )
110
+ end
95
111
 
96
112
  # Use the DOM native click() so JS-only event listeners fire.
97
113
  # CDP mouse simulation (el.click) dispatches events at screen coordinates
@@ -71,7 +71,7 @@ module Browserctl
71
71
  def self.load(session_name)
72
72
  validate_name!(session_name)
73
73
  dir = path(session_name)
74
- raise "session '#{session_name}' not found" unless Dir.exist?(dir)
74
+ raise Browserctl::Error, "session '#{session_name}' not found" unless Dir.exist?(dir)
75
75
 
76
76
  meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
77
77
 
@@ -4,6 +4,7 @@ require "json"
4
4
  require "openssl"
5
5
  require "securerandom"
6
6
  require_relative "../errors"
7
+ require_relative "../error/codes"
7
8
 
8
9
  module Browserctl
9
10
  module State
@@ -42,6 +43,12 @@ module Browserctl
42
43
  class Bundle
43
44
  MAGIC = "BCTL\x00".b.freeze
44
45
  VERSION = 1
46
+ # Manifest-level format version, written as `format_version` and
47
+ # validated on decode. Distinct from the wire-format byte `VERSION`
48
+ # above (which gates the binary envelope shape) — this gates the
49
+ # manifest schema. See docs/reference/format-versions.md.
50
+ BUNDLE_FORMAT_VERSION = 1
51
+ SUPPORTED_FORMAT_VERSIONS = [BUNDLE_FORMAT_VERSION].freeze
45
52
  FLAG_ENCRYPTED = 0x01
46
53
  HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
47
54
  LEN_SIZE = 4
@@ -63,6 +70,7 @@ module Browserctl
63
70
  # the footer is an HMAC. When nil, payload is plaintext and the
64
71
  # footer is a SHA-256 digest.
65
72
  def self.encode(manifest:, payload:, passphrase: nil)
73
+ manifest = stamp_format_version(manifest)
66
74
  manifest_bytes = JSON.generate(manifest).b
67
75
  payload_json = JSON.generate(payload).b
68
76
  flags = 0
@@ -96,7 +104,8 @@ module Browserctl
96
104
  verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
97
105
 
98
106
  manifest = JSON.parse(manifest_bytes, symbolize_names: true)
99
- payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
107
+ verify_format_version!(manifest)
108
+ payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
100
109
 
101
110
  { manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
102
111
  end
@@ -108,8 +117,40 @@ module Browserctl
108
117
  raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
109
118
 
110
119
  manifest_bytes, = read_sections!(blob)
111
- JSON.parse(manifest_bytes, symbolize_names: true)
120
+ manifest = JSON.parse(manifest_bytes, symbolize_names: true)
121
+ verify_format_version!(manifest)
122
+ manifest
123
+ end
124
+
125
+ # Returns the manifest with `format_version` set as the first key. When
126
+ # the caller already provided a value we keep it (so encoders can stamp
127
+ # a future version explicitly); otherwise we stamp the current one.
128
+ def self.stamp_format_version(manifest)
129
+ existing = manifest[:format_version] || manifest["format_version"]
130
+ version = existing || BUNDLE_FORMAT_VERSION
131
+ rest = manifest.except(:format_version, "format_version")
132
+ { format_version: version }.merge(rest)
133
+ end
134
+ private_class_method :stamp_format_version
135
+
136
+ # Raises Browserctl::ProtocolMismatch when the manifest declares no
137
+ # format_version or one this build does not support. The error carries
138
+ # the canonical PROTOCOL_MISMATCH code from the v0.12 error taxonomy.
139
+ def self.verify_format_version!(manifest, path: nil)
140
+ version = manifest[:format_version] || manifest["format_version"]
141
+ return if version && SUPPORTED_FORMAT_VERSIONS.include?(version)
142
+
143
+ where = path ? " at #{path}" : ""
144
+ msg = if version.nil?
145
+ "bundle manifest#{where} is missing format_version " \
146
+ "(supported: #{SUPPORTED_FORMAT_VERSIONS.inspect})"
147
+ else
148
+ "bundle manifest#{where} declares format_version=#{version.inspect}, " \
149
+ "this build supports #{SUPPORTED_FORMAT_VERSIONS.inspect}"
150
+ end
151
+ raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
112
152
  end
153
+ private_class_method :verify_format_version!
113
154
 
114
155
  def self.build_body(flags, manifest_bytes, payload_bytes)
115
156
  header = MAGIC + [VERSION, flags, 0].pack("CCC")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -59,7 +59,7 @@ module Browserctl
59
59
  def write(defn, overwrite: true, dir: nil)
60
60
  path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
61
61
  if File.exist?(path) && !overwrite
62
- raise "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
62
+ raise Browserctl::WorkflowError, "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
63
63
  end
64
64
 
65
65
  FileUtils.mkdir_p(File.dirname(path))
@@ -11,6 +11,61 @@ require_relative "secret_resolvers"
11
11
  require_relative "session"
12
12
 
13
13
  module Browserctl
14
+ # Workflow-file format version. Workflows are Ruby files; the schema gate
15
+ # is a top-of-file comment header:
16
+ #
17
+ # # format_version: 1
18
+ #
19
+ # Unlike bundles and recordings, an unsupported or missing version on a
20
+ # workflow file is a *warning*, not a hard failure. Workflows are
21
+ # human-authored Ruby — the loader prefers to surface drift via stderr
22
+ # and let the file run, rather than block execution. See
23
+ # docs/reference/format-versions.md.
24
+ WORKFLOW_FORMAT_VERSION = 1
25
+ SUPPORTED_WORKFLOW_FORMAT_VERSIONS = [WORKFLOW_FORMAT_VERSION].freeze
26
+
27
+ # Matches a leading-line comment of the form `# format_version: <int>`.
28
+ # Tolerates leading whitespace inside the comment body and ignores the
29
+ # `# frozen_string_literal: true` magic comment that conventionally
30
+ # precedes it.
31
+ WORKFLOW_FORMAT_VERSION_HEADER = /^\s*#\s*format_version:\s*(\d+)\s*$/
32
+
33
+ # Parses the `# format_version: N` header from a workflow file's source.
34
+ # Scans only the contiguous leading comment block (and blank lines) so
35
+ # the header cannot be smuggled in mid-file. Returns the integer if
36
+ # present, or nil if the file has no version header.
37
+ def self.parse_workflow_format_version(source)
38
+ source.each_line do |line|
39
+ stripped = line.strip
40
+ next if stripped.empty?
41
+ break unless stripped.start_with?("#")
42
+
43
+ if (m = line.match(WORKFLOW_FORMAT_VERSION_HEADER))
44
+ return Integer(m[1])
45
+ end
46
+ end
47
+ nil
48
+ end
49
+
50
+ # Reads a workflow file and warns to stderr when the `format_version:`
51
+ # header is missing or declares an unsupported version. Always returns
52
+ # the parsed integer (or nil) — never raises. Callers should still
53
+ # `load` the file regardless.
54
+ def self.verify_workflow_format_version!(path)
55
+ source = File.read(path)
56
+ version = parse_workflow_format_version(source)
57
+
58
+ if version.nil?
59
+ warn "[browserctl] workflow #{path} is missing a `# format_version: N` header " \
60
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
61
+ elsif !SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
62
+ warn "[browserctl] workflow #{path} format_version=#{version} is not supported " \
63
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
64
+ end
65
+
66
+ version
67
+ end
68
+
14
69
  ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
15
70
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
16
71
  StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
@@ -391,7 +446,7 @@ module Browserctl
391
446
  end
392
447
 
393
448
  def selector_not_found?(res)
394
- res.is_a?(Hash) && res[:code] == "selector_not_found"
449
+ res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
395
450
  end
396
451
 
397
452
  def log_rematch(cmd, selector, match)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: ferrum
@@ -127,8 +126,8 @@ description: Named browser sessions, Ruby workflow DSL, and a token-efficient DO
127
126
  email:
128
127
  - patrick204nqh@gmail.com
129
128
  executables:
130
- - browserd
131
129
  - browserctl
130
+ - browserd
132
131
  extensions: []
133
132
  extra_rdoc_files: []
134
133
  files:
@@ -176,6 +175,7 @@ files:
176
175
  - lib/browserctl/commands/fill.rb
177
176
  - lib/browserctl/commands/flow.rb
178
177
  - lib/browserctl/commands/init.rb
178
+ - lib/browserctl/commands/migrate.rb
179
179
  - lib/browserctl/commands/page.rb
180
180
  - lib/browserctl/commands/record.rb
181
181
  - lib/browserctl/commands/resume.rb
@@ -184,14 +184,19 @@ files:
184
184
  - lib/browserctl/commands/snapshot.rb
185
185
  - lib/browserctl/commands/state.rb
186
186
  - lib/browserctl/commands/storage.rb
187
+ - lib/browserctl/commands/trace.rb
187
188
  - lib/browserctl/commands/workflow.rb
188
189
  - lib/browserctl/constants.rb
190
+ - lib/browserctl/crash_report.rb
189
191
  - lib/browserctl/detectors.rb
190
192
  - lib/browserctl/detectors/auth_required.rb
191
193
  - lib/browserctl/driver.rb
192
194
  - lib/browserctl/driver/base.rb
193
195
  - lib/browserctl/driver/cdp.rb
194
196
  - lib/browserctl/driver/cdp_page.rb
197
+ - lib/browserctl/error/codes.rb
198
+ - lib/browserctl/error/exit_codes.rb
199
+ - lib/browserctl/error/suggested_actions.rb
195
200
  - lib/browserctl/errors.rb
196
201
  - lib/browserctl/flow.rb
197
202
  - lib/browserctl/flow_registry.rb
@@ -201,13 +206,17 @@ files:
201
206
  - lib/browserctl/flows/stdlib/oauth_github.rb
202
207
  - lib/browserctl/flows/stdlib/oauth_google.rb
203
208
  - lib/browserctl/flows/stdlib/totp_2fa.rb
209
+ - lib/browserctl/format_version.rb
204
210
  - lib/browserctl/logger.rb
211
+ - lib/browserctl/migrations.rb
205
212
  - lib/browserctl/policy.rb
206
213
  - lib/browserctl/recording.rb
214
+ - lib/browserctl/redactor.rb
207
215
  - lib/browserctl/replay/context.rb
208
216
  - lib/browserctl/replay/fingerprint_matcher.rb
209
217
  - lib/browserctl/replay/snapshot_diff.rb
210
218
  - lib/browserctl/replay/telemetry.rb
219
+ - lib/browserctl/rubocop/cops/typed_error.rb
211
220
  - lib/browserctl/runner.rb
212
221
  - lib/browserctl/secret_resolver_registry.rb
213
222
  - lib/browserctl/secret_resolvers.rb
@@ -220,6 +229,7 @@ files:
220
229
  - lib/browserctl/server/handlers/cookies.rb
221
230
  - lib/browserctl/server/handlers/daemon_control.rb
222
231
  - lib/browserctl/server/handlers/devtools.rb
232
+ - lib/browserctl/server/handlers/error_payload.rb
223
233
  - lib/browserctl/server/handlers/hitl.rb
224
234
  - lib/browserctl/server/handlers/interaction.rb
225
235
  - lib/browserctl/server/handlers/navigation.rb
@@ -258,7 +268,6 @@ metadata:
258
268
  bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
259
269
  documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
260
270
  rubygems_mfa_required: 'true'
261
- post_install_message:
262
271
  rdoc_options: []
263
272
  require_paths:
264
273
  - lib
@@ -273,8 +282,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
273
282
  - !ruby/object:Gem::Version
274
283
  version: '0'
275
284
  requirements: []
276
- rubygems_version: 3.5.22
277
- signing_key:
285
+ rubygems_version: 3.6.9
278
286
  specification_version: 4
279
287
  summary: Persistent browser automation daemon and CLI for AI agents and developer
280
288
  workflows