ruby_llm-browser 0.1.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 +7 -0
- data/CHANGELOG.md +61 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/CONTRIBUTING.md +42 -0
- data/LICENSE.txt +21 -0
- data/NOTICE.md +92 -0
- data/README.md +139 -0
- data/SECURITY.md +46 -0
- data/docs/API.md +177 -0
- data/docs/CNAME +1 -0
- data/docs/RAILS.md +167 -0
- data/docs/README.md +16 -0
- data/docs/RELEASE_ACTION_PLAN.md +81 -0
- data/docs/RELEASE_AUDIT.md +158 -0
- data/docs/RELEASING.md +131 -0
- data/docs/SECURITY_MODEL.md +117 -0
- data/docs/SPEC_BASELINE.md +107 -0
- data/docs/WORK.md +260 -0
- data/docs/_advanced/live-backend-qualification.md +74 -0
- data/docs/_advanced/rails-integration.md +56 -0
- data/docs/_config.yml +109 -0
- data/docs/_core_features/inspect-extract.md +91 -0
- data/docs/_core_features/offline-act.md +110 -0
- data/docs/_core_features/operation-lifecycle.md +63 -0
- data/docs/_core_features/source-policy.md +61 -0
- data/docs/_data/navigation.yml +12 -0
- data/docs/_data/sidebar.yml +8 -0
- data/docs/_data/social_links.yml +3 -0
- data/docs/_getting_started/configuration.md +68 -0
- data/docs/_getting_started/getting-started.md +77 -0
- data/docs/_getting_started/overview.md +52 -0
- data/docs/_plugins/versioned_docs.rb +46 -0
- data/docs/_reference/api-reference.md +29 -0
- data/docs/_reference/release-history.md +23 -0
- data/docs/_reference/upgrading.md +33 -0
- data/docs/bin/build-api.sh +23 -0
- data/docs/bin/build.sh +161 -0
- data/docs/bin/export-api-markdown.rb +53 -0
- data/docs/bin/link_versions.rb +54 -0
- data/docs/bin/postprocess_api_seo.rb +129 -0
- data/docs/bin/prepare_versions.rb +40 -0
- data/docs/bin/serve.sh +12 -0
- data/docs/bin/versions.yml +14 -0
- data/docs/index.md +82 -0
- data/examples/plain_ruby.rb +34 -0
- data/examples/rails_adapter.rb +47 -0
- data/lib/generators/ruby_llm/browser/install_generator.rb +29 -0
- data/lib/generators/ruby_llm/browser/templates/create_ruby_llm_browser_tables.rb.tt +60 -0
- data/lib/ruby_llm/browser/approval.rb +128 -0
- data/lib/ruby_llm/browser/approval_store.rb +38 -0
- data/lib/ruby_llm/browser/backend.rb +99 -0
- data/lib/ruby_llm/browser/budget.rb +90 -0
- data/lib/ruby_llm/browser/capabilities.rb +76 -0
- data/lib/ruby_llm/browser/configuration.rb +74 -0
- data/lib/ruby_llm/browser/context.rb +117 -0
- data/lib/ruby_llm/browser/doctor.rb +134 -0
- data/lib/ruby_llm/browser/engine.rb +326 -0
- data/lib/ruby_llm/browser/errors.rb +86 -0
- data/lib/ruby_llm/browser/fake_backend.rb +134 -0
- data/lib/ruby_llm/browser/immutable.rb +19 -0
- data/lib/ruby_llm/browser/in_memory_approval_store.rb +67 -0
- data/lib/ruby_llm/browser/in_memory_operation_store.rb +174 -0
- data/lib/ruby_llm/browser/operation.rb +218 -0
- data/lib/ruby_llm/browser/operation_store.rb +92 -0
- data/lib/ruby_llm/browser/policy.rb +298 -0
- data/lib/ruby_llm/browser/rails/active_record_approval_store.rb +72 -0
- data/lib/ruby_llm/browser/rails/active_record_operation_store.rb +231 -0
- data/lib/ruby_llm/browser/rails/actor_record.rb +13 -0
- data/lib/ruby_llm/browser/rails/approval_record.rb +49 -0
- data/lib/ruby_llm/browser/rails/operation_job.rb +163 -0
- data/lib/ruby_llm/browser/rails/operation_record.rb +56 -0
- data/lib/ruby_llm/browser/rails/reaper.rb +117 -0
- data/lib/ruby_llm/browser/rails/schema.rb +81 -0
- data/lib/ruby_llm/browser/rails.rb +79 -0
- data/lib/ruby_llm/browser/redactor.rb +159 -0
- data/lib/ruby_llm/browser/result.rb +102 -0
- data/lib/ruby_llm/browser/stagehand_backend.rb +332 -0
- data/lib/ruby_llm/browser/tool.rb +70 -0
- data/lib/ruby_llm/browser/tools/act.rb +91 -0
- data/lib/ruby_llm/browser/tools/extract.rb +60 -0
- data/lib/ruby_llm/browser/tools/inspect.rb +38 -0
- data/lib/ruby_llm/browser/url_guard.rb +253 -0
- data/lib/ruby_llm/browser/version.rb +9 -0
- data/lib/ruby_llm/browser.rb +79 -0
- metadata +267 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Browser
|
|
9
|
+
# Sanitization and bounding helpers for anything that could reach a model message,
|
|
10
|
+
# tool result, log line or error.
|
|
11
|
+
#
|
|
12
|
+
# Page text, model output and provider metadata are untrusted. They are redacted by
|
|
13
|
+
# key name, scrubbed of known secrets, stripped of DevTools URLs, and bounded by
|
|
14
|
+
# size and depth before they leave a backend.
|
|
15
|
+
module Redactor
|
|
16
|
+
# The replacement for redacted values.
|
|
17
|
+
REDACTED = "[REDACTED]"
|
|
18
|
+
|
|
19
|
+
# JSON preview marker when a value is too large to return whole.
|
|
20
|
+
TRUNCATED = "[TRUNCATED]"
|
|
21
|
+
|
|
22
|
+
# Maximum nesting depth kept while redacting.
|
|
23
|
+
MAX_DEPTH = 12
|
|
24
|
+
|
|
25
|
+
# Keys whose values are always treated as secrets. Matching is case-insensitive
|
|
26
|
+
# and applies to `snake_case`, `kebab-case` and `camelCase` spellings.
|
|
27
|
+
SECRET_KEY = /
|
|
28
|
+
api[-_]?key | apikey |
|
|
29
|
+
access[-_]?token | refresh[-_]?token | id[-_]?token |
|
|
30
|
+
client[-_]?secret | private[-_]?key |
|
|
31
|
+
authorization | bearer |
|
|
32
|
+
password | passwd | credential |
|
|
33
|
+
cookie | set[-_]?cookie |
|
|
34
|
+
secret | token |
|
|
35
|
+
cdp[-_]?url | devtools |
|
|
36
|
+
x[-_]bb[-_]api[-_]key | x[-_]model[-_]api[-_]key
|
|
37
|
+
/xi
|
|
38
|
+
|
|
39
|
+
# A DevTools WebSocket URL anywhere in a string. Full browser control, so it is
|
|
40
|
+
# always removed.
|
|
41
|
+
DEVTOOLS_URL = %r{ws{1,2}://[^\s"']*}i
|
|
42
|
+
|
|
43
|
+
# Secrets shorter than this are not treated as replaceable literals; replacing
|
|
44
|
+
# very short strings would corrupt ordinary text.
|
|
45
|
+
MIN_LITERAL_SECRET = 8
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Returns a redacted, depth-bounded copy of +value+.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Object]
|
|
52
|
+
# @param secrets [Array<String>] exact secret values to remove from strings
|
|
53
|
+
# @param max_depth [Integer] nesting depth at which content is replaced entirely
|
|
54
|
+
def redact(value, secrets: [], depth: 0, max_depth: MAX_DEPTH)
|
|
55
|
+
return REDACTED if depth > max_depth
|
|
56
|
+
|
|
57
|
+
case value
|
|
58
|
+
when Hash
|
|
59
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
60
|
+
safe_key = scrub_string(key.to_s, secrets: secrets)
|
|
61
|
+
result[safe_key] = secret_key?(key) ? REDACTED : redact(nested, secrets: secrets, depth: depth + 1, max_depth: max_depth)
|
|
62
|
+
end
|
|
63
|
+
when Array
|
|
64
|
+
value.map { |item| redact(item, secrets: secrets, depth: depth + 1, max_depth: max_depth) }
|
|
65
|
+
when String
|
|
66
|
+
scrub_string(value, secrets: secrets)
|
|
67
|
+
when Symbol
|
|
68
|
+
scrub_string(value.to_s, secrets: secrets)
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Redacts and then bounds +value+ to +max_bytes+ of JSON.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array(Object, Boolean)] the bounded value and whether it was truncated
|
|
77
|
+
def bound(value, max_bytes:, secrets: [])
|
|
78
|
+
unless max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
79
|
+
raise InvalidInput, "max_bytes must be a positive integer"
|
|
80
|
+
end
|
|
81
|
+
redacted = redact(value, secrets: secrets)
|
|
82
|
+
json = canonical_json(redacted)
|
|
83
|
+
return [ redacted, false ] if json.bytesize <= max_bytes
|
|
84
|
+
|
|
85
|
+
# Count encoded bytes, not characters: JSON escaping can expand a preview.
|
|
86
|
+
envelope = { "truncated" => true, "preview" => "", "note" => TRUNCATED }
|
|
87
|
+
return [ "", true ] if max_bytes < canonical_json(envelope).bytesize && max_bytes >= 2
|
|
88
|
+
return [ 0, true ] if max_bytes == 1
|
|
89
|
+
|
|
90
|
+
low = 0
|
|
91
|
+
high = [ json.length, max_bytes ].min
|
|
92
|
+
while low < high
|
|
93
|
+
midpoint = (low + high + 1) / 2
|
|
94
|
+
envelope["preview"] = json[0, midpoint]
|
|
95
|
+
if canonical_json(envelope).bytesize <= max_bytes
|
|
96
|
+
low = midpoint
|
|
97
|
+
else
|
|
98
|
+
high = midpoint - 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
envelope["preview"] = json[0, low]
|
|
102
|
+
[ envelope, true ]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Stable JSON for digests: object keys sorted, no insignificant whitespace.
|
|
106
|
+
def canonical_json(value)
|
|
107
|
+
JSON.generate(sort_keys(value))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# SHA-256 hex digest of the canonical JSON of +value+.
|
|
111
|
+
def digest(value)
|
|
112
|
+
Digest::SHA256.hexdigest(canonical_json(value))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# True when a key name denotes a secret value.
|
|
116
|
+
def secret_key?(key)
|
|
117
|
+
SECRET_KEY.match?(key.to_s)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Removes userinfo, fragments and credential-like query values from provenance.
|
|
121
|
+
def url(value, secrets: [])
|
|
122
|
+
uri = URI.parse(value.to_s)
|
|
123
|
+
uri.userinfo = nil if uri.respond_to?(:userinfo=)
|
|
124
|
+
uri.fragment = nil
|
|
125
|
+
if uri.query
|
|
126
|
+
uri.query = URI.encode_www_form(URI.decode_www_form(uri.query).map do |key, nested|
|
|
127
|
+
[ key, secret_key?(key) ? REDACTED : nested ]
|
|
128
|
+
end)
|
|
129
|
+
end
|
|
130
|
+
scrub_string(uri.to_s, secrets: secrets)
|
|
131
|
+
rescue URI::Error, ArgumentError
|
|
132
|
+
"[REDACTED URL]"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sort_keys(value)
|
|
136
|
+
case value
|
|
137
|
+
when Hash
|
|
138
|
+
value.keys.map(&:to_s).sort.to_h do |key|
|
|
139
|
+
[ key, sort_keys(value.key?(key) ? value[key] : value[key.to_sym]) ]
|
|
140
|
+
end
|
|
141
|
+
when Array
|
|
142
|
+
value.map { |item| sort_keys(item) }
|
|
143
|
+
else
|
|
144
|
+
value
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def scrub_string(string, secrets:)
|
|
149
|
+
scrubbed = string.dup.force_encoding(Encoding::UTF_8).scrub
|
|
150
|
+
(secrets || []).each do |secret|
|
|
151
|
+
next if secret.to_s.length < MIN_LITERAL_SECRET
|
|
152
|
+
|
|
153
|
+
scrubbed = scrubbed.gsub(secret.to_s, REDACTED)
|
|
154
|
+
end
|
|
155
|
+
scrubbed.gsub(DEVTOOLS_URL, REDACTED)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Browser
|
|
8
|
+
# Bounded, sanitized output of a browser operation, with provenance.
|
|
9
|
+
#
|
|
10
|
+
# A Result is untrusted evidence with provenance: it records where content came from
|
|
11
|
+
# and when, but an `ok` status never verifies that an extracted claim is true. It
|
|
12
|
+
# never carries API keys, cookies, provider session IDs, `cdp_url` values, raw page
|
|
13
|
+
# dumps or screenshots.
|
|
14
|
+
class Result
|
|
15
|
+
# Machine-readable outcome.
|
|
16
|
+
#
|
|
17
|
+
# - `:ok` completed with confirmed session closure
|
|
18
|
+
# - `:truncated` completed but content hit a byte/step limit
|
|
19
|
+
# - `:error` a typed, safe error stopped the operation
|
|
20
|
+
# - `:unknown` a remote outcome could not be confirmed
|
|
21
|
+
# - `:cleanup_pending` cleanup was attempted but not confirmed
|
|
22
|
+
STATUSES = %i[ok truncated error unknown cleanup_pending].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :status, :operation_id, :final_url, :captured_at, :locator, :digest,
|
|
25
|
+
:data, :error_code, :error_message
|
|
26
|
+
|
|
27
|
+
def initialize(status:, operation_id: nil, final_url: nil, captured_at: nil, locator: nil,
|
|
28
|
+
digest: nil, data: nil, truncated: false, error_code: nil, error_message: nil)
|
|
29
|
+
@status = normalize_status(status)
|
|
30
|
+
@operation_id = operation_id
|
|
31
|
+
@final_url = Immutable.copy(final_url)
|
|
32
|
+
@captured_at = Immutable.copy(captured_at)
|
|
33
|
+
@locator = Immutable.copy(locator)
|
|
34
|
+
@digest = Immutable.copy(digest)
|
|
35
|
+
@data = Immutable.copy(data)
|
|
36
|
+
@truncated = truncated ? true : false
|
|
37
|
+
@error_code = error_code
|
|
38
|
+
@error_message = error_message
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A successful result. `truncated:` marks a bounded-but-complete outcome.
|
|
43
|
+
def self.ok(data: nil, operation_id: nil, final_url: nil, captured_at: nil, locator: nil,
|
|
44
|
+
digest: nil, truncated: false)
|
|
45
|
+
new(status: truncated ? :truncated : :ok, data: data, operation_id: operation_id,
|
|
46
|
+
final_url: final_url, captured_at: captured_at, locator: locator, digest: digest,
|
|
47
|
+
truncated: truncated)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A typed failure. Used for `error`, `unknown` and `cleanup_pending` outcomes.
|
|
51
|
+
def self.failure(status, error_code:, message:, operation_id: nil, final_url: nil,
|
|
52
|
+
captured_at: nil, data: nil)
|
|
53
|
+
new(status: status, error_code: error_code, error_message: message,
|
|
54
|
+
operation_id: operation_id, final_url: final_url, captured_at: captured_at, data: data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def truncated? = @truncated
|
|
58
|
+
def ok? = status == :ok || status == :truncated
|
|
59
|
+
|
|
60
|
+
# A model-safe Hash. Times are ISO 8601 UTC strings.
|
|
61
|
+
def to_h
|
|
62
|
+
{
|
|
63
|
+
status: status.to_s,
|
|
64
|
+
operation_id: operation_id,
|
|
65
|
+
final_url: final_url,
|
|
66
|
+
captured_at: captured_at&.getutc&.iso8601,
|
|
67
|
+
locator: locator,
|
|
68
|
+
digest: digest,
|
|
69
|
+
truncated: @truncated,
|
|
70
|
+
data: data,
|
|
71
|
+
error: error_code ? { code: error_code.to_s, message: error_message } : nil
|
|
72
|
+
}.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_json(*args)
|
|
76
|
+
JSON.generate(to_h, *args)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deconstruct_keys(_keys)
|
|
80
|
+
to_h
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ==(other)
|
|
84
|
+
other.is_a?(Result) && to_h == other.to_h
|
|
85
|
+
end
|
|
86
|
+
alias eql? ==
|
|
87
|
+
|
|
88
|
+
def inspect
|
|
89
|
+
"#<#{self.class} status=#{status} operation_id=#{operation_id.inspect} url=#{final_url.inspect}>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def normalize_status(status)
|
|
95
|
+
status = status.to_sym
|
|
96
|
+
return status if STATUSES.include?(status)
|
|
97
|
+
|
|
98
|
+
raise InvalidInput, "unknown result status: #{status.inspect}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# An experimental {Backend} adapter for the official `stagehand` Ruby SDK.
|
|
6
|
+
#
|
|
7
|
+
# It is a thin, fail-closed translation layer, not a browser engine. It:
|
|
8
|
+
#
|
|
9
|
+
# - constructs `Stagehand::Client` with **every** argument explicit, so the SDK's
|
|
10
|
+
# environment defaults (`STAGEHAND_API_URL`/`STAGEHAND_BASE_URL`,
|
|
11
|
+
# `BROWSERBASE_API_KEY`, `MODEL_API_KEY`) can never redirect or authenticate a call
|
|
12
|
+
# - requires `max_retries: 0`, both at the client and per effectful request, because
|
|
13
|
+
# the SDK otherwise retries errors and timeouts
|
|
14
|
+
# - never returns `cdp_url`, cookies or credentials, and reports the pinned API keys
|
|
15
|
+
# as secret literals so they are scrubbed from page text and results
|
|
16
|
+
# - refuses to construct a live client in local mode unless a host-pinned server
|
|
17
|
+
# binary or version is present, so it never triggers the SDK's runtime download
|
|
18
|
+
#
|
|
19
|
+
# The SDK is required lazily, inside {#client}, so requiring this Gem never reads
|
|
20
|
+
# Stagehand environment variables and never starts or downloads a server.
|
|
21
|
+
#
|
|
22
|
+
# This adapter is unsupported for live operations in the first release. Its
|
|
23
|
+
# capabilities always report unverified egress, so callers cannot enable it by
|
|
24
|
+
# passing a host assertion. Provider egress and cleanup need qualification first.
|
|
25
|
+
class StagehandBackend < Backend
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.stagehand.browserbase.com"
|
|
27
|
+
CUSTOM_HEADERS_ENV = "STAGEHAND_CUSTOM_HEADERS"
|
|
28
|
+
PINNED_BINARY_ENV = "STAGEHAND_SEA_BINARY"
|
|
29
|
+
PINNED_VERSION_ENV = "STAGEHAND_SERVER_VERSION"
|
|
30
|
+
ENV_OVERRIDE_KEYS = %w[STAGEHAND_API_URL STAGEHAND_BASE_URL].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :model_name, :server, :base_url
|
|
33
|
+
|
|
34
|
+
# @param browserbase_api_key [String] the Browserbase key
|
|
35
|
+
# @param model_api_key [String] the model provider key
|
|
36
|
+
# @param model_name [String] the model the provider uses for AI browser steps
|
|
37
|
+
# @param server [Symbol, String] `:remote` or `:local`
|
|
38
|
+
# @param base_url [String, nil] explicit endpoint; defaults to the official remote
|
|
39
|
+
# endpoint. Never read from the environment.
|
|
40
|
+
# @param timeout [Numeric] per-request timeout in seconds
|
|
41
|
+
# @param custom_headers_approved [Boolean] whether the host approved the SDK's
|
|
42
|
+
# always-merged `STAGEHAND_CUSTOM_HEADERS`
|
|
43
|
+
# @param env [Hash] environment to inspect; injectable for tests
|
|
44
|
+
# @param client [Object, nil] an already-built client (tests/advanced hosts)
|
|
45
|
+
def initialize(browserbase_api_key:, model_api_key:, model_name:,
|
|
46
|
+
server: :remote, base_url: nil, timeout: 60.0,
|
|
47
|
+
custom_headers_approved: false, env: ENV, client: nil)
|
|
48
|
+
super()
|
|
49
|
+
@browserbase_api_key = presence!(browserbase_api_key, :browserbase_api_key)
|
|
50
|
+
@model_api_key = presence!(model_api_key, :model_api_key)
|
|
51
|
+
@model_name = presence!(model_name, :model_name)
|
|
52
|
+
@server = server_mode!(server)
|
|
53
|
+
@base_url = base_url.nil? ? default_base_url : presence!(base_url, :base_url)
|
|
54
|
+
@timeout = positive_number!(timeout, :timeout)
|
|
55
|
+
@env = env
|
|
56
|
+
@environment_snapshot = relevant_environment(env)
|
|
57
|
+
@process_environment_snapshot = relevant_environment(ENV)
|
|
58
|
+
@custom_headers_present = !env[CUSTOM_HEADERS_ENV].to_s.empty? || !ENV[CUSTOM_HEADERS_ENV].to_s.empty?
|
|
59
|
+
@custom_headers_approved = !!custom_headers_approved
|
|
60
|
+
@local_binary_pinned = local? && pinned_binary?
|
|
61
|
+
@client_mutex = Mutex.new
|
|
62
|
+
@client = client
|
|
63
|
+
@capabilities = Capabilities.new(
|
|
64
|
+
server_mode: local? ? :local : :remote,
|
|
65
|
+
egress_isolation: :unverified,
|
|
66
|
+
local_binary_pinned: @local_binary_pinned,
|
|
67
|
+
custom_headers_present: @custom_headers_present,
|
|
68
|
+
custom_headers_approved: @custom_headers_approved,
|
|
69
|
+
act_supported: false
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The pinned credential literals, so the redactor scrubs them from any page text
|
|
74
|
+
# or provider payload that reaches a result.
|
|
75
|
+
def secrets = [ @browserbase_api_key, @model_api_key ]
|
|
76
|
+
|
|
77
|
+
def capabilities
|
|
78
|
+
return @capabilities if environment_unchanged?
|
|
79
|
+
|
|
80
|
+
Capabilities.new(server_mode: local? ? :local : :remote, egress_isolation: :unverified,
|
|
81
|
+
custom_headers_present: true, custom_headers_approved: false,
|
|
82
|
+
act_supported: false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def local? = server == "local"
|
|
86
|
+
|
|
87
|
+
def start(context:, operation:, now:)
|
|
88
|
+
ensure_qualified!
|
|
89
|
+
response = effectful do
|
|
90
|
+
client.sessions.start(model_name: model_name, self_heal: false, verbose: 0,
|
|
91
|
+
wait_for_captcha_solves: false,
|
|
92
|
+
browserbase_session_create_params: {
|
|
93
|
+
keep_alive: false, timeout: operation.budget.max_seconds.ceil,
|
|
94
|
+
browser_settings: { solve_captchas: false, record_session: false, log_session: false }
|
|
95
|
+
},
|
|
96
|
+
request_options: effectful_options(operation, now: now))
|
|
97
|
+
end
|
|
98
|
+
session_id = response.data&.session_id.to_s
|
|
99
|
+
unless response.success == true && !session_id.empty?
|
|
100
|
+
partial = Session.new(id: session_id, started_at: now) unless session_id.empty?
|
|
101
|
+
raise SessionUnknown.new("browser provider did not confirm a session identity", session: partial)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Session.new(id: session_id, url: nil, started_at: now)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def navigate(session, url:, operation:, now:)
|
|
108
|
+
ensure_qualified!
|
|
109
|
+
response = effectful do
|
|
110
|
+
client.sessions.navigate(session.id, url: url, request_options: effectful_options(operation, now: now))
|
|
111
|
+
end
|
|
112
|
+
raise SessionUnknown, "browser navigation outcome is unconfirmed" unless response.success == true
|
|
113
|
+
|
|
114
|
+
destination = final_url(response)
|
|
115
|
+
unless destination.is_a?(String) && !destination.empty?
|
|
116
|
+
raise SessionUnknown, "browser provider did not verify the final destination URL"
|
|
117
|
+
end
|
|
118
|
+
Session.new(id: session.id, url: destination, started_at: session.started_at)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def observe(session, instruction:, operation:, now:)
|
|
122
|
+
ensure_qualified!
|
|
123
|
+
response = read do
|
|
124
|
+
client.sessions.observe(session.id, instruction: instruction,
|
|
125
|
+
request_options: read_options(operation, now: now))
|
|
126
|
+
end
|
|
127
|
+
raise ProviderError, "browser observation was unsuccessful" unless response.success == true
|
|
128
|
+
Array(response.data&.result).map { |action| observed_action(action) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract(session, schema:, instruction:, operation:, now:)
|
|
132
|
+
ensure_qualified!
|
|
133
|
+
response = read do
|
|
134
|
+
client.sessions.extract(session.id, schema: schema, instruction: instruction,
|
|
135
|
+
request_options: read_options(operation, now: now))
|
|
136
|
+
end
|
|
137
|
+
raise ProviderError, "browser extraction was unsuccessful" unless response.success == true
|
|
138
|
+
dump(response.data&.result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def act(session, action:, operation:, now:)
|
|
142
|
+
ensure_qualified!
|
|
143
|
+
raise UnsupportedSecurityCapability, "backend Act is disabled" unless capabilities.act_supported?
|
|
144
|
+
response = effectful do
|
|
145
|
+
client.sessions.act(session.id, input: action_input(action),
|
|
146
|
+
request_options: effectful_options(operation, now: now))
|
|
147
|
+
end
|
|
148
|
+
raise SessionUnknown, "browser action outcome is unconfirmed" unless response.success == true
|
|
149
|
+
dump(response.data&.result)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Stagehand is unsupported for this release. Do not send even a best-effort
|
|
153
|
+
# cleanup request: the response cannot prove terminal closure, and the owner
|
|
154
|
+
# declined all live provider calls. Keep the operation `cleanup_pending`.
|
|
155
|
+
def end_session(_session, operation:, now:)
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# No provider correlation endpoint is exposed by the SDK, so an unresolved
|
|
160
|
+
# operation cannot be proven closed or active here.
|
|
161
|
+
def reconcile(_operation, now:)
|
|
162
|
+
:unknown
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def inspect
|
|
166
|
+
"#<#{self.class} server=#{server}>"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def client
|
|
172
|
+
return @client if @client
|
|
173
|
+
|
|
174
|
+
@client_mutex.synchronize do
|
|
175
|
+
@client ||= build_client
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_client
|
|
180
|
+
raise UnsupportedSecurityCapability, "Stagehand environment changed; rebuild the backend" unless environment_unchanged?
|
|
181
|
+
if local? && ENV[PINNED_BINARY_ENV].to_s.empty? && ENV[PINNED_VERSION_ENV].to_s.empty?
|
|
182
|
+
raise UnsupportedSecurityCapability, "local mode requires a process-level server pin"
|
|
183
|
+
end
|
|
184
|
+
require "stagehand"
|
|
185
|
+
Stagehand::Client.new(
|
|
186
|
+
browserbase_api_key: @browserbase_api_key,
|
|
187
|
+
model_api_key: @model_api_key,
|
|
188
|
+
server: @server,
|
|
189
|
+
base_url: @base_url,
|
|
190
|
+
max_retries: 0,
|
|
191
|
+
timeout: @timeout
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Effectful calls never retry and, when the outcome is ambiguous, never claim
|
|
196
|
+
# success: a connection error or a 5xx becomes {SessionUnknown}.
|
|
197
|
+
def effectful
|
|
198
|
+
yield
|
|
199
|
+
rescue Stagehand::Errors::APIConnectionError
|
|
200
|
+
raise SessionUnknown, "browser provider connection outcome is unconfirmed", cause: nil
|
|
201
|
+
rescue Stagehand::Errors::APIStatusError => e
|
|
202
|
+
raise translate_status(e), cause: nil
|
|
203
|
+
rescue Stagehand::Errors::Error => e
|
|
204
|
+
raise SessionUnknown, "browser provider outcome is unconfirmed: #{e.class.name.split("::").last}", cause: nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Read-only calls never retry; an ambiguous failure is a plain provider error.
|
|
208
|
+
def read
|
|
209
|
+
yield
|
|
210
|
+
rescue Stagehand::Errors::Error => e
|
|
211
|
+
raise ProviderError, "browser provider error: #{e.class.name.split("::").last}", cause: nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def translate_status(error)
|
|
215
|
+
status = error.status.to_i
|
|
216
|
+
if status >= 500 || status == 408
|
|
217
|
+
return SessionUnknown.new("browser provider returned #{status}; outcome unconfirmed")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
ProviderError.new("browser provider rejected the request (status #{status})")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def effectful_options(operation, now:)
|
|
224
|
+
# The SDK has no configured idempotency header. The durable store, not an
|
|
225
|
+
# ignored request option, prevents duplicate starts and actions.
|
|
226
|
+
read_options(operation, now: now)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def read_options(operation, now:)
|
|
230
|
+
remaining = operation.budget.max_seconds - operation.elapsed_seconds(now)
|
|
231
|
+
raise BudgetExhausted, "operation wall-clock budget exhausted" unless remaining.positive?
|
|
232
|
+
|
|
233
|
+
{ max_retries: 0, timeout: [ @timeout, remaining ].min }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def action_input(action)
|
|
237
|
+
{
|
|
238
|
+
"description" => action["description"],
|
|
239
|
+
"selector" => action["selector"],
|
|
240
|
+
"method" => action["method"],
|
|
241
|
+
"arguments" => Array(action["arguments"])
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def observed_action(action)
|
|
246
|
+
{
|
|
247
|
+
"description" => attribute(action, :description),
|
|
248
|
+
"method" => attribute(action, :method_) || attribute(action, :method),
|
|
249
|
+
"selector" => attribute(action, :selector),
|
|
250
|
+
"arguments" => Array(attribute(action, :arguments))
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def attribute(action, name)
|
|
255
|
+
if action.respond_to?(name)
|
|
256
|
+
action.public_send(name)
|
|
257
|
+
elsif action.is_a?(Hash)
|
|
258
|
+
action[name.to_s] || action[name]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Converts SDK model objects into plain, string-keyed JSON-safe data.
|
|
263
|
+
def dump(value)
|
|
264
|
+
case value
|
|
265
|
+
when Hash
|
|
266
|
+
value.to_h { |key, nested| [ key.to_s, dump(nested) ] }
|
|
267
|
+
when Array
|
|
268
|
+
value.map { |item| dump(item) }
|
|
269
|
+
else
|
|
270
|
+
value.respond_to?(:to_h) && !value.is_a?(String) ? dump(value.to_h) : value
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def final_url(response)
|
|
275
|
+
result = response.data&.result
|
|
276
|
+
return nil unless result.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
result["url"] || result[:url]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def pinned_binary?
|
|
282
|
+
!@env[PINNED_BINARY_ENV].to_s.empty? || !@env[PINNED_VERSION_ENV].to_s.empty?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def default_base_url
|
|
286
|
+
local? ? "http://127.0.0.1" : DEFAULT_BASE_URL
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def presence!(value, name)
|
|
290
|
+
raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
291
|
+
|
|
292
|
+
value.dup.freeze
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def positive_number!(value, name)
|
|
296
|
+
unless value.is_a?(Numeric) && value.finite? && value.positive?
|
|
297
|
+
raise InvalidInput, "#{name} must be a positive finite number"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
value
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def server_mode!(value)
|
|
304
|
+
mode = value.to_s
|
|
305
|
+
return mode if %w[remote local].include?(mode)
|
|
306
|
+
|
|
307
|
+
raise InvalidInput, "server must be :remote or :local"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def relevant_environment(env)
|
|
311
|
+
Immutable.copy([ CUSTOM_HEADERS_ENV, PINNED_BINARY_ENV, PINNED_VERSION_ENV ].to_h { |key| [ key, env[key] ] })
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def environment_unchanged?
|
|
315
|
+
relevant_environment(@env) == @environment_snapshot &&
|
|
316
|
+
relevant_environment(ENV) == @process_environment_snapshot
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def ensure_qualified!
|
|
320
|
+
current = capabilities
|
|
321
|
+
unless current.enforced_egress? && (!current.local_mode? || current.local_binary_pinned?) &&
|
|
322
|
+
(!current.custom_headers_present? || current.custom_headers_approved?)
|
|
323
|
+
raise UnsupportedSecurityCapability,
|
|
324
|
+
"Stagehand backend is unsupported until provider egress and cleanup are qualified"
|
|
325
|
+
end
|
|
326
|
+
if !ENV[CUSTOM_HEADERS_ENV].to_s.empty? && !@custom_headers_approved
|
|
327
|
+
raise UnsupportedSecurityCapability, "Stagehand process custom headers are not approved"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Base class for the Gem's RubyLLM tools.
|
|
6
|
+
#
|
|
7
|
+
# A tool is a thin, policy-free adapter. The host injects exactly one trusted
|
|
8
|
+
# {Context}; the tool forwards model-supplied arguments to an {Engine}, and the
|
|
9
|
+
# engine enforces egress qualification, destination/schema/action policy, budgets,
|
|
10
|
+
# operation lifecycle and result redaction. A tool never invents an actor, a
|
|
11
|
+
# backend, a schema or an approval, and it never turns a typed failure into an
|
|
12
|
+
# untyped exception that would abort the chat.
|
|
13
|
+
#
|
|
14
|
+
# Model-facing names are declared explicitly so they stay stable across releases.
|
|
15
|
+
class Tool < RubyLLM::Tool
|
|
16
|
+
class << self
|
|
17
|
+
# Declares or returns the stable, model-facing tool name.
|
|
18
|
+
#
|
|
19
|
+
# tool_name "browser_inspect"
|
|
20
|
+
def tool_name(value = nil)
|
|
21
|
+
return @tool_name if value.nil?
|
|
22
|
+
|
|
23
|
+
@tool_name = value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :context
|
|
28
|
+
|
|
29
|
+
# @param context [Context] the host-supplied, trusted execution context
|
|
30
|
+
def initialize(context:)
|
|
31
|
+
super()
|
|
32
|
+
@context = context!(context)
|
|
33
|
+
@engine = Engine.new(context: @context)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Reports coarse progress when the host's RubyLLM exposes the 2.1+ API.
|
|
39
|
+
#
|
|
40
|
+
# The Gem's floor stays at RubyLLM 2.0, where {RubyLLM::Tool#progress} does not
|
|
41
|
+
# exist, so the call is feature-detected rather than assumed.
|
|
42
|
+
def report(message = nil, value: nil, total: nil)
|
|
43
|
+
return unless respond_to?(:progress)
|
|
44
|
+
|
|
45
|
+
progress(message, value: value, total: total)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Converts a typed engine error into a model-safe tool result. The stable error
|
|
49
|
+
# code is preserved; the message is Gem-authored and never contains secrets.
|
|
50
|
+
def failure(error)
|
|
51
|
+
message = Redactor.scrub_string(error.message, secrets: context.secrets)
|
|
52
|
+
Result.failure(:error, error_code: error.code, message: message).to_h
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unexpected_failure
|
|
56
|
+
failure(ProviderError.new("browser operation could not be completed"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def engine = @engine
|
|
60
|
+
|
|
61
|
+
def context!(value)
|
|
62
|
+
unless value.is_a?(Context)
|
|
63
|
+
raise InvalidInput, "context must be a RubyLLM::Browser::Context"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|