browserctl 0.11.0 → 0.13.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +142 -0
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# Stable mapping from a canonical {Browserctl::Error::Codes} string to a
|
|
8
|
+
# process exit status. The CLI's top-level rescue uses this so AI agents
|
|
9
|
+
# and shell scripts can branch on `$?` deterministically without parsing
|
|
10
|
+
# stderr.
|
|
11
|
+
#
|
|
12
|
+
# Stability contract:
|
|
13
|
+
# - Exit code integers are part of the v0.12 stable surface and will not
|
|
14
|
+
# be renumbered without a major version bump.
|
|
15
|
+
# - Unknown / unmapped codes intentionally fall through to {GENERIC} (1)
|
|
16
|
+
# so an unfamiliar code never silently surfaces as a non-error (0).
|
|
17
|
+
#
|
|
18
|
+
# See docs/reference/exit-codes.md for the operator-facing table.
|
|
19
|
+
module ExitCodes
|
|
20
|
+
OK = 0
|
|
21
|
+
GENERIC = 1
|
|
22
|
+
DRIFT = 2
|
|
23
|
+
AUTH_REQUIRED = 3
|
|
24
|
+
DAEMON_UNREACHABLE = 4
|
|
25
|
+
PROTOCOL_MISMATCH = 5
|
|
26
|
+
SELECTOR_NOT_FOUND = 6
|
|
27
|
+
STATE_EXPIRED = 7
|
|
28
|
+
|
|
29
|
+
# Canonical Codes string → exit status integer. Codes without an entry
|
|
30
|
+
# (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
|
|
31
|
+
# GENERIC) collapse to {GENERIC} for now; they may earn dedicated exit
|
|
32
|
+
# codes in a future milestone.
|
|
33
|
+
#
|
|
34
|
+
# DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
|
|
35
|
+
# entry in this table — drift-related raises fall through to GENERIC
|
|
36
|
+
# until that code is introduced.
|
|
37
|
+
TABLE = {
|
|
38
|
+
Codes::AUTH_REQUIRED => AUTH_REQUIRED,
|
|
39
|
+
Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
|
|
40
|
+
Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
|
|
41
|
+
Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
|
|
42
|
+
Codes::STATE_EXPIRED => STATE_EXPIRED
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
|
|
46
|
+
# @return [Integer] mapped exit status; {GENERIC} for nil or unknown codes
|
|
47
|
+
def self.for(code)
|
|
48
|
+
return GENERIC if code.nil?
|
|
49
|
+
|
|
50
|
+
TABLE.fetch(code, GENERIC)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# Maps a stable error code (see {Browserctl::Error::Codes}) to a short
|
|
8
|
+
# imperative sentence telling the operator (or AI agent) what to try
|
|
9
|
+
# next. Codes without an explicit entry fall back to a generic pointer
|
|
10
|
+
# to the error reference doc (added in PR #11 of v0.12).
|
|
11
|
+
module SuggestedActions
|
|
12
|
+
DEFAULT = "See docs/reference/errors.md for guidance."
|
|
13
|
+
|
|
14
|
+
TABLE = {
|
|
15
|
+
Codes::AUTH_REQUIRED =>
|
|
16
|
+
"Run the suggested flow to refresh credentials, then retry.",
|
|
17
|
+
Codes::SELECTOR_NOT_FOUND =>
|
|
18
|
+
"Re-run snapshot to get fresh refs, then retry with a stable ref or selector.",
|
|
19
|
+
Codes::STATE_EXPIRED =>
|
|
20
|
+
"Re-save the state bundle (state save) or rotate it (state rotate).",
|
|
21
|
+
Codes::SECRET_RESOLUTION_FAILED =>
|
|
22
|
+
"Verify the secret resolver config and that the underlying secret exists.",
|
|
23
|
+
Codes::DAEMON_UNREACHABLE =>
|
|
24
|
+
"Start the daemon with 'browserctl daemon start', then retry.",
|
|
25
|
+
Codes::PROTOCOL_MISMATCH =>
|
|
26
|
+
"Upgrade browserctl to a version that supports this artifact's format version.",
|
|
27
|
+
Codes::DOMAIN_NOT_ALLOWED =>
|
|
28
|
+
"Add the domain to your policy allowlist or use an allowed URL.",
|
|
29
|
+
Codes::KEY_NOT_FOUND =>
|
|
30
|
+
"Verify the key was stored in this daemon session before fetching.",
|
|
31
|
+
Codes::GENERIC => DEFAULT
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# @param code [String, nil] a SCREAMING_SNAKE code from {Codes}
|
|
35
|
+
# @return [String] suggested action sentence; never nil
|
|
36
|
+
def self.for(code)
|
|
37
|
+
TABLE.fetch(code, DEFAULT)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error/codes"
|
|
4
|
+
require_relative "error/exit_codes"
|
|
5
|
+
require_relative "error/suggested_actions"
|
|
6
|
+
|
|
3
7
|
module Browserctl
|
|
4
8
|
# Base error class for all browserctl daemon errors.
|
|
5
9
|
# Subclasses carry a machine-readable `code` that appears in wire responses.
|
|
10
|
+
# The canonical enum of stable codes lives in {Browserctl::Error::Codes};
|
|
11
|
+
# the sweep that retrofits every raise to use those codes lands in a later
|
|
12
|
+
# v0.12 PR.
|
|
6
13
|
# @attr_reader code [String] machine-readable error code
|
|
14
|
+
# @attr_reader context [Hash] free-form structured fields (selector, path, ...)
|
|
7
15
|
class Error < StandardError
|
|
8
16
|
def self.default_code = "error"
|
|
9
17
|
|
|
10
|
-
attr_reader :code
|
|
18
|
+
attr_reader :code, :context
|
|
11
19
|
|
|
12
|
-
def initialize(msg = nil, code: self.class.default_code)
|
|
13
|
-
@code
|
|
20
|
+
def initialize(msg = nil, code: self.class.default_code, context: {})
|
|
21
|
+
@code = code
|
|
22
|
+
@context = context || {}
|
|
14
23
|
super(msg)
|
|
15
24
|
end
|
|
25
|
+
|
|
26
|
+
# Returns the canonical structured payload emitted on the daemon wire and
|
|
27
|
+
# on CLI stderr. Shape is stable across releases — agents branch on `code`
|
|
28
|
+
# without parsing prose.
|
|
29
|
+
# @return [Hash{Symbol => Object}]
|
|
30
|
+
def to_payload
|
|
31
|
+
{
|
|
32
|
+
code: code,
|
|
33
|
+
message: message,
|
|
34
|
+
context: context,
|
|
35
|
+
suggested_action: SuggestedActions.for(code)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
16
38
|
end
|
|
17
39
|
|
|
18
|
-
class PageNotFound < Error; def self.default_code = "page_not_found"
|
|
19
|
-
class SelectorNotFound < Error; def self.default_code =
|
|
20
|
-
class RefNotFound < Error; def self.default_code = "ref_not_found"
|
|
21
|
-
class PathNotAllowed < Error; def self.default_code = "path_not_allowed"
|
|
22
|
-
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed"
|
|
23
|
-
class TimeoutError < Error; def self.default_code = "timeout"
|
|
24
|
-
class KeyNotFound
|
|
25
|
-
class DaemonUnavailableError < Error; def self.default_code =
|
|
40
|
+
class PageNotFound < Error; def self.default_code = "page_not_found" end
|
|
41
|
+
class SelectorNotFound < Error; def self.default_code = Codes::SELECTOR_NOT_FOUND end
|
|
42
|
+
class RefNotFound < Error; def self.default_code = "ref_not_found" end
|
|
43
|
+
class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
|
|
44
|
+
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
45
|
+
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
46
|
+
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
47
|
+
class DaemonUnavailableError < Error; def self.default_code = Codes::DAEMON_UNREACHABLE end
|
|
26
48
|
class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
|
|
27
49
|
|
|
28
50
|
# Raised when the daemon detects that the current page needs authentication —
|
|
@@ -31,7 +53,7 @@ module Browserctl
|
|
|
31
53
|
# suggested flow (from the bundle manifest) so callers can recover without
|
|
32
54
|
# additional lookups. The CLI maps this code to exit status 7.
|
|
33
55
|
class AuthRequiredError < Error
|
|
34
|
-
def self.default_code =
|
|
56
|
+
def self.default_code = Codes::AUTH_REQUIRED
|
|
35
57
|
|
|
36
58
|
AUTH_REQUIRED_EXIT_CODE = 7
|
|
37
59
|
|
|
@@ -50,17 +72,25 @@ module Browserctl
|
|
|
50
72
|
code: self.class.default_code,
|
|
51
73
|
state: state,
|
|
52
74
|
suggested_flow: suggested_flow,
|
|
53
|
-
reason: reason
|
|
75
|
+
reason: reason,
|
|
76
|
+
context: { state: state, suggested_flow: suggested_flow, reason: reason }.compact,
|
|
77
|
+
suggested_action: SuggestedActions.for(self.class.default_code)
|
|
54
78
|
}.compact
|
|
55
79
|
end
|
|
56
80
|
end
|
|
57
81
|
|
|
58
82
|
class WorkflowError < Error; def self.default_code = "workflow_error" end
|
|
59
|
-
class SecretResolverError < WorkflowError; def self.default_code =
|
|
83
|
+
class SecretResolverError < WorkflowError; def self.default_code = Codes::SECRET_RESOLUTION_FAILED end
|
|
60
84
|
|
|
61
85
|
class FlowError < WorkflowError; def self.default_code = "flow_error" end
|
|
62
86
|
class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
|
|
63
87
|
class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
|
|
64
88
|
class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
|
|
65
89
|
class FlowPostconditionError < FlowError; def self.default_code = "flow_postcondition_failed" end
|
|
90
|
+
|
|
91
|
+
# Raised when a persisted artifact (bundle, recording, workflow, etc.) has a
|
|
92
|
+
# `version:` header that this build does not know how to read. The full error
|
|
93
|
+
# code taxonomy lands in WS-2 (PR #7); this class is a forward-reference stub
|
|
94
|
+
# so WS-1 PRs can already raise the canonical code.
|
|
95
|
+
class ProtocolMismatch < Error; def self.default_code = Codes::PROTOCOL_MISMATCH end
|
|
66
96
|
end
|
data/lib/browserctl/flow.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "callable_definition"
|
|
4
4
|
require_relative "errors"
|
|
5
|
-
require_relative "secret_resolvers"
|
|
6
5
|
|
|
7
6
|
module Browserctl
|
|
8
|
-
FlowParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
9
|
-
FlowStepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
10
7
|
FlowConditionDef = Struct.new(:kind, :label, :block, keyword_init: true)
|
|
11
8
|
|
|
9
|
+
# Back-compat aliases — flow_wrapper specs reference these directly.
|
|
10
|
+
FlowParamDef = CallableDefinition::ParamDef
|
|
11
|
+
FlowStepDef = CallableDefinition::StepDef
|
|
12
|
+
|
|
12
13
|
class FlowContext
|
|
13
14
|
attr_reader :page, :client, :params
|
|
14
15
|
|
|
@@ -29,31 +30,28 @@ module Browserctl
|
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
class Flow
|
|
33
|
+
class Flow < CallableDefinition
|
|
33
34
|
SEMVER_RE = /\A\d+\.\d+\.\d+\z/
|
|
34
35
|
|
|
35
|
-
attr_reader :
|
|
36
|
-
:version_string,
|
|
37
|
-
:description,
|
|
38
|
-
:param_defs,
|
|
39
|
-
:steps,
|
|
36
|
+
attr_reader :version_string,
|
|
40
37
|
:preconditions,
|
|
41
38
|
:postconditions,
|
|
42
39
|
:produces_state_block,
|
|
43
40
|
:min_browserctl_version
|
|
44
41
|
|
|
45
42
|
def initialize(name)
|
|
46
|
-
|
|
43
|
+
super
|
|
47
44
|
@version_string = "0.0.0"
|
|
48
|
-
@description = nil
|
|
49
|
-
@param_defs = {}
|
|
50
|
-
@steps = []
|
|
51
45
|
@preconditions = []
|
|
52
46
|
@postconditions = []
|
|
53
47
|
@produces_state_block = nil
|
|
54
48
|
@min_browserctl_version = nil
|
|
55
49
|
end
|
|
56
50
|
|
|
51
|
+
def callable_kind
|
|
52
|
+
:flow
|
|
53
|
+
end
|
|
54
|
+
|
|
57
55
|
def version(value)
|
|
58
56
|
validate_semver!(value, label: "version")
|
|
59
57
|
@version_string = value.to_s
|
|
@@ -64,27 +62,6 @@ module Browserctl
|
|
|
64
62
|
@min_browserctl_version = value.to_s
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def desc(text)
|
|
68
|
-
@description = text.to_s
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
72
|
-
secret = true if secret_ref
|
|
73
|
-
@param_defs[name] = FlowParamDef.new(
|
|
74
|
-
name: name,
|
|
75
|
-
required: required,
|
|
76
|
-
secret: secret,
|
|
77
|
-
default: default,
|
|
78
|
-
secret_ref: secret_ref
|
|
79
|
-
)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def step(label, retry_count: 0, timeout: nil, &block)
|
|
83
|
-
raise ArgumentError, "flow step '#{label}' requires a block" unless block
|
|
84
|
-
|
|
85
|
-
@steps << FlowStepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
65
|
def precondition(label = "precondition", &block)
|
|
89
66
|
raise ArgumentError, "precondition '#{label}' requires a block" unless block
|
|
90
67
|
|
|
@@ -103,6 +80,24 @@ module Browserctl
|
|
|
103
80
|
@produces_state_block = block
|
|
104
81
|
end
|
|
105
82
|
|
|
83
|
+
# Definition-time guard against cross-type composition. A flow may only
|
|
84
|
+
# compose other flows; pulling steps from a workflow would smuggle
|
|
85
|
+
# `store`/`fetch` into a flow context that has no daemon-backed
|
|
86
|
+
# persistence.
|
|
87
|
+
def compose(target_name)
|
|
88
|
+
name = target_name.to_s
|
|
89
|
+
if Browserctl.respond_to?(:lookup_workflow) && Browserctl.lookup_workflow(name)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
|
|
92
|
+
"workflows share state — composition across kinds is not supported"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
source = Browserctl.lookup_flow(name)
|
|
96
|
+
raise ArgumentError, "flow '#{name}' not found for composition" unless source
|
|
97
|
+
|
|
98
|
+
@steps.concat(source.steps)
|
|
99
|
+
end
|
|
100
|
+
|
|
106
101
|
def run(page: nil, client: nil, **params)
|
|
107
102
|
ctx = FlowContext.new(page: page, client: client, params: resolve_params(params))
|
|
108
103
|
|
|
@@ -121,20 +116,12 @@ module Browserctl
|
|
|
121
116
|
raise ArgumentError, "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})"
|
|
122
117
|
end
|
|
123
118
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
128
|
-
elsif provided.key?(name)
|
|
129
|
-
provided[name]
|
|
130
|
-
else
|
|
131
|
-
defn.default
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
raise FlowParamError, "flow '#{@name}' requires param '#{name}'" if defn.required && val.nil?
|
|
119
|
+
def missing_param_error(name)
|
|
120
|
+
FlowParamError.new("flow '#{@name}' requires param '#{name}'")
|
|
121
|
+
end
|
|
135
122
|
|
|
136
|
-
|
|
137
|
-
|
|
123
|
+
def step_timeout_error(defn)
|
|
124
|
+
FlowStepError.new("flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s")
|
|
138
125
|
end
|
|
139
126
|
|
|
140
127
|
def run_conditions(ctx, conditions, error_class:)
|
|
@@ -168,17 +155,6 @@ module Browserctl
|
|
|
168
155
|
"flow '#{@name}' step '#{defn.label}' failed: #{last_error.message}"
|
|
169
156
|
end
|
|
170
157
|
|
|
171
|
-
def execute_step_block(ctx, defn)
|
|
172
|
-
if defn.timeout
|
|
173
|
-
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
174
|
-
else
|
|
175
|
-
ctx.instance_exec(&defn.block)
|
|
176
|
-
end
|
|
177
|
-
rescue ::Timeout::Error
|
|
178
|
-
raise FlowStepError,
|
|
179
|
-
"flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
158
|
def produce_state(ctx)
|
|
183
159
|
return nil unless @produces_state_block
|
|
184
160
|
|
|
@@ -5,8 +5,8 @@ require_relative "../../flow"
|
|
|
5
5
|
|
|
6
6
|
# Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
|
|
7
7
|
# moment...", interactive checkbox), then verifies the challenge cleared
|
|
8
|
-
# before returning. Optionally saves the post-solve
|
|
9
|
-
# you can reload later with `state load
|
|
8
|
+
# before returning. Optionally saves the post-solve state under a name
|
|
9
|
+
# you can reload later with `state load`.
|
|
10
10
|
#
|
|
11
11
|
# Reuses Browserctl::Detectors.cloudflare? — the server-side detector
|
|
12
12
|
# already shipped in v0.8 — by adapting the client-facing PageProxy to
|
|
@@ -34,7 +34,7 @@ Browserctl.flow("cloudflare_solve") do
|
|
|
34
34
|
|
|
35
35
|
param :prompt,
|
|
36
36
|
default: "Cloudflare challenge detected. Solve it in the browser, then press Enter to continue."
|
|
37
|
-
param :state_name # optional — if set,
|
|
37
|
+
param :state_name # optional — if set, state_save is called after the challenge clears
|
|
38
38
|
|
|
39
39
|
precondition("page proxy is present") { !page.nil? }
|
|
40
40
|
precondition("cloudflare challenge is present") do
|
|
@@ -54,6 +54,6 @@ Browserctl.flow("cloudflare_solve") do
|
|
|
54
54
|
produces_state do
|
|
55
55
|
next nil unless state_name && client
|
|
56
56
|
|
|
57
|
-
client.
|
|
57
|
+
client.state_save(state_name)
|
|
58
58
|
end
|
|
59
59
|
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
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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)
|