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/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/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/resume.rb,
lib/browserctl/driver/cdp_page.rb,
lib/browserctl/recording/state.rb,
lib/browserctl/state/transport.rb,
lib/browserctl/commands/migrate.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/commands/recording.rb,
lib/browserctl/encryption_service.rb,
lib/browserctl/recording/redactor.rb,
lib/browserctl/snapshot/annotator.rb,
lib/browserctl/snapshot/extractor.rb,
lib/browserctl/callable_definition.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/recording/log_writer.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/commands/output_format.rb,
lib/browserctl/contextual_persistence.rb,
lib/browserctl/detectors/auth_required.rb,
lib/browserctl/error/suggested_actions.rb,
lib/browserctl/server/handlers/cookies.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/workflow/recovery_manager.rb,
lib/browserctl/replay/fingerprint_matcher.rb,
lib/browserctl/server/handlers/navigation.rb,
lib/browserctl/recording/workflow_renderer.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 state under a name you can reload later with ‘state 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, ContextualPersistence, CrashReport, Detectors, Driver, EncryptionService, Flows, FormatVersion, Migrations, Policy, Replay, SecretResolvers, Snapshot, State, Workflow Classes: AuthRequiredError, BrowserNotFound, CallableDefinition, Client, CommandDispatcher, DaemonUnavailableError, DomainNotAllowed, Error, Flow, FlowConditionDef, FlowContext, FlowError, FlowParamError, FlowPostconditionError, FlowPreconditionError, FlowRegistry, FlowStepError, HeaderlessLogDevice, IdleWatcher, JsonlFormatter, KeyNotFound, MultiLogger, PageNotFound, PageProxy, PageSession, PathNotAllowed, ProtocolMismatch, Recording, Redactor, RefNotFound, Runner, SecretResolverError, SecretResolverRegistry, SelectorNotFound, Server, SnapshotBuilder, StepResult, TimeoutError, WorkflowContext, WorkflowDefinition, WorkflowError

Constant Summary collapse

FlowParamDef =

Back-compat aliases — flow_wrapper specs reference these directly.

CallableDefinition::ParamDef
FlowStepDef =
CallableDefinition::StepDef
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.13.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*$/
ParamDef =

Back-compat aliases — exposed in the public surface (flow_wrapper specs, workflow specs reference these directly).

CallableDefinition::ParamDef
StepDef =
CallableDefinition::StepDef
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)


168
169
170
171
172
173
174
# File 'lib/browserctl/flow.rb', line 168

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



188
189
190
# File 'lib/browserctl/flow.rb', line 188

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

.flow_registry_snapshotObject



184
185
186
# File 'lib/browserctl/flow.rb', line 184

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



180
181
182
# File 'lib/browserctl/flow.rb', line 180

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



376
377
378
# File 'lib/browserctl/workflow.rb', line 376

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.



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

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



176
177
178
# File 'lib/browserctl/flow.rb', line 176

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

.registry_snapshotObject



380
381
382
# File 'lib/browserctl/workflow.rb', line 380

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.



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

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



370
371
372
373
374
# File 'lib/browserctl/workflow.rb', line 370

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