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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flow"
4
+
5
+ module Browserctl
6
+ # Discovers and loads flow files from project, user, and bundled stdlib
7
+ # locations. Project flows shadow user flows, which shadow stdlib flows.
8
+ #
9
+ # Files are plain Ruby; loading them is expected to invoke
10
+ # `Browserctl.flow(name) { ... }`, which registers the flow in the global
11
+ # registry. Filename should match the registered name (validated lazily —
12
+ # mismatches are allowed but discouraged).
13
+ class FlowRegistry
14
+ SAFE_NAME = /\A[a-zA-Z0-9_-]+\z/
15
+
16
+ # Search order: highest-precedence last so later registrations
17
+ # overwrite earlier ones in the global registry.
18
+ def self.bundled_dir = File.expand_path("flows/stdlib", __dir__)
19
+ def self.user_dir = File.expand_path("~/.browserctl/flows")
20
+ def self.project_dir = "./.browserctl/flows"
21
+
22
+ def self.search_paths
23
+ [bundled_dir, user_dir, project_dir]
24
+ end
25
+
26
+ # Loads every flow file from every search path. Lower-precedence dirs
27
+ # run first; project files load last and win on name collisions.
28
+ def self.load_all
29
+ search_paths.each do |dir|
30
+ next unless Dir.exist?(dir)
31
+
32
+ Dir.glob(File.join(dir, "*.rb")).each { |f| load f }
33
+ end
34
+ Browserctl.flow_registry_snapshot
35
+ end
36
+
37
+ # Resolves a name to a registered flow, loading from disk on demand.
38
+ # Searches in precedence order: project → user → stdlib. The first
39
+ # matching file is loaded and the flow returned via the global registry.
40
+ def self.resolve(name)
41
+ validate_name!(name)
42
+ existing = Browserctl.lookup_flow(name)
43
+ return existing if existing
44
+
45
+ search_paths.reverse_each do |dir|
46
+ candidate = File.join(dir, "#{name}.rb")
47
+ next unless File.exist?(candidate)
48
+
49
+ load candidate
50
+ flow = Browserctl.lookup_flow(name)
51
+ return flow if flow
52
+ end
53
+ nil
54
+ end
55
+
56
+ def self.list
57
+ load_all.map { |n, f| { name: n, desc: f.description, version: f.version_string } }
58
+ end
59
+
60
+ def self.validate_name!(name)
61
+ return if SAFE_NAME.match?(name.to_s)
62
+
63
+ raise ArgumentError, "invalid flow name: #{name.inspect} — use letters, digits, _ and - only"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../../flow"
5
+
6
+ # Authenticates with an HTTP Basic Auth-protected URL by navigating to it
7
+ # with credentials embedded in the URL. This avoids the native auth
8
+ # dialog entirely; Chromium ingests the userinfo and supplies it on the
9
+ # request without prompting.
10
+ #
11
+ # Use for sites where the auth challenge is real HTTP Basic. For form-
12
+ # based "username + password" logins, use a workflow with `page.fill`.
13
+ Browserctl.flow("basic_auth") do
14
+ version "1.0.0"
15
+ requires_browserctl "0.11.0"
16
+ desc "Navigate to an HTTP Basic Auth URL using credentials embedded in the URL."
17
+
18
+ param :url, required: true
19
+ param :username, required: true
20
+ param :password, required: true, secret: true
21
+
22
+ precondition("page proxy is present") { !page.nil? }
23
+
24
+ step("navigate with embedded credentials") do
25
+ parsed = URI.parse(url)
26
+ parsed.user = URI.encode_www_form_component(username)
27
+ parsed.password = URI.encode_www_form_component(password)
28
+ page.navigate(parsed.to_s)
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../detectors"
4
+ require_relative "../../flow"
5
+
6
+ # Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
7
+ # moment...", interactive checkbox), then verifies the challenge cleared
8
+ # before returning. Optionally saves the post-solve session under a name
9
+ # you can reload later with `state load` or `session_load`.
10
+ #
11
+ # Reuses Browserctl::Detectors.cloudflare? — the server-side detector
12
+ # already shipped in v0.8 — by adapting the client-facing PageProxy to
13
+ # the duck-typed (current_url, body) interface the detector expects.
14
+ module Browserctl
15
+ module Flows
16
+ PageDetectorAdapter = Struct.new(:current_url, :body)
17
+
18
+ module CloudflareSolve
19
+ module_function
20
+
21
+ def detect?(page_proxy)
22
+ body = page_proxy.evaluate("document.body && document.body.innerText || ''").to_s
23
+ adapter = PageDetectorAdapter.new(page_proxy.url, body)
24
+ Browserctl::Detectors.cloudflare?(adapter)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ Browserctl.flow("cloudflare_solve") do
31
+ version "1.0.0"
32
+ requires_browserctl "0.11.0"
33
+ desc "Pause for a human to solve a Cloudflare challenge; verify it cleared; optionally capture state."
34
+
35
+ param :prompt,
36
+ default: "Cloudflare challenge detected. Solve it in the browser, then press Enter to continue."
37
+ param :state_name # optional — if set, session_save is called after the challenge clears
38
+
39
+ precondition("page proxy is present") { !page.nil? }
40
+ precondition("cloudflare challenge is present") do
41
+ Browserctl::Flows::CloudflareSolve.detect?(page)
42
+ end
43
+
44
+ step("wait for human signal") do
45
+ warn "[browserctl] #{prompt}"
46
+ line = $stdin.gets
47
+ raise "stdin closed before user signaled" if line.nil?
48
+ end
49
+
50
+ step("verify challenge cleared") do
51
+ raise "cloudflare challenge still detected after human signal" if Browserctl::Flows::CloudflareSolve.detect?(page)
52
+ end
53
+
54
+ produces_state do
55
+ next nil unless state_name && client
56
+
57
+ client.session_save(state_name)
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # HITL flow for magic-link login: pauses, asks the human to paste the
6
+ # link they received via email, then navigates the page to it.
7
+ #
8
+ # Does NOT read the email mailbox; that would require provider-specific
9
+ # IMAP/Gmail integration which belongs in a vendor flow, not stdlib.
10
+ Browserctl.flow("magic_link_email") do
11
+ version "1.0.0"
12
+ requires_browserctl "0.11.0"
13
+ desc "Wait for the human to paste a magic link from their email, then navigate to it."
14
+
15
+ param :prompt, default: "Paste the magic link from your email:"
16
+
17
+ precondition("page proxy is present") { !page.nil? }
18
+
19
+ step("prompt and navigate") do
20
+ $stderr.print("[browserctl] #{prompt} ")
21
+ link = $stdin.gets&.strip
22
+ raise "no magic link provided" if link.nil? || link.empty?
23
+
24
+ raise "magic link must start with http:// or https:// (got #{link.inspect})" unless link.match?(%r{\Ahttps?://}i)
25
+
26
+ page.navigate(link)
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # Clicks the "Authorize <app>" button on a GitHub OAuth consent screen.
6
+ #
7
+ # Assumes the user is already signed in to GitHub and the page is parked
8
+ # on the consent URL — this flow does not handle the credential entry
9
+ # step. Use a separate workflow or flow to land on the consent page first.
10
+ Browserctl.flow("oauth_github") do
11
+ version "1.0.0"
12
+ requires_browserctl "0.11.0"
13
+ desc "Click the Authorize button on a GitHub OAuth consent screen."
14
+
15
+ # The default selector targets the green Authorize submit button on
16
+ # github.com/login/oauth/authorize. GitHub keeps name="authorize" stable
17
+ # across UI revisions; override only if you're testing against a forked
18
+ # GitHub Enterprise instance with a customised template.
19
+ param :authorize_selector, default: 'button[name="authorize"][value="1"]'
20
+
21
+ precondition("on a github oauth consent page") do
22
+ page.url.include?("/login/oauth/authorize")
23
+ end
24
+
25
+ step("click authorize") do
26
+ page.click(authorize_selector)
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../flow"
4
+
5
+ # Clicks the Continue / Allow button on a Google OAuth consent screen.
6
+ #
7
+ # Assumes the user is already signed in to Google and the page is parked
8
+ # on accounts.google.com showing the consent prompt. This flow does not
9
+ # pick an account from the chooser, enter a password, or solve 2FA —
10
+ # compose those before calling this flow.
11
+ #
12
+ # Google rotates consent UI more often than GitHub, so the default
13
+ # selector is a best-effort match against the modern Material 3 button.
14
+ # Override if your account or app version sees a different layout.
15
+ Browserctl.flow("oauth_google") do
16
+ version "1.0.0"
17
+ requires_browserctl "0.11.0"
18
+ desc "Click the Continue/Allow button on a Google OAuth consent screen."
19
+
20
+ param :continue_selector, default: 'button[jsname="LgbsSe"]'
21
+
22
+ precondition("on a google oauth consent page") do
23
+ url = page.url
24
+ url.include?("accounts.google.com") && (url.include?("/oauth") || url.include?("/signin/oauth"))
25
+ end
26
+
27
+ step("click continue") do
28
+ page.click(continue_selector)
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "../../flow"
5
+
6
+ module Browserctl
7
+ module Flows
8
+ # RFC 6238 TOTP code generation from a base32 secret.
9
+ # Pure Ruby; no network and no external gem.
10
+ module TOTP
11
+ module_function
12
+
13
+ def generate(secret, at: Time.now, digits: 6, period: 30, digest: "SHA1")
14
+ counter = (at.to_i / period).to_i
15
+ key = decode_base32(secret)
16
+ counter_b = [counter].pack("Q>") # 64-bit big-endian
17
+ hmac = OpenSSL::HMAC.digest(digest, key, counter_b)
18
+ offset = hmac[-1].ord & 0x0f
19
+ truncated = hmac[offset, 4].unpack1("N") & 0x7fffffff
20
+ truncated.to_s.rjust(digits, "0")[-digits..]
21
+ end
22
+
23
+ BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
24
+
25
+ def decode_base32(secret)
26
+ cleaned = secret.to_s.upcase.gsub(/[^A-Z2-7]/, "")
27
+ bits = cleaned.each_char.map { |c| char_to_bits(c) }.join
28
+ whole_bytes = bits[0, (bits.length / 8) * 8]
29
+ whole_bytes.scan(/.{8}/).map { |b| b.to_i(2).chr }.join
30
+ end
31
+
32
+ def char_to_bits(char)
33
+ idx = BASE32_ALPHABET.index(char) or
34
+ raise ArgumentError, "invalid base32 char #{char.inspect}"
35
+ idx.to_s(2).rjust(5, "0")
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ Browserctl.flow("totp_2fa") do
42
+ version "1.0.0"
43
+ requires_browserctl "0.11.0"
44
+ desc "Generate an RFC 6238 TOTP code from a base32 secret and type it into the page."
45
+
46
+ param :secret, required: true, secret: true
47
+ param :selector, required: true
48
+ param :digits, default: 6
49
+ param :period, default: 30
50
+
51
+ precondition("page proxy is present") { !page.nil? }
52
+
53
+ step("compute and fill code") do
54
+ code = Browserctl::Flows::TOTP.generate(
55
+ secret,
56
+ digits: digits.to_i,
57
+ period: period.to_i
58
+ )
59
+ page.fill(selector, code)
60
+ end
61
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Browserctl
6
+ # Format-version header convention.
7
+ #
8
+ # Every persisted browserctl artifact declares its format version on the very
9
+ # first line as `version: <int>`. This module is the convention's single
10
+ # source of truth; per-format adoption (bundle, recording, workflow) lands in
11
+ # later WS-1 PRs. See `docs/reference/format-versions.md`.
12
+ module FormatVersion
13
+ HEADER_RE = /\Aversion:\s*(\d+)\s*\z/
14
+
15
+ module_function
16
+
17
+ # Parse the version header from an IO or String. Returns the Integer
18
+ # version. Raises Browserctl::ProtocolMismatch if the header is missing or
19
+ # malformed.
20
+ def parse(io_or_string)
21
+ first_line = io_or_string.respond_to?(:gets) ? io_or_string.gets : io_or_string.to_s.each_line.first
22
+ raise ProtocolMismatch, "missing version header" if first_line.nil?
23
+
24
+ match = HEADER_RE.match(first_line.chomp)
25
+ raise ProtocolMismatch, "malformed version header: #{first_line.inspect}" unless match
26
+
27
+ Integer(match[1])
28
+ end
29
+
30
+ # Returns the canonical header string for a given integer version.
31
+ def stamp(version:)
32
+ raise ArgumentError, "version must be a non-negative Integer" unless version.is_a?(Integer) && version >= 0
33
+
34
+ "version: #{version}\n"
35
+ end
36
+ end
37
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "logger"
4
4
  require "fileutils"
5
+ require "json"
6
+ require "time"
5
7
 
6
8
  module Browserctl
7
9
  LEVEL_MAP = {
@@ -11,6 +13,16 @@ module Browserctl
11
13
  "error" => ::Logger::ERROR
12
14
  }.freeze
13
15
 
16
+ # JSONL rotation policy. Stdlib `Logger` rotates by size when given an
17
+ # integer `shift_age` and `shift_size`.
18
+ LOG_SHIFT_AGE = 10 # keep last 10 rotated files
19
+ LOG_SHIFT_SIZE = 10 * 1024 * 1024 # rotate at 10MB
20
+
21
+ # Resolved at call time so tests can override BROWSERCTL_DIR via stub_const.
22
+ def self.log_dir
23
+ File.join(BROWSERCTL_DIR, "logs")
24
+ end
25
+
14
26
  class MultiLogger
15
27
  def initialize(*loggers)
16
28
  @loggers = loggers
@@ -30,6 +42,37 @@ module Browserctl
30
42
  end
31
43
  end
32
44
 
45
+ # Formats every log line as a single JSON object: {ts, level, component, msg, ...}.
46
+ # If the message is a Hash, its keys are merged so callers can attach
47
+ # structured context, e.g. `logger.info(event: "x", session: id)`.
48
+ class JsonlFormatter
49
+ def initialize(component:)
50
+ @component = component
51
+ end
52
+
53
+ def call(severity, time, _progname, msg)
54
+ record = {
55
+ ts: time.utc.iso8601(3),
56
+ level: severity,
57
+ component: @component
58
+ }
59
+
60
+ case msg
61
+ when Hash
62
+ explicit = msg[:msg] || msg["msg"]
63
+ record[:msg] = explicit if explicit
64
+ record.merge!(msg.reject { |k, _| k.to_s == "msg" })
65
+ when Exception
66
+ record[:msg] = "#{msg.class}: #{msg.message}"
67
+ record[:backtrace] = Array(msg.backtrace).first(10)
68
+ else
69
+ record[:msg] = msg.to_s
70
+ end
71
+
72
+ "#{JSON.generate(record)}\n"
73
+ end
74
+ end
75
+
33
76
  def self.logger
34
77
  @logger ||= build_logger("info")
35
78
  end
@@ -38,19 +81,69 @@ module Browserctl
38
81
  @logger = instance
39
82
  end
40
83
 
41
- def self.build_logger(level_name, log_path: nil)
84
+ # Build a logger that writes:
85
+ # - human-readable lines to stderr (unchanged behaviour)
86
+ # - human-readable lines to log_path: when given (the daemon tail file)
87
+ # - structured JSONL lines to ~/.browserctl/logs/<component>.log (rotating
88
+ # 10 files x 10MB) when jsonl: is true
89
+ #
90
+ # JSONL output is purely additive — existing stderr/stdout behaviour is
91
+ # preserved so scripted callers see no change.
92
+ def self.build_logger(level_name, log_path: nil, component: "daemon", jsonl: true)
42
93
  level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
43
- formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
94
+ text_formatter = proc do |sev, t, prog, msg|
95
+ "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{format_text_msg(msg)}\n"
96
+ end
97
+
98
+ loggers = [make_logger($stderr, level, text_formatter)]
44
99
 
45
- stderr_log = make_logger($stderr, level, formatter)
46
- return stderr_log unless log_path
100
+ if log_path
101
+ FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
102
+ FileUtils.touch(log_path)
103
+ File.chmod(0o600, log_path)
104
+ loggers << make_logger(log_path, level, text_formatter)
105
+ end
47
106
 
48
- FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
49
- FileUtils.touch(log_path)
50
- File.chmod(0o600, log_path)
51
- file_log = make_logger(log_path, level, formatter)
52
- MultiLogger.new(stderr_log, file_log)
107
+ if jsonl
108
+ jsonl_logger = build_jsonl_logger(level, component)
109
+ loggers << jsonl_logger if jsonl_logger
110
+ end
111
+
112
+ loggers.length == 1 ? loggers.first : MultiLogger.new(*loggers)
113
+ end
114
+
115
+ # Returns a stdlib Logger writing JSON-Lines records to
116
+ # ~/.browserctl/logs/<component>.log with size-based rotation. Returns nil
117
+ # (and stays silent) if the directory cannot be created so logging never
118
+ # crashes the daemon.
119
+ # LogDevice that suppresses stdlib's "# Logfile created on ..." header so
120
+ # the resulting file is pure JSON Lines.
121
+ class HeaderlessLogDevice < ::Logger::LogDevice
122
+ def add_log_header(_file); end
123
+ end
124
+
125
+ def self.build_jsonl_logger(level, component)
126
+ dir = log_dir
127
+ FileUtils.mkdir_p(dir, mode: 0o700)
128
+ path = File.join(dir, "#{component}.log")
129
+ device = HeaderlessLogDevice.new(path, shift_age: LOG_SHIFT_AGE, shift_size: LOG_SHIFT_SIZE)
130
+ log = ::Logger.new(device)
131
+ log.level = level
132
+ log.progname = component
133
+ log.formatter = JsonlFormatter.new(component: component)
134
+ log
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def self.format_text_msg(msg)
140
+ case msg
141
+ when Hash then (msg[:msg] || msg["msg"] || msg.inspect).to_s
142
+ when Exception then "#{msg.class}: #{msg.message}"
143
+ else msg.to_s
144
+ end
53
145
  end
146
+ private_class_method :format_text_msg
54
147
 
55
148
  def self.make_logger(device, level, formatter)
56
149
  log = ::Logger.new(device)