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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/CODE_OF_CONDUCT.md +11 -0
  4. data/CONTRIBUTING.md +42 -0
  5. data/LICENSE.txt +21 -0
  6. data/NOTICE.md +92 -0
  7. data/README.md +139 -0
  8. data/SECURITY.md +46 -0
  9. data/docs/API.md +177 -0
  10. data/docs/CNAME +1 -0
  11. data/docs/RAILS.md +167 -0
  12. data/docs/README.md +16 -0
  13. data/docs/RELEASE_ACTION_PLAN.md +81 -0
  14. data/docs/RELEASE_AUDIT.md +158 -0
  15. data/docs/RELEASING.md +131 -0
  16. data/docs/SECURITY_MODEL.md +117 -0
  17. data/docs/SPEC_BASELINE.md +107 -0
  18. data/docs/WORK.md +260 -0
  19. data/docs/_advanced/live-backend-qualification.md +74 -0
  20. data/docs/_advanced/rails-integration.md +56 -0
  21. data/docs/_config.yml +109 -0
  22. data/docs/_core_features/inspect-extract.md +91 -0
  23. data/docs/_core_features/offline-act.md +110 -0
  24. data/docs/_core_features/operation-lifecycle.md +63 -0
  25. data/docs/_core_features/source-policy.md +61 -0
  26. data/docs/_data/navigation.yml +12 -0
  27. data/docs/_data/sidebar.yml +8 -0
  28. data/docs/_data/social_links.yml +3 -0
  29. data/docs/_getting_started/configuration.md +68 -0
  30. data/docs/_getting_started/getting-started.md +77 -0
  31. data/docs/_getting_started/overview.md +52 -0
  32. data/docs/_plugins/versioned_docs.rb +46 -0
  33. data/docs/_reference/api-reference.md +29 -0
  34. data/docs/_reference/release-history.md +23 -0
  35. data/docs/_reference/upgrading.md +33 -0
  36. data/docs/bin/build-api.sh +23 -0
  37. data/docs/bin/build.sh +161 -0
  38. data/docs/bin/export-api-markdown.rb +53 -0
  39. data/docs/bin/link_versions.rb +54 -0
  40. data/docs/bin/postprocess_api_seo.rb +129 -0
  41. data/docs/bin/prepare_versions.rb +40 -0
  42. data/docs/bin/serve.sh +12 -0
  43. data/docs/bin/versions.yml +14 -0
  44. data/docs/index.md +82 -0
  45. data/examples/plain_ruby.rb +34 -0
  46. data/examples/rails_adapter.rb +47 -0
  47. data/lib/generators/ruby_llm/browser/install_generator.rb +29 -0
  48. data/lib/generators/ruby_llm/browser/templates/create_ruby_llm_browser_tables.rb.tt +60 -0
  49. data/lib/ruby_llm/browser/approval.rb +128 -0
  50. data/lib/ruby_llm/browser/approval_store.rb +38 -0
  51. data/lib/ruby_llm/browser/backend.rb +99 -0
  52. data/lib/ruby_llm/browser/budget.rb +90 -0
  53. data/lib/ruby_llm/browser/capabilities.rb +76 -0
  54. data/lib/ruby_llm/browser/configuration.rb +74 -0
  55. data/lib/ruby_llm/browser/context.rb +117 -0
  56. data/lib/ruby_llm/browser/doctor.rb +134 -0
  57. data/lib/ruby_llm/browser/engine.rb +326 -0
  58. data/lib/ruby_llm/browser/errors.rb +86 -0
  59. data/lib/ruby_llm/browser/fake_backend.rb +134 -0
  60. data/lib/ruby_llm/browser/immutable.rb +19 -0
  61. data/lib/ruby_llm/browser/in_memory_approval_store.rb +67 -0
  62. data/lib/ruby_llm/browser/in_memory_operation_store.rb +174 -0
  63. data/lib/ruby_llm/browser/operation.rb +218 -0
  64. data/lib/ruby_llm/browser/operation_store.rb +92 -0
  65. data/lib/ruby_llm/browser/policy.rb +298 -0
  66. data/lib/ruby_llm/browser/rails/active_record_approval_store.rb +72 -0
  67. data/lib/ruby_llm/browser/rails/active_record_operation_store.rb +231 -0
  68. data/lib/ruby_llm/browser/rails/actor_record.rb +13 -0
  69. data/lib/ruby_llm/browser/rails/approval_record.rb +49 -0
  70. data/lib/ruby_llm/browser/rails/operation_job.rb +163 -0
  71. data/lib/ruby_llm/browser/rails/operation_record.rb +56 -0
  72. data/lib/ruby_llm/browser/rails/reaper.rb +117 -0
  73. data/lib/ruby_llm/browser/rails/schema.rb +81 -0
  74. data/lib/ruby_llm/browser/rails.rb +79 -0
  75. data/lib/ruby_llm/browser/redactor.rb +159 -0
  76. data/lib/ruby_llm/browser/result.rb +102 -0
  77. data/lib/ruby_llm/browser/stagehand_backend.rb +332 -0
  78. data/lib/ruby_llm/browser/tool.rb +70 -0
  79. data/lib/ruby_llm/browser/tools/act.rb +91 -0
  80. data/lib/ruby_llm/browser/tools/extract.rb +60 -0
  81. data/lib/ruby_llm/browser/tools/inspect.rb +38 -0
  82. data/lib/ruby_llm/browser/url_guard.rb +253 -0
  83. data/lib/ruby_llm/browser/version.rb +9 -0
  84. data/lib/ruby_llm/browser.rb +79 -0
  85. 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