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,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Durable bookkeeping for browser operations.
|
|
6
|
+
#
|
|
7
|
+
# The host supplies an implementation. {InMemoryOperationStore} is a fixture for
|
|
8
|
+
# tests and single-process demos only; production multi-worker or restart-safe use
|
|
9
|
+
# requires a durable store (for example the ActiveRecord store in
|
|
10
|
+
# `ruby_llm/browser/rails`).
|
|
11
|
+
#
|
|
12
|
+
# Every method is expected to be atomic. Callers assume:
|
|
13
|
+
#
|
|
14
|
+
# - {#reserve} never starts two operations for one idempotency key
|
|
15
|
+
# - {#update} rejects a stale writer (fencing)
|
|
16
|
+
# - the reservation is held for `unknown` / `cleanup_pending`, not released early
|
|
17
|
+
class OperationStore
|
|
18
|
+
# Atomically stores a new operation and enforces the actor's concurrency budget.
|
|
19
|
+
# @raise [InvalidInput] the id already exists
|
|
20
|
+
# @raise [BudgetExhausted] the concurrency slot is taken
|
|
21
|
+
def reserve(_operation)
|
|
22
|
+
raise NotImplementedError, "OperationStore#reserve must be implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the existing operation for the same actor/run/idempotency key, or
|
|
26
|
+
# reserves +operation+. Duplicate jobs must attach, never start a second session.
|
|
27
|
+
def attach_or_reserve(_operation)
|
|
28
|
+
raise NotImplementedError, "OperationStore#attach_or_reserve must be implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Operation, nil]
|
|
32
|
+
def fetch(_id)
|
|
33
|
+
raise NotImplementedError, "OperationStore#fetch must be implemented"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the operation or raises {InvalidInput} when absent.
|
|
37
|
+
def fetch!(_id)
|
|
38
|
+
raise NotImplementedError, "OperationStore#fetch! must be implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Claims the operation for one worker, bumping its fence token.
|
|
42
|
+
#
|
|
43
|
+
# @return [Operation] the newly leased operation
|
|
44
|
+
# @raise [NotAuthorized] a different actor/run tries to claim it
|
|
45
|
+
def lease(_id, actor_id:, run_id:, now:)
|
|
46
|
+
raise NotImplementedError, "OperationStore#lease must be implemented"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Persists +operation+ only if its fence is current.
|
|
50
|
+
#
|
|
51
|
+
# @return [Operation] the stored operation
|
|
52
|
+
# @raise [InvalidInput] the writer is fenced (stale)
|
|
53
|
+
def update(_operation)
|
|
54
|
+
raise NotImplementedError, "OperationStore#update must be implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Atomically changes reserved to starting, checking the caller's fence. A second
|
|
58
|
+
# delivery must never start even when it read the row before the first claim.
|
|
59
|
+
def claim_start(_operation, now:)
|
|
60
|
+
raise NotImplementedError, "OperationStore#claim_start must be implemented"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Persists a returned session even if cancellation fenced its starting worker.
|
|
64
|
+
def record_session(_operation, session_id:, now:)
|
|
65
|
+
raise NotImplementedError, "OperationStore#record_session must be implemented"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Records provider-confirmed closure of this exact session, including cancellation.
|
|
69
|
+
def confirm_closed(_operation, session_id:, now:)
|
|
70
|
+
raise NotImplementedError, "OperationStore#confirm_closed must be implemented"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Marks an operation unable to confirm its own cleanup.
|
|
74
|
+
#
|
|
75
|
+
# @return [Operation]
|
|
76
|
+
def mark_cleanup_pending(_id, actor_id:, run_id:, error_code: nil, now:)
|
|
77
|
+
raise NotImplementedError, "OperationStore#mark_cleanup_pending must be implemented"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Array<Operation>] operations currently holding a slot for the actor
|
|
81
|
+
def open_for(_actor_id)
|
|
82
|
+
raise NotImplementedError, "OperationStore#open_for must be implemented"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Array<Operation>] every open operation across all actors. Used by the
|
|
86
|
+
# cleanup reaper, which is a host-operated, system-wide sweep.
|
|
87
|
+
def open_operations
|
|
88
|
+
raise NotImplementedError, "OperationStore#open_operations must be implemented"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Browser
|
|
8
|
+
# The host's source, schema, action and egress policy.
|
|
9
|
+
#
|
|
10
|
+
# Policy is composed by the host application and enforced by the Gem. It never
|
|
11
|
+
# infers permissions from model arguments or page text, and it fails closed: an
|
|
12
|
+
# unknown host, schema, action or an unqualified backend stops the operation.
|
|
13
|
+
class Policy
|
|
14
|
+
# JSON Schema keys this Gem accepts inside an extraction schema.
|
|
15
|
+
SCHEMA_KEYS = %w[
|
|
16
|
+
type description properties required items enum
|
|
17
|
+
additionalProperties title minimum maximum
|
|
18
|
+
minLength maxLength minItems maxItems
|
|
19
|
+
format default
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
ALLOWED_SCHEMA_TYPES = %w[object array string integer number boolean null].freeze
|
|
23
|
+
|
|
24
|
+
# Action methods an approved Act may use. There is no keypress/execute/download and
|
|
25
|
+
# no arbitrary JavaScript.
|
|
26
|
+
DEFAULT_ACTION_METHODS = %w[click fill press select_option check uncheck hover].freeze
|
|
27
|
+
|
|
28
|
+
# Sensitive operations are refused even with a broad host approval.
|
|
29
|
+
DEFAULT_FORBIDDEN_ACTIONS = [
|
|
30
|
+
/\b(?:purchase|buy|checkout|pay(?:ment)?|place[-_ ]?order|subscribe|billing)\b/i,
|
|
31
|
+
/\b(?:post|publish|tweet|toot|comment|reply|send(?:[-\s]?message)?|submit|share)\b/i,
|
|
32
|
+
/\b(?:delete|destroy|remove|deactivate|close[-_ ]?account|cancel[-_ ]?account|unsubscribe)\b/i,
|
|
33
|
+
/\b(?:password|passwd|credential|api[-_ ]?key|secret|credit[-_ ]?card|cvv|ssn|passport)\b/i,
|
|
34
|
+
/\b(?:sign[-\s]?in|log[-\s]?in|log[-\s]?out|authenticate|sign[-\s]?up|register|2fa|otp)\b/i,
|
|
35
|
+
/\b(?:transfer|withdraw|refund|donate)\b/i
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
DEFAULT_MAX_SCHEMA_DEPTH = 8
|
|
39
|
+
DEFAULT_MAX_SCHEMA_PROPERTIES = 64
|
|
40
|
+
DEFAULT_MAX_SCHEMA_BYTES = 16_384
|
|
41
|
+
|
|
42
|
+
attr_reader :allowed_hosts, :allowed_schemes, :allowed_ports, :max_result_bytes,
|
|
43
|
+
:allowed_action_methods, :forbidden_action_patterns,
|
|
44
|
+
:max_schema_depth, :max_schema_properties, :max_schema_bytes
|
|
45
|
+
|
|
46
|
+
def initialize(allowed_hosts:, allowed_schemes: UrlGuard::DEFAULT_SCHEMES,
|
|
47
|
+
allowed_ports: UrlGuard::DEFAULT_PORTS, verify_dns: true, resolver: nil,
|
|
48
|
+
max_result_bytes: nil,
|
|
49
|
+
allowed_action_methods: DEFAULT_ACTION_METHODS,
|
|
50
|
+
forbidden_action_patterns: DEFAULT_FORBIDDEN_ACTIONS,
|
|
51
|
+
max_schema_depth: DEFAULT_MAX_SCHEMA_DEPTH,
|
|
52
|
+
max_schema_properties: DEFAULT_MAX_SCHEMA_PROPERTIES,
|
|
53
|
+
max_schema_bytes: DEFAULT_MAX_SCHEMA_BYTES)
|
|
54
|
+
@url_guard = UrlGuard.new(
|
|
55
|
+
allowed_hosts: allowed_hosts, allowed_schemes: allowed_schemes,
|
|
56
|
+
allowed_ports: allowed_ports, verify_dns: verify_dns, resolver: resolver
|
|
57
|
+
)
|
|
58
|
+
@allowed_hosts = @url_guard.allowed_hosts
|
|
59
|
+
@allowed_schemes = @url_guard.allowed_schemes
|
|
60
|
+
@allowed_ports = @url_guard.allowed_ports
|
|
61
|
+
@max_result_bytes = positive_integer!(max_result_bytes || RubyLLM::Browser.configuration.max_result_bytes, :max_result_bytes)
|
|
62
|
+
@allowed_action_methods = Immutable.copy(normalize_action_methods!(allowed_action_methods))
|
|
63
|
+
@forbidden_action_patterns = (DEFAULT_FORBIDDEN_ACTIONS + Array(forbidden_action_patterns)).uniq.map do |pattern|
|
|
64
|
+
raise InvalidInput, "forbidden actions must be regexps" unless pattern.is_a?(Regexp)
|
|
65
|
+
|
|
66
|
+
pattern
|
|
67
|
+
end.freeze
|
|
68
|
+
@max_schema_depth = positive_integer!(max_schema_depth, :max_schema_depth)
|
|
69
|
+
@max_schema_properties = positive_integer!(max_schema_properties, :max_schema_properties)
|
|
70
|
+
@max_schema_bytes = positive_integer!(max_schema_bytes, :max_schema_bytes)
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Authorizes and normalizes a destination URL.
|
|
75
|
+
#
|
|
76
|
+
# @return [URI::Generic]
|
|
77
|
+
# @raise [InvalidInput] malformed or ambiguous URL
|
|
78
|
+
# @raise [NotAuthorized] policy denial
|
|
79
|
+
def authorize_destination!(url, actor_id: nil)
|
|
80
|
+
@url_guard.authorize!(url)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validates a model-supplied extraction schema: an object schema, bounded in depth,
|
|
84
|
+
# properties and bytes, using only known keys and types.
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash] the schema (JSON string keys)
|
|
87
|
+
def authorize_schema!(schema)
|
|
88
|
+
schema = coerce_schema!(schema)
|
|
89
|
+
raise InvalidInput, "extraction schema must be an object schema" unless schema.is_a?(Hash)
|
|
90
|
+
json = Redactor.canonical_json(schema)
|
|
91
|
+
raise InvalidInput, "extraction schema is too large" if json.bytesize > max_schema_bytes
|
|
92
|
+
raise InvalidInput, "extraction schema must be an object schema" unless schema["type"] == "object"
|
|
93
|
+
|
|
94
|
+
validate_schema_node!(schema, depth: 1)
|
|
95
|
+
unless JSONSchemer.valid_schema?(schema)
|
|
96
|
+
raise InvalidInput, "extraction schema is malformed"
|
|
97
|
+
end
|
|
98
|
+
properties = schema["properties"] || {}
|
|
99
|
+
if properties.size > max_schema_properties
|
|
100
|
+
raise InvalidInput, "extraction schema has too many properties"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Immutable.copy(schema)
|
|
104
|
+
rescue JSON::GeneratorError, JSON::NestingError, SystemStackError
|
|
105
|
+
raise InvalidInput, "extraction schema is not bounded JSON"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Rejects provider data that does not satisfy the host's JSON Schema.
|
|
109
|
+
def authorize_extraction!(data, schema:)
|
|
110
|
+
raise ProviderError, "browser extraction did not match the host schema" unless JSONSchemer.schema(schema).valid?(data)
|
|
111
|
+
|
|
112
|
+
data
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Redacts and bounds arbitrary extraction/observation data.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array(Object, Boolean)] bounded data and truncation flag
|
|
118
|
+
def authorize_result!(data, max_bytes: nil, secrets: [])
|
|
119
|
+
Redactor.bound(data, max_bytes: [ max_bytes || max_result_bytes, max_result_bytes ].min, secrets: secrets)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validates one observed/approved action hash.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash] the normalized action (`description`, `method`, `selector`, `arguments`)
|
|
125
|
+
def authorize_action!(action)
|
|
126
|
+
action = symbolize_action!(action)
|
|
127
|
+
method = action.fetch("method", nil)&.to_s&.downcase
|
|
128
|
+
selector = action["selector"].to_s
|
|
129
|
+
description = action["description"].to_s
|
|
130
|
+
arguments = Array(action["arguments"]).map(&:to_s)
|
|
131
|
+
|
|
132
|
+
raise InvalidInput, "action method is required" if method.to_s.empty?
|
|
133
|
+
raise InvalidInput, "action selector is required" if selector.empty?
|
|
134
|
+
unless allowed_action_methods.include?(method)
|
|
135
|
+
raise NotAuthorized, "action method #{method.inspect} is not allowed"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
haystack = ([ description, selector ] + arguments).join(" ")
|
|
139
|
+
if forbidden_action_patterns.any? { |pattern| pattern.match?(haystack) }
|
|
140
|
+
raise NotAuthorized, "action is outside the allowed, non-sensitive set"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
"description" => description.empty? ? "#{method} #{selector}" : description,
|
|
145
|
+
"method" => method,
|
|
146
|
+
"selector" => selector,
|
|
147
|
+
"arguments" => arguments
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# True when a backend proves the egress isolation this Gem requires.
|
|
152
|
+
def egress_qualified?(capabilities)
|
|
153
|
+
return false unless capabilities.is_a?(Capabilities)
|
|
154
|
+
return false unless capabilities.enforced_egress?
|
|
155
|
+
return false if capabilities.local_mode? && !capabilities.local_binary_pinned?
|
|
156
|
+
return false if capabilities.custom_headers_present? && !capabilities.custom_headers_approved?
|
|
157
|
+
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Raises unless the backend is egress-qualified. Called before any session starts.
|
|
162
|
+
def authorize_egress!(capabilities)
|
|
163
|
+
return true if egress_qualified?(capabilities)
|
|
164
|
+
|
|
165
|
+
raise UnsupportedSecurityCapability, egress_failure_reason(capabilities)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_h
|
|
169
|
+
{
|
|
170
|
+
allowed_hosts: allowed_hosts,
|
|
171
|
+
allowed_schemes: allowed_schemes,
|
|
172
|
+
allowed_ports: allowed_ports,
|
|
173
|
+
max_result_bytes: max_result_bytes,
|
|
174
|
+
allowed_action_methods: allowed_action_methods,
|
|
175
|
+
max_schema_depth: max_schema_depth
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def digest = Redactor.digest(to_h)
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def egress_failure_reason(capabilities)
|
|
184
|
+
return "backend did not report capabilities" unless capabilities.is_a?(Capabilities)
|
|
185
|
+
|
|
186
|
+
reasons = []
|
|
187
|
+
reasons << "browser egress isolation is not enforced" unless capabilities.enforced_egress?
|
|
188
|
+
if capabilities.local_mode? && !capabilities.local_binary_pinned?
|
|
189
|
+
reasons << "local mode requires a host-pinned server binary"
|
|
190
|
+
end
|
|
191
|
+
if capabilities.custom_headers_present? && !capabilities.custom_headers_approved?
|
|
192
|
+
reasons << "STAGEHAND_CUSTOM_HEADERS is set but not host-approved"
|
|
193
|
+
end
|
|
194
|
+
"backend cannot enforce egress isolation: #{reasons.join("; ")}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def coerce_schema!(schema)
|
|
198
|
+
case schema
|
|
199
|
+
when Hash
|
|
200
|
+
stringify_keys(schema)
|
|
201
|
+
when String
|
|
202
|
+
parse_schema_json!(schema)
|
|
203
|
+
when Class
|
|
204
|
+
if schema.respond_to?(:to_json_schema)
|
|
205
|
+
return stringify_keys(schema.to_json_schema)
|
|
206
|
+
end
|
|
207
|
+
if schema.instance_methods.include?(:to_json_schema)
|
|
208
|
+
return stringify_keys(schema.new.to_json_schema)
|
|
209
|
+
end
|
|
210
|
+
raise InvalidInput, "unsupported schema class"
|
|
211
|
+
else
|
|
212
|
+
if schema.respond_to?(:to_json_schema)
|
|
213
|
+
stringify_keys(schema.to_json_schema)
|
|
214
|
+
else
|
|
215
|
+
raise InvalidInput, "extraction schema must be a Hash or JSON object string"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def parse_schema_json!(string)
|
|
221
|
+
raise InvalidInput, "extraction schema is too large" if string.bytesize > max_schema_bytes
|
|
222
|
+
|
|
223
|
+
parsed = JSON.parse(string)
|
|
224
|
+
raise InvalidInput, "extraction schema must be a JSON object" unless parsed.is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
stringify_keys(parsed)
|
|
227
|
+
rescue JSON::ParserError
|
|
228
|
+
raise InvalidInput, "extraction schema is not valid JSON", cause: nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_schema_node!(node, depth:)
|
|
232
|
+
return unless node.is_a?(Hash) || node.is_a?(Array)
|
|
233
|
+
|
|
234
|
+
if depth > max_schema_depth
|
|
235
|
+
raise InvalidInput, "extraction schema is nested too deeply"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
case node
|
|
239
|
+
when Array
|
|
240
|
+
node.each { |item| validate_schema_node!(item, depth: depth + 1) }
|
|
241
|
+
when Hash
|
|
242
|
+
node.each do |key, value|
|
|
243
|
+
name = key.to_s
|
|
244
|
+
unless SCHEMA_KEYS.include?(name)
|
|
245
|
+
raise InvalidInput, "unsupported schema key #{name.inspect}"
|
|
246
|
+
end
|
|
247
|
+
validate_schema_type!(value) if name == "type"
|
|
248
|
+
if name == "properties"
|
|
249
|
+
# Values are schemas; property names themselves are arbitrary.
|
|
250
|
+
value.each_value { |subschema| validate_schema_node!(subschema, depth: depth + 1) } if value.is_a?(Hash)
|
|
251
|
+
elsif name == "items" || name == "additionalProperties"
|
|
252
|
+
validate_schema_node!(value, depth: depth + 1)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_schema_type!(value)
|
|
259
|
+
types = Array(value).map(&:to_s)
|
|
260
|
+
invalid = types - ALLOWED_SCHEMA_TYPES
|
|
261
|
+
raise InvalidInput, "unsupported schema type #{invalid.inspect}" if invalid.any?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def symbolize_action!(action)
|
|
265
|
+
raise InvalidInput, "action must be a Hash" unless action.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
action.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def stringify_keys(value)
|
|
271
|
+
case value
|
|
272
|
+
when Hash
|
|
273
|
+
value.each_with_object({}) { |(key, nested), result| result[key.to_s] = stringify_keys(nested) }
|
|
274
|
+
when Array
|
|
275
|
+
value.map { |item| stringify_keys(item) }
|
|
276
|
+
else
|
|
277
|
+
value
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def normalize_action_methods!(methods)
|
|
282
|
+
normalized = Array(methods).map { |method| method.to_s.downcase }
|
|
283
|
+
raise InvalidInput, "allowed_action_methods must not be empty" if normalized.empty?
|
|
284
|
+
unless (normalized - DEFAULT_ACTION_METHODS).empty?
|
|
285
|
+
raise InvalidInput, "only supported non-script action methods are allowed"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
normalized.uniq
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def positive_integer!(value, name)
|
|
292
|
+
raise InvalidInput, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive?
|
|
293
|
+
|
|
294
|
+
value
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
module Rails
|
|
6
|
+
# Durable {RubyLLM::Browser::ApprovalStore} backed by ActiveRecord.
|
|
7
|
+
#
|
|
8
|
+
# {#consume_once} performs every check and uses a conditional database update:
|
|
9
|
+
# existence, active, actor/run, URL, page revision, operation/session and exact
|
|
10
|
+
# action digest — before it marks the row consumed. Two workers cannot both consume
|
|
11
|
+
# the same grant, and a replayed or cross-actor approval is refused.
|
|
12
|
+
class ActiveRecordApprovalStore < ApprovalStore
|
|
13
|
+
def initialize(record_class: ApprovalRecord)
|
|
14
|
+
super()
|
|
15
|
+
@record_class = record_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def issue(approval)
|
|
19
|
+
@record_class.create!(
|
|
20
|
+
id: approval.id, actor_id: approval.actor_id, run_id: approval.run_id,
|
|
21
|
+
url: approval.url, action_digest: approval.action_digest,
|
|
22
|
+
action: @record_class.dump_action(approval.action),
|
|
23
|
+
page_revision: approval.page_revision, operation_id: approval.operation_id,
|
|
24
|
+
session_id: approval.session_id, issued_at: approval.issued_at,
|
|
25
|
+
expires_at: approval.expires_at, consumed_at: approval.consumed_at,
|
|
26
|
+
revoked: approval.revoked?, created_at: approval.issued_at,
|
|
27
|
+
updated_at: approval.issued_at
|
|
28
|
+
)
|
|
29
|
+
approval
|
|
30
|
+
rescue ActiveRecord::RecordNotUnique
|
|
31
|
+
raise InvalidInput, "approval #{approval.id} already exists"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch(id)
|
|
35
|
+
@record_class.find_by(id: id)&.to_approval
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def consume_once(id, actor_id:, run_id:, url:, action_digest:, now:, page_revision: nil,
|
|
39
|
+
operation_id: nil, session_id: nil)
|
|
40
|
+
record = @record_class.find_by(id: id)
|
|
41
|
+
raise ApprovalRequired, "no approval was issued" unless record
|
|
42
|
+
approval = record.to_approval
|
|
43
|
+
raise ApprovalStale, "approval #{id} was already consumed" if approval.consumed?
|
|
44
|
+
raise ApprovalStale, "approval #{id} was revoked" if approval.revoked?
|
|
45
|
+
raise ApprovalStale, "approval #{id} has expired" if approval.expired?(now)
|
|
46
|
+
|
|
47
|
+
unless approval.binding_matches?(
|
|
48
|
+
actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
|
|
49
|
+
page_revision: page_revision, operation_id: operation_id, session_id: session_id
|
|
50
|
+
)
|
|
51
|
+
raise ApprovalStale, "approval #{id} does not authorize this actor, URL, page or action"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
consumed = approval.consume!(now: now)
|
|
55
|
+
changed = @record_class.where(id: id, consumed_at: nil, revoked: false)
|
|
56
|
+
.where("expires_at > ?", now)
|
|
57
|
+
.update_all(consumed_at: now, updated_at: now)
|
|
58
|
+
raise ApprovalStale, "approval is no longer active" unless changed == 1
|
|
59
|
+
|
|
60
|
+
consumed
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def revoke(id)
|
|
64
|
+
changed = @record_class.where(id: id).update_all(revoked: true, updated_at: Time.now.utc)
|
|
65
|
+
raise ApprovalRequired, "no approval was issued" if changed.zero?
|
|
66
|
+
|
|
67
|
+
fetch(id)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
module Rails
|
|
6
|
+
# Durable, multi-worker {RubyLLM::Browser::OperationStore} backed by ActiveRecord.
|
|
7
|
+
#
|
|
8
|
+
# Supports SQLite and PostgreSQL. Reservations acquire a durable per-actor write
|
|
9
|
+
# lock, including when the actor has no existing operations. Worker writes check
|
|
10
|
+
# the exact fence and legal transition. No database transaction spans network I/O.
|
|
11
|
+
#
|
|
12
|
+
# - a duplicate reservation loses the unique-index race and is reported as
|
|
13
|
+
# {InvalidInput} instead of silently starting a second session
|
|
14
|
+
# - a duplicated worker that re-leases an operation bumps its fence, and any
|
|
15
|
+
# earlier writer is then refused as stale (fenced out)
|
|
16
|
+
# - a cancelled operation keeps its row and reservation until closure/expiry is
|
|
17
|
+
# proven; cancellation fences stale workers by bumping the fence
|
|
18
|
+
#
|
|
19
|
+
# The store never interprets provider truth: it records the states the engine and
|
|
20
|
+
# reaper report.
|
|
21
|
+
class ActiveRecordOperationStore < OperationStore
|
|
22
|
+
def initialize(record_class: OperationRecord)
|
|
23
|
+
super()
|
|
24
|
+
@record_class = record_class
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reserve(operation)
|
|
28
|
+
@record_class.transaction do
|
|
29
|
+
lock_actor!(operation.actor_id)
|
|
30
|
+
if @record_class.exists?(operation.id)
|
|
31
|
+
raise InvalidInput, "operation #{operation.id} is already reserved"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
enforce_concurrency!(operation)
|
|
35
|
+
insert!(operation)
|
|
36
|
+
end
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique
|
|
38
|
+
raise InvalidInput, "operation #{operation.id} is already reserved"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attach_or_reserve(operation)
|
|
42
|
+
@record_class.transaction do
|
|
43
|
+
lock_actor!(operation.actor_id)
|
|
44
|
+
existing = find_by_idempotency_key(operation.actor_id, operation.run_id, operation.idempotency_key)
|
|
45
|
+
if existing
|
|
46
|
+
unless existing.request_digest == operation.request_digest
|
|
47
|
+
raise InvalidInput, "idempotency key belongs to a different request"
|
|
48
|
+
end
|
|
49
|
+
return existing.to_operation
|
|
50
|
+
end
|
|
51
|
+
raise InvalidInput, "operation is already reserved" if @record_class.exists?(operation.id)
|
|
52
|
+
|
|
53
|
+
enforce_concurrency!(operation)
|
|
54
|
+
insert!(operation)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch(id)
|
|
59
|
+
@record_class.find_by(id: id)&.to_operation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fetch!(id)
|
|
63
|
+
fetch(id) || raise(InvalidInput, "unknown operation #{id.inspect}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Claims the operation for one worker by bumping its fence token.
|
|
67
|
+
def lease(id, actor_id:, run_id:, now:)
|
|
68
|
+
@record_class.transaction do
|
|
69
|
+
record = locked!(id)
|
|
70
|
+
assert_actor!(record, actor_id, run_id)
|
|
71
|
+
if record.to_operation.terminal?
|
|
72
|
+
raise InvalidInput, "operation #{id} is already #{record.state}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
leased = record.to_operation.with_fence(record.fence + 1, now: now)
|
|
76
|
+
persist!(record, leased)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Persists +operation+ only when its fence is current.
|
|
81
|
+
def update(operation)
|
|
82
|
+
@record_class.transaction do
|
|
83
|
+
record = locked!(operation.id)
|
|
84
|
+
operation.validate_update!(record.to_operation)
|
|
85
|
+
persist!(record, operation)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def claim_start(operation, now:)
|
|
90
|
+
@record_class.transaction do
|
|
91
|
+
record = locked!(operation.id)
|
|
92
|
+
current = record.to_operation
|
|
93
|
+
operation.validate_update!(current)
|
|
94
|
+
raise InvalidInput, "operation was already started" unless current.state == :reserved
|
|
95
|
+
raise BudgetExhausted, "operation wall-clock budget exhausted" if current.expired?(now)
|
|
96
|
+
|
|
97
|
+
persist!(record, current.advance(:starting, now: now))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record_session(operation, session_id:, now:)
|
|
102
|
+
@record_class.transaction do
|
|
103
|
+
record = locked!(operation.id)
|
|
104
|
+
assert_actor!(record, operation.actor_id, operation.run_id)
|
|
105
|
+
current = record.to_operation
|
|
106
|
+
if current.session_id && current.session_id != session_id
|
|
107
|
+
raise InvalidInput, "operation session cannot change"
|
|
108
|
+
end
|
|
109
|
+
target = current.fence == operation.fence ? :active : :cleanup_pending
|
|
110
|
+
persist!(record, current.advance(target, session_id: session_id, now: now))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def confirm_closed(operation, session_id:, now:)
|
|
115
|
+
@record_class.transaction do
|
|
116
|
+
record = locked!(operation.id)
|
|
117
|
+
assert_actor!(record, operation.actor_id, operation.run_id)
|
|
118
|
+
current = record.to_operation
|
|
119
|
+
raise InvalidInput, "operation session cannot change" unless current.session_id == session_id
|
|
120
|
+
|
|
121
|
+
target = current.cancel_requested? ? :cancelled : :closed
|
|
122
|
+
persist!(record, current.advance(target, now: now))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def mark_cleanup_pending(id, actor_id:, run_id:, error_code: nil, now:)
|
|
127
|
+
@record_class.transaction do
|
|
128
|
+
record = locked!(id)
|
|
129
|
+
assert_actor!(record, actor_id, run_id)
|
|
130
|
+
updated = record.to_operation.advance(:cleanup_pending, now: now, error_code: error_code)
|
|
131
|
+
persist!(record, updated)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Host-initiated cancellation. Marks the operation and fences stale workers.
|
|
136
|
+
#
|
|
137
|
+
# @return [Operation] the cancelled (or already terminal) operation
|
|
138
|
+
def cancel(id, actor_id:, run_id:, now:)
|
|
139
|
+
@record_class.transaction do
|
|
140
|
+
record = locked!(id)
|
|
141
|
+
assert_actor!(record, actor_id, run_id)
|
|
142
|
+
current = record.to_operation
|
|
143
|
+
return current if current.terminal? || current.cancel_requested?
|
|
144
|
+
|
|
145
|
+
cancelled = current.with_fence(record.fence + 1, now: now).request_cancel(now: now)
|
|
146
|
+
persist!(record, cancelled)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def open_for(actor_id)
|
|
151
|
+
@record_class.where(actor_id: actor_id).where(state: open_states).order(:created_at).map(&:to_operation)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def open_operations
|
|
155
|
+
@record_class.where(state: open_states).order(:created_at).map(&:to_operation)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Finds an existing operation by its actor/run idempotency key.
|
|
159
|
+
def find_by_idempotency_key(actor_id, run_id, key)
|
|
160
|
+
return nil if key.nil?
|
|
161
|
+
|
|
162
|
+
@record_class.find_by(actor_id: actor_id, run_id: run_id, idempotency_key: key)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def locked!(id)
|
|
168
|
+
# SQLite ignores SELECT FOR UPDATE. Acquire its write lock before reading
|
|
169
|
+
# so a second worker cannot read the old state and later overwrite it.
|
|
170
|
+
if @record_class.connection.adapter_name == "SQLite"
|
|
171
|
+
@record_class.where(id: id).update_all("fence = fence")
|
|
172
|
+
end
|
|
173
|
+
@record_class.lock.find_by(id: id) || raise(InvalidInput, "unknown operation #{id.inspect}")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def open_states = Operation::OPEN_STATES.map(&:to_s)
|
|
177
|
+
|
|
178
|
+
# The first statement acquires SQLite's write lock before any reads. On
|
|
179
|
+
# PostgreSQL both statements serialize on the same primary-key row.
|
|
180
|
+
def lock_actor!(actor_id)
|
|
181
|
+
unless %w[SQLite PostgreSQL].include?(@record_class.connection.adapter_name)
|
|
182
|
+
raise UnsupportedSecurityCapability, "durable store supports SQLite and PostgreSQL only"
|
|
183
|
+
end
|
|
184
|
+
ActorRecord.insert_all([ { id: actor_id, sequence: 0 } ], unique_by: :id)
|
|
185
|
+
ActorRecord.where(id: actor_id).update_all("sequence = sequence + 1")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def enforce_concurrency!(operation)
|
|
189
|
+
open = @record_class.where(actor_id: operation.actor_id).where(state: open_states).count
|
|
190
|
+
return if open < operation.budget.max_concurrent
|
|
191
|
+
|
|
192
|
+
raise BudgetExhausted,
|
|
193
|
+
"actor #{operation.actor_id.inspect} already holds #{open} open browser operation(s)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def assert_actor!(record, actor_id, run_id)
|
|
197
|
+
return if record.actor_id == actor_id && record.run_id == run_id
|
|
198
|
+
|
|
199
|
+
raise NotAuthorized, "operation #{record.id} belongs to a different actor or run"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def insert!(operation)
|
|
203
|
+
record = @record_class.new(id: operation.id)
|
|
204
|
+
persist!(record, operation)
|
|
205
|
+
# Databases persist timestamps at their configured precision. Return that
|
|
206
|
+
# canonical identity rather than a nanosecond value that cannot round-trip.
|
|
207
|
+
record.reload.to_operation
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def persist!(record, operation)
|
|
211
|
+
record.actor_id = operation.actor_id
|
|
212
|
+
record.run_id = operation.run_id
|
|
213
|
+
record.state = operation.state.to_s
|
|
214
|
+
record.session_id = operation.session_id
|
|
215
|
+
record.idempotency_key = operation.idempotency_key
|
|
216
|
+
record.request_digest = operation.request_digest
|
|
217
|
+
record.steps = operation.steps
|
|
218
|
+
record.bytes = operation.bytes
|
|
219
|
+
record.fence = operation.fence
|
|
220
|
+
record.error_code = operation.error_code
|
|
221
|
+
record.cancel_requested = operation.cancel_requested?
|
|
222
|
+
record.budget = @record_class.dump_budget(operation.budget)
|
|
223
|
+
record.created_at = operation.created_at
|
|
224
|
+
record.updated_at = operation.updated_at
|
|
225
|
+
record.save!
|
|
226
|
+
operation
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|