Module: Browserctl

Defined in:
lib/browserctl/flows/stdlib/cloudflare_solve.rb,
lib/browserctl.rb,
lib/browserctl/flow.rb,
lib/browserctl/state.rb,
lib/browserctl/client.rb,
lib/browserctl/errors.rb,
lib/browserctl/logger.rb,
lib/browserctl/policy.rb,
lib/browserctl/runner.rb,
lib/browserctl/server.rb,
lib/browserctl/session.rb,
lib/browserctl/version.rb,
lib/browserctl/redactor.rb,
lib/browserctl/workflow.rb,
lib/browserctl/constants.rb,
lib/browserctl/detectors.rb,
lib/browserctl/recording.rb,
lib/browserctl/driver/cdp.rb,
lib/browserctl/migrations.rb,
lib/browserctl/driver/base.rb,
lib/browserctl/error/codes.rb,
lib/browserctl/commands/ask.rb,
lib/browserctl/crash_report.rb,
lib/browserctl/snapshot/ref.rb,
lib/browserctl/state/bundle.rb,
lib/browserctl/commands/fill.rb,
lib/browserctl/commands/flow.rb,
lib/browserctl/commands/init.rb,
lib/browserctl/commands/page.rb,
lib/browserctl/flow_registry.rb,
lib/browserctl/commands/click.rb,
lib/browserctl/commands/state.rb,
lib/browserctl/commands/trace.rb,
lib/browserctl/format_version.rb,
lib/browserctl/replay/context.rb,
lib/browserctl/commands/cookie.rb,
lib/browserctl/commands/daemon.rb,
lib/browserctl/commands/dialog.rb,
lib/browserctl/commands/record.rb,
lib/browserctl/commands/resume.rb,
lib/browserctl/driver/cdp_page.rb,
lib/browserctl/state/transport.rb,
lib/browserctl/commands/migrate.rb,
lib/browserctl/commands/session.rb,
lib/browserctl/commands/storage.rb,
lib/browserctl/error/exit_codes.rb,
lib/browserctl/replay/telemetry.rb,
lib/browserctl/commands/snapshot.rb,
lib/browserctl/commands/workflow.rb,
lib/browserctl/workflow/promoter.rb,
lib/browserctl/snapshot/annotator.rb,
lib/browserctl/snapshot/extractor.rb,
lib/browserctl/commands/cli_output.rb,
lib/browserctl/commands/screenshot.rb,
lib/browserctl/server/idle_watcher.rb,
lib/browserctl/server/page_session.rb,
lib/browserctl/snapshot/serializer.rb,
lib/browserctl/state/transports/s3.rb,
lib/browserctl/replay/snapshot_diff.rb,
lib/browserctl/secret_resolvers/env.rb,
lib/browserctl/server/handlers/hitl.rb,
lib/browserctl/snapshot/fingerprint.rb,
lib/browserctl/flows/stdlib/totp_2fa.rb,
lib/browserctl/secret_resolvers/base.rb,
lib/browserctl/server/handlers/state.rb,
lib/browserctl/state/transports/file.rb,
lib/browserctl/workflow/flow_wrapper.rb,
lib/browserctl/detectors/auth_required.rb,
lib/browserctl/error/suggested_actions.rb,
lib/browserctl/server/handlers/cookies.rb,
lib/browserctl/server/handlers/session.rb,
lib/browserctl/server/handlers/storage.rb,
lib/browserctl/server/snapshot_builder.rb,
lib/browserctl/secret_resolver_registry.rb,
lib/browserctl/server/handlers/devtools.rb,
lib/browserctl/server/command_dispatcher.rb,
lib/browserctl/workflow/promotion_ledger.rb,
lib/browserctl/replay/fingerprint_matcher.rb,
lib/browserctl/server/handlers/navigation.rb,
lib/browserctl/server/handlers/interaction.rb,
lib/browserctl/server/handlers/observation.rb,
lib/browserctl/secret_resolvers/one_password.rb,
lib/browserctl/server/handlers/error_payload.rb,
lib/browserctl/state/transports/one_password.rb,
lib/browserctl/server/handlers/daemon_control.rb,
lib/browserctl/server/handlers/page_lifecycle.rb,
lib/browserctl/secret_resolvers/macos_keychain.rb

Overview

Pauses for a human to solve a Cloudflare challenge (Turnstile, “Just a moment…”, interactive checkbox), then verifies the challenge cleared before returning. Optionally saves the post-solve session under a name you can reload later with ‘state load` or `session_load`.

Reuses Browserctl::Detectors.cloudflare? — the server-side detector already shipped in v0.8 — by adapting the client-facing PageProxy to the duck-typed (current_url, body) interface the detector expects.

Defined Under Namespace

Modules: Commands, CrashReport, Detectors, Driver, Flows, FormatVersion, Migrations, Policy, Replay, SecretResolvers, Snapshot, State, Workflow Classes: AuthRequiredError, BrowserNotFound, Client, CommandDispatcher, DaemonUnavailableError, DomainNotAllowed, Error, Flow, FlowConditionDef, FlowContext, FlowError, FlowParamDef, FlowParamError, FlowPostconditionError, FlowPreconditionError, FlowRegistry, FlowStepDef, FlowStepError, HeaderlessLogDevice, IdleWatcher, JsonlFormatter, KeyNotFound, MultiLogger, PageNotFound, PageProxy, PageSession, ParamDef, PathNotAllowed, ProtocolMismatch, Recording, Redactor, RefNotFound, Runner, SecretResolverError, SecretResolverRegistry, SelectorNotFound, Server, Session, SnapshotBuilder, StepDef, StepResult, TimeoutError, WorkflowContext, WorkflowDefinition, WorkflowError

Constant Summary collapse

LEVEL_MAP =
{
  "debug" => ::Logger::DEBUG,
  "info" => ::Logger::INFO,
  "warn" => ::Logger::WARN,
  "error" => ::Logger::ERROR
}.freeze
LOG_SHIFT_AGE =

JSONL rotation policy. Stdlib ‘Logger` rotates by size when given an integer `shift_age` and `shift_size`.

10
LOG_SHIFT_SIZE =

keep last 10 rotated files

10 * 1024 * 1024
VERSION =
"0.12.0"
WORKFLOW_FORMAT_VERSION =

Workflow-file format version. Workflows are Ruby files; the schema gate is a top-of-file comment header:

# format_version: 1

Unlike bundles and recordings, an unsupported or missing version on a workflow file is a warning, not a hard failure. Workflows are human-authored Ruby — the loader prefers to surface drift via stderr and let the file run, rather than block execution. See docs/reference/format-versions.md.

1
SUPPORTED_WORKFLOW_FORMAT_VERSIONS =
[WORKFLOW_FORMAT_VERSION].freeze
WORKFLOW_FORMAT_VERSION_HEADER =

Matches a leading-line comment of the form ‘# format_version: <int>`. Tolerates leading whitespace inside the comment body and ignores the `# frozen_string_literal: true` magic comment that conventionally precedes it.

/^\s*#\s*format_version:\s*(\d+)\s*$/
BROWSERCTL_DIR =
File.expand_path("~/.browserctl")
IDLE_TTL =
30 * 60
PROTOCOL_VERSION =

Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes). Clients read this from ‘ping` to verify compatibility before sending commands.

"3"
SOCKET_PATH =

Backward-compatible constants

socket_path
PID_PATH =
pid_path

Class Method Summary collapse

Class Method Details

.all_daemon_namesObject



38
39
40
41
# File 'lib/browserctl/constants.rb', line 38

def self.all_daemon_names
  all_daemon_sockets.map { |f| File.basename(f, ".sock") }
                    .map { |n| n == "browserd" ? nil : n }
end

.all_daemon_socketsObject



34
35
36
# File 'lib/browserctl/constants.rb', line 34

def self.all_daemon_sockets
  Dir[File.join(BROWSERCTL_DIR, "*.sock")]
end

.build_jsonl_logger(level, component) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/browserctl/logger.rb', line 125

def self.build_jsonl_logger(level, component)
  dir = log_dir
  FileUtils.mkdir_p(dir, mode: 0o700)
  path = File.join(dir, "#{component}.log")
  device = HeaderlessLogDevice.new(path, shift_age: LOG_SHIFT_AGE, shift_size: LOG_SHIFT_SIZE)
  log = ::Logger.new(device)
  log.level     = level
  log.progname  = component
  log.formatter = JsonlFormatter.new(component: component)
  log
rescue StandardError
  nil
end

.build_logger(level_name, log_path: nil, component: "daemon", jsonl: true) ⇒ Object

Build a logger that writes:

- human-readable lines to stderr (unchanged behaviour)
- human-readable lines to log_path: when given (the daemon tail file)
- structured JSONL lines to ~/.browserctl/logs/<component>.log (rotating
  10 files x 10MB) when jsonl: is true

JSONL output is purely additive — existing stderr/stdout behaviour is preserved so scripted callers see no change.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/browserctl/logger.rb', line 92

def self.build_logger(level_name, log_path: nil, component: "daemon", jsonl: true)
  level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
  text_formatter = proc do |sev, t, prog, msg|
    "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{format_text_msg(msg)}\n"
  end

  loggers = [make_logger($stderr, level, text_formatter)]

  if log_path
    FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
    FileUtils.touch(log_path)
    File.chmod(0o600, log_path)
    loggers << make_logger(log_path, level, text_formatter)
  end

  if jsonl
    jsonl_logger = build_jsonl_logger(level, component)
    loggers << jsonl_logger if jsonl_logger
  end

  loggers.length == 1 ? loggers.first : MultiLogger.new(*loggers)
end

.flow(name, &block) ⇒ Object

Raises:

  • (ArgumentError)


192
193
194
195
196
197
198
# File 'lib/browserctl/flow.rb', line 192

def self.flow(name, &block)
  raise ArgumentError, "Browserctl.flow requires a block" unless block

  flow = Flow.new(name).tap { |f| f.instance_exec(&block) }
  register_flow(flow)
  flow
end

.flow_registry_reset!Object



212
213
214
# File 'lib/browserctl/flow.rb', line 212

def self.flow_registry_reset!
  @flow_registry_mutex.synchronize { @flow_registry.clear }
end

.flow_registry_snapshotObject



208
209
210
# File 'lib/browserctl/flow.rb', line 208

def self.flow_registry_snapshot
  @flow_registry_mutex.synchronize { @flow_registry.dup }
end

.log_dirObject

Resolved at call time so tests can override BROWSERCTL_DIR via stub_const.



22
23
24
# File 'lib/browserctl/logger.rb', line 22

def self.log_dir
  File.join(BROWSERCTL_DIR, "logs")
end

.log_path(name = nil) ⇒ Object



20
21
22
# File 'lib/browserctl/constants.rb', line 20

def self.log_path(name = nil)
  File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
end

.loggerObject



76
77
78
# File 'lib/browserctl/logger.rb', line 76

def self.logger
  @logger ||= build_logger("info")
end

.logger=(instance) ⇒ Object



80
81
82
# File 'lib/browserctl/logger.rb', line 80

def self.logger=(instance)
  @logger = instance
end

.lookup_flow(name) ⇒ Object



204
205
206
# File 'lib/browserctl/flow.rb', line 204

def self.lookup_flow(name)
  @flow_registry_mutex.synchronize { @flow_registry[name.to_s] }
end

.lookup_plugin_command(name) ⇒ Object



19
20
21
# File 'lib/browserctl.rb', line 19

def self.lookup_plugin_command(name)
  @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] }
end

.lookup_workflow(name) ⇒ Object



552
553
554
# File 'lib/browserctl/workflow.rb', line 552

def self.lookup_workflow(name)
  @registry_mutex.synchronize { @registry[name.to_s] }
end

.next_daemon_nameObject

Returns nil when the default (unnamed) slot is free; otherwise returns “d1”, “d2”, etc.

Raises:



25
26
27
28
29
30
31
32
# File 'lib/browserctl/constants.rb', line 25

def self.next_daemon_name
  return nil unless File.exist?(socket_path)

  1.upto(99) do |i|
    return "d#{i}" unless File.exist?(socket_path("d#{i}"))
  end
  raise Browserctl::Error, "too many running daemons (limit: 99)"
end

.parse_workflow_format_version(source) ⇒ Object

Parses the ‘# format_version: N` header from a workflow file’s source. Scans only the contiguous leading comment block (and blank lines) so the header cannot be smuggled in mid-file. Returns the integer if present, or nil if the file has no version header.



37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/browserctl/workflow.rb', line 37

def self.parse_workflow_format_version(source)
  source.each_line do |line|
    stripped = line.strip
    next if stripped.empty?
    break unless stripped.start_with?("#")

    if (m = line.match(WORKFLOW_FORMAT_VERSION_HEADER))
      return Integer(m[1])
    end
  end
  nil
end

.pid_path(name = nil) ⇒ Object



16
17
18
# File 'lib/browserctl/constants.rb', line 16

def self.pid_path(name = nil)
  File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
end

.plugin_commands_snapshotObject



23
24
25
# File 'lib/browserctl.rb', line 23

def self.plugin_commands_snapshot
  @plugin_commands_mutex.synchronize { @plugin_commands.dup }
end

.register_command(name, &block) ⇒ Object



15
16
17
# File 'lib/browserctl.rb', line 15

def self.register_command(name, &block)
  @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] = block }
end

.register_flow(flow) ⇒ Object



200
201
202
# File 'lib/browserctl/flow.rb', line 200

def self.register_flow(flow)
  @flow_registry_mutex.synchronize { @flow_registry[flow.name] = flow }
end

.registry_snapshotObject



556
557
558
# File 'lib/browserctl/workflow.rb', line 556

def self.registry_snapshot
  @registry_mutex.synchronize { @registry.dup }
end

.socket_path(name = nil) ⇒ Object



12
13
14
# File 'lib/browserctl/constants.rb', line 12

def self.socket_path(name = nil)
  File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
end

.verify_workflow_format_version!(path) ⇒ Object

Reads a workflow file and warns to stderr when the ‘format_version:` header is missing or declares an unsupported version. Always returns the parsed integer (or nil) — never raises. Callers should still `load` the file regardless.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/browserctl/workflow.rb', line 54

def self.verify_workflow_format_version!(path)
  source = File.read(path)
  version = parse_workflow_format_version(source)

  if version.nil?
    warn "[browserctl] workflow #{path} is missing a `# format_version: N` header " \
         "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
  elsif !SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
    warn "[browserctl] workflow #{path} format_version=#{version} is not supported " \
         "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
  end

  version
end

.workflow(name) ⇒ Object



546
547
548
549
550
# File 'lib/browserctl/workflow.rb', line 546

def self.workflow(name, &)
  defn = WorkflowDefinition.new(name.to_s)
  defn.instance_exec(&)
  @registry_mutex.synchronize { @registry[name.to_s] = defn }
end