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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Browser
|
|
7
|
+
# Process-wide defaults. Per-call behaviour belongs on {Context}, {Policy} and the
|
|
8
|
+
# backend; this object only holds values that are safe to share.
|
|
9
|
+
#
|
|
10
|
+
# Requiring the Gem never reads credentials or environment variables; configuration
|
|
11
|
+
# is explicit.
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_MAX_RESULT_BYTES = 65_536 # 64 KiB of sanitized result data
|
|
14
|
+
|
|
15
|
+
# Reserved host configuration slot. Context still requires an explicit backend;
|
|
16
|
+
# setting this never enables a fallback or changes an existing tool's backend.
|
|
17
|
+
attr_accessor :backend
|
|
18
|
+
|
|
19
|
+
attr_reader :clock, :default_budget, :max_result_bytes
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@backend = nil
|
|
23
|
+
@logger = nil
|
|
24
|
+
@clock = -> { Time.now.utc }
|
|
25
|
+
@default_budget = Budget.new
|
|
26
|
+
@max_result_bytes = DEFAULT_MAX_RESULT_BYTES
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The logger used for audit lines. Falls back to RubyLLM's logger when present.
|
|
30
|
+
def logger
|
|
31
|
+
@logger || default_logger
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_writer :logger
|
|
35
|
+
|
|
36
|
+
def clock=(callable)
|
|
37
|
+
unless callable.respond_to?(:call)
|
|
38
|
+
raise InvalidInput, "clock must respond to #call and return a Time"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@clock = callable
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_budget=(budget)
|
|
45
|
+
raise InvalidInput, "default_budget must be a RubyLLM::Browser::Budget" unless budget.is_a?(Budget)
|
|
46
|
+
|
|
47
|
+
@default_budget = budget
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def max_result_bytes=(value)
|
|
51
|
+
unless value.is_a?(Integer) && value.positive?
|
|
52
|
+
raise InvalidInput, "max_result_bytes must be a positive integer"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@max_result_bytes = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Current UTC time from the configured clock.
|
|
59
|
+
def now
|
|
60
|
+
clock.call
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def default_logger
|
|
66
|
+
if defined?(RubyLLM) && RubyLLM.respond_to?(:logger) && RubyLLM.logger
|
|
67
|
+
RubyLLM.logger
|
|
68
|
+
else
|
|
69
|
+
Logger.new($stderr, level: Logger::WARN)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Trusted, host-supplied execution context.
|
|
6
|
+
#
|
|
7
|
+
# Context is created by the host application — not the model and not page text — and
|
|
8
|
+
# carries identity, the source policy, the backend, stores, budgets and the data-
|
|
9
|
+
# sharing consent decision. Tools never invent any of it.
|
|
10
|
+
class Context
|
|
11
|
+
attr_reader :actor_id, :run_id, :backend, :source_policy, :operation_store,
|
|
12
|
+
:approval_store, :budget, :consent, :metadata, :logger
|
|
13
|
+
|
|
14
|
+
# @param source_policy [Policy] the host's URL/schema/action/egress policy
|
|
15
|
+
# @param backend [Backend] an egress-qualified backend
|
|
16
|
+
# @param operation_store [OperationStore] durable operation bookkeeping
|
|
17
|
+
# @param approval_store [ApprovalStore, nil] required only for Act
|
|
18
|
+
# @param budget [Budget, nil] defaults to the process default budget
|
|
19
|
+
# @param clock [#call, nil] returns the current UTC Time
|
|
20
|
+
# @param logger [Logger, nil]
|
|
21
|
+
# @param consent [Object, nil] host record of the remote-processing consent
|
|
22
|
+
def initialize(actor_id:, run_id:, backend:, source_policy:, operation_store:,
|
|
23
|
+
approval_store: nil, budget: nil, clock: nil, logger: nil, consent: nil,
|
|
24
|
+
metadata: {})
|
|
25
|
+
@actor_id = presence!(actor_id, :actor_id)
|
|
26
|
+
@run_id = presence!(run_id, :run_id)
|
|
27
|
+
@backend = backend!(backend)
|
|
28
|
+
@source_policy = policy!(source_policy)
|
|
29
|
+
@operation_store = store!(operation_store)
|
|
30
|
+
@approval_store = approval_store!(approval_store)
|
|
31
|
+
@budget = budget.nil? ? RubyLLM::Browser.configuration.default_budget : budget!(budget)
|
|
32
|
+
@clock = clock || RubyLLM::Browser.clock
|
|
33
|
+
@logger = logger || RubyLLM::Browser.logger
|
|
34
|
+
@consent = consent
|
|
35
|
+
@metadata = Immutable.copy(metadata || {})
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The host policy.
|
|
40
|
+
def policy = source_policy
|
|
41
|
+
|
|
42
|
+
def capabilities
|
|
43
|
+
backend.capabilities
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Current UTC time from the context clock.
|
|
47
|
+
def now
|
|
48
|
+
@clock.call
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Exact secret values the backend wants scrubbed from page text and results.
|
|
52
|
+
def secrets
|
|
53
|
+
backend.respond_to?(:secrets) ? Array(backend.secrets) : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An audit-safe summary. Never includes backend credentials.
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
actor_id: actor_id,
|
|
60
|
+
run_id: run_id,
|
|
61
|
+
budget: budget.to_h,
|
|
62
|
+
consent: !consent.nil?,
|
|
63
|
+
capabilities: capabilities.respond_to?(:to_h) ? capabilities.to_h : capabilities
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inspect
|
|
68
|
+
"#<#{self.class} actor=#{actor_id} run=#{run_id} backend=#{backend.class}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def presence!(value, name)
|
|
74
|
+
raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
75
|
+
|
|
76
|
+
value.dup.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def backend!(value)
|
|
80
|
+
unless value.is_a?(Backend)
|
|
81
|
+
raise InvalidInput, "backend must be a RubyLLM::Browser::Backend"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def policy!(value)
|
|
88
|
+
raise InvalidInput, "source_policy must be a RubyLLM::Browser::Policy" unless value.is_a?(Policy)
|
|
89
|
+
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def store!(value)
|
|
94
|
+
unless value.is_a?(OperationStore)
|
|
95
|
+
raise InvalidInput, "operation_store must be a RubyLLM::Browser::OperationStore"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def approval_store!(value)
|
|
102
|
+
return nil if value.nil?
|
|
103
|
+
unless value.is_a?(ApprovalStore)
|
|
104
|
+
raise InvalidInput, "approval_store must be a RubyLLM::Browser::ApprovalStore"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def budget!(value)
|
|
111
|
+
raise InvalidInput, "budget must be a RubyLLM::Browser::Budget" unless value.is_a?(Budget)
|
|
112
|
+
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Read-only preflight diagnostics for a live browser deployment.
|
|
6
|
+
#
|
|
7
|
+
# The Doctor never makes a network call, never starts a session and never prints a
|
|
8
|
+
# credential. It reports the version pins, the environment overrides that the SDK
|
|
9
|
+
# would otherwise honour, and whether the configured backend is qualified to start a
|
|
10
|
+
# session. A `:fail` check means live use must not proceed.
|
|
11
|
+
#
|
|
12
|
+
# report = RubyLLM::Browser::Doctor.new(backend: backend).report
|
|
13
|
+
# raise report[:summary] unless report[:ok]
|
|
14
|
+
class Doctor
|
|
15
|
+
STATUSES = %i[pass warn info fail].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :backend, :env
|
|
18
|
+
|
|
19
|
+
# @param backend [Backend, nil]
|
|
20
|
+
# @param env [Hash] environment to inspect; injectable for tests
|
|
21
|
+
def initialize(backend: nil, env: ENV)
|
|
22
|
+
@backend = backend
|
|
23
|
+
@env = env
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] `{ ok:, summary:, checks: [...] }`, safe to log
|
|
27
|
+
def report
|
|
28
|
+
checks = [
|
|
29
|
+
ruby_llm_check, stagehand_check, egress_check, local_binary_check,
|
|
30
|
+
custom_headers_check, base_url_check, credentials_check
|
|
31
|
+
].compact
|
|
32
|
+
{
|
|
33
|
+
ok: checks.none? { |check| check[:status] == :fail },
|
|
34
|
+
summary: summary_for(checks),
|
|
35
|
+
checks: checks
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ok? = report[:ok]
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def ruby_llm_check
|
|
44
|
+
version = Gem::Version.new(RubyLLM::VERSION)
|
|
45
|
+
required = Gem::Version.new("2.0.0")
|
|
46
|
+
ceiling = Gem::Version.new("3.0.0")
|
|
47
|
+
if version >= required && version < ceiling
|
|
48
|
+
check(:ruby_llm, :pass, "RubyLLM #{RubyLLM::VERSION} is within >= 2.0, < 3")
|
|
49
|
+
else
|
|
50
|
+
check(:ruby_llm, :fail, "RubyLLM #{RubyLLM::VERSION} is outside the supported range >= 2.0, < 3")
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
check(:ruby_llm, :warn, "could not determine the RubyLLM version: #{e.class}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stagehand_check
|
|
57
|
+
require "stagehand/version"
|
|
58
|
+
unless Gem::Requirement.new("~> 3.21.0").satisfied_by?(Gem::Version.new(Stagehand::VERSION))
|
|
59
|
+
return check(:stagehand, :fail, "Stagehand version is outside the tested 3.21.x series")
|
|
60
|
+
end
|
|
61
|
+
check(:stagehand, :pass, "stagehand #{Stagehand::VERSION} is available")
|
|
62
|
+
rescue LoadError
|
|
63
|
+
check(:stagehand, :warn, "the stagehand SDK is not installed; only fake backends can start")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def egress_check
|
|
67
|
+
return check(:egress_isolation, :fail, "no backend was supplied") unless backend
|
|
68
|
+
|
|
69
|
+
capabilities = backend.capabilities
|
|
70
|
+
if backend.is_a?(StagehandBackend)
|
|
71
|
+
return check(:egress_isolation, :fail,
|
|
72
|
+
"Stagehand backend is unsupported until provider egress and cleanup are qualified")
|
|
73
|
+
end
|
|
74
|
+
if capabilities.enforced_egress?
|
|
75
|
+
check(:egress_isolation, :pass, "the backend asserts enforced, browser-side egress isolation")
|
|
76
|
+
else
|
|
77
|
+
check(:egress_isolation, :fail,
|
|
78
|
+
"the backend does not assert enforced egress isolation; live sessions are refused")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def local_binary_check
|
|
83
|
+
return nil unless backend&.capabilities&.local_mode?
|
|
84
|
+
|
|
85
|
+
if backend.capabilities.local_binary_pinned?
|
|
86
|
+
check(:local_binary, :pass, "local mode uses a host-pinned server binary or version")
|
|
87
|
+
else
|
|
88
|
+
check(:local_binary, :fail,
|
|
89
|
+
"local mode has no host-pinned #{StagehandBackend::PINNED_BINARY_ENV} or " \
|
|
90
|
+
"#{StagehandBackend::PINNED_VERSION_ENV}; the SDK would download a binary")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def custom_headers_check
|
|
95
|
+
return nil unless backend&.capabilities&.custom_headers_present?
|
|
96
|
+
|
|
97
|
+
if backend.capabilities.custom_headers_approved?
|
|
98
|
+
check(:custom_headers, :pass, "#{StagehandBackend::CUSTOM_HEADERS_ENV} is set and host-approved")
|
|
99
|
+
else
|
|
100
|
+
check(:custom_headers, :fail,
|
|
101
|
+
"#{StagehandBackend::CUSTOM_HEADERS_ENV} is set but not host-approved; the SDK always merges it")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def base_url_check
|
|
106
|
+
present = StagehandBackend::ENV_OVERRIDE_KEYS.select { |key| !env[key].to_s.empty? }
|
|
107
|
+
return nil if present.empty?
|
|
108
|
+
|
|
109
|
+
check(:base_url_override, :warn,
|
|
110
|
+
"#{present.join(", ")} set; the adapter passes an explicit base_url so the override is ignored")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def credentials_check
|
|
114
|
+
missing = %w[BROWSERBASE_API_KEY MODEL_API_KEY].reject { |key| !env[key].to_s.empty? }
|
|
115
|
+
return check(:credentials, :info, "both provider credentials are present in the environment") if missing.empty?
|
|
116
|
+
|
|
117
|
+
check(:credentials, :warn, "#{missing.join(", ")} not set in the environment")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check(name, status, detail)
|
|
121
|
+
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
122
|
+
|
|
123
|
+
{ name: name, status: status, detail: detail }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def summary_for(checks)
|
|
127
|
+
failed = checks.select { |check| check[:status] == :fail }
|
|
128
|
+
return "configuration checks passed; live provider guarantees are not verified" if failed.empty?
|
|
129
|
+
|
|
130
|
+
"blocked: #{failed.map { |check| check[:name] }.join(", ")}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Orchestrates one bounded browser operation.
|
|
6
|
+
#
|
|
7
|
+
# The engine enforces, in order: egress qualification (before anything remote),
|
|
8
|
+
# destination policy, budget reservation and the actor concurrency slot, the
|
|
9
|
+
# operation lifecycle, step/byte/time limits, result redaction and bounding, and a
|
|
10
|
+
# best-effort cleanup that never reports unconfirmed closure as success.
|
|
11
|
+
#
|
|
12
|
+
# Pre-flight failures (policy, budget, input) raise a typed {Error}. Failures after a
|
|
13
|
+
# session exists are returned as a {Result} with a typed status so the operation's
|
|
14
|
+
# durable state is recorded first.
|
|
15
|
+
class Engine
|
|
16
|
+
Attempt = Struct.new(:operation, :session, :uri, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
attr_reader :context
|
|
19
|
+
|
|
20
|
+
def initialize(context:)
|
|
21
|
+
@context = context
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Read-only observation of an allowlisted page.
|
|
25
|
+
#
|
|
26
|
+
# @param operation [Operation, nil] a caller-reserved, caller-leased operation to
|
|
27
|
+
# drive. Used by the optional Rails job so a duplicate delivery attaches to the
|
|
28
|
+
# stored operation instead of reserving a second one. When nil the engine
|
|
29
|
+
# reserves its own operation.
|
|
30
|
+
def inspect(url:, instruction: nil, max_bytes: nil, operation: nil)
|
|
31
|
+
max_bytes = optional_positive_integer!(max_bytes, :max_bytes)
|
|
32
|
+
execute(url: url, operation: operation) do |attempt|
|
|
33
|
+
attempt.operation = next_step(attempt.operation)
|
|
34
|
+
observed = backend.observe(attempt.session, instruction: instruction,
|
|
35
|
+
operation: attempt.operation, now: now)
|
|
36
|
+
ok_result(
|
|
37
|
+
attempt.operation, observed, session: attempt.session,
|
|
38
|
+
locator: { "url" => current_url(attempt), "scope" => "observe" },
|
|
39
|
+
max_bytes: max_bytes
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Schema-bounded extraction from an allowlisted page.
|
|
45
|
+
def extract(url:, schema:, instruction: nil, max_bytes: nil, operation: nil)
|
|
46
|
+
max_bytes = optional_positive_integer!(max_bytes, :max_bytes)
|
|
47
|
+
validated = policy.authorize_schema!(schema)
|
|
48
|
+
execute(url: url, operation: operation) do |attempt|
|
|
49
|
+
attempt.operation = next_step(attempt.operation)
|
|
50
|
+
data = backend.extract(attempt.session, schema: validated, instruction: instruction,
|
|
51
|
+
operation: attempt.operation, now: now)
|
|
52
|
+
policy.authorize_extraction!(data, schema: validated)
|
|
53
|
+
ok_result(
|
|
54
|
+
attempt.operation, data, session: attempt.session,
|
|
55
|
+
locator: { "url" => current_url(attempt), "scope" => "extract",
|
|
56
|
+
"schema_digest" => Redactor.digest(validated) },
|
|
57
|
+
max_bytes: max_bytes
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One human-approved action. Disabled unless an approval store is configured and an
|
|
63
|
+
# active, exactly-bound, single-use approval exists.
|
|
64
|
+
def act(url:, approval_id:, action:, page_revision: nil, operation: nil)
|
|
65
|
+
unless capabilities.server_mode == :fake
|
|
66
|
+
raise UnsupportedSecurityCapability, "live Act is unavailable until same-session approval is implemented"
|
|
67
|
+
end
|
|
68
|
+
unless capabilities.act_supported?
|
|
69
|
+
raise UnsupportedSecurityCapability, "backend does not support qualified Act"
|
|
70
|
+
end
|
|
71
|
+
normalized = policy.authorize_action!(action)
|
|
72
|
+
require_approval_store!
|
|
73
|
+
execute(url: url, operation: operation) do |attempt|
|
|
74
|
+
ensure_no_redirect!(attempt)
|
|
75
|
+
attempt.operation = next_step(attempt.operation)
|
|
76
|
+
observed = backend.observe(attempt.session, instruction: nil,
|
|
77
|
+
operation: attempt.operation, now: now)
|
|
78
|
+
revision = Approval.page_revision(observed)
|
|
79
|
+
if page_revision && page_revision != revision
|
|
80
|
+
raise ApprovalStale, "the current page does not match the requested revision"
|
|
81
|
+
end
|
|
82
|
+
approval = consume_approval!(approval_id, attempt: attempt, action: normalized,
|
|
83
|
+
page_revision: revision)
|
|
84
|
+
attempt.operation = next_step(attempt.operation)
|
|
85
|
+
data = backend.act(attempt.session, action: normalized, operation: attempt.operation, now: now)
|
|
86
|
+
ok_result(
|
|
87
|
+
attempt.operation, data, session: attempt.session,
|
|
88
|
+
locator: { "url" => attempt.uri.to_s, "scope" => "act",
|
|
89
|
+
"approval_id" => approval.id, "action_digest" => approval.action_digest },
|
|
90
|
+
max_bytes: nil
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def execute(url:, operation: nil)
|
|
98
|
+
policy.authorize_egress!(capabilities)
|
|
99
|
+
uri = policy.authorize_destination!(url, actor_id: context.actor_id)
|
|
100
|
+
operation = reserve(uri, operation)
|
|
101
|
+
operation = store.claim_start(operation, now: now)
|
|
102
|
+
attempt = Attempt.new(operation: operation, uri: uri)
|
|
103
|
+
outcome = nil
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
begin
|
|
107
|
+
attempt.session = backend.start(context: context, operation: operation, now: now)
|
|
108
|
+
rescue ProviderError => e
|
|
109
|
+
safe_transition(operation, :closed, error_code: e.code)
|
|
110
|
+
return Result.failure(:error, error_code: e.code, message: "browser provider rejected session start",
|
|
111
|
+
operation_id: operation.id, final_url: safe_url(uri.to_s))
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
error = e.is_a?(SessionUnknown) ? e : SessionUnknown.new("session start outcome is unconfirmed")
|
|
114
|
+
if error.session
|
|
115
|
+
attempt.session = error.session
|
|
116
|
+
attempt.operation = store.record_session(operation, session_id: error.session.id, now: now)
|
|
117
|
+
raise error
|
|
118
|
+
end
|
|
119
|
+
safe_transition(operation, :unknown, error_code: error.code)
|
|
120
|
+
return Result.failure(:unknown, error_code: error.code, message: error.message,
|
|
121
|
+
operation_id: operation.id, final_url: safe_url(uri.to_s))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
stored = store.record_session(operation, session_id: attempt.session.id, now: now)
|
|
125
|
+
if stored.fence != operation.fence
|
|
126
|
+
raise InvalidInput, "starting worker was fenced or cancelled"
|
|
127
|
+
end
|
|
128
|
+
attempt.operation = stored
|
|
129
|
+
attempt.operation = next_step(attempt.operation)
|
|
130
|
+
attempt.session = navigate(attempt)
|
|
131
|
+
outcome = yield attempt
|
|
132
|
+
attempt.operation = store.fetch!(attempt.operation.id)
|
|
133
|
+
rescue Error => e
|
|
134
|
+
outcome = failure_result(e, attempt)
|
|
135
|
+
rescue StandardError
|
|
136
|
+
outcome = failure_result(ProviderError.new("browser backend or bookkeeping failed"), attempt)
|
|
137
|
+
ensure
|
|
138
|
+
outcome = finish(attempt, outcome) if attempt.session
|
|
139
|
+
end
|
|
140
|
+
outcome
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def reserve(uri, provided = nil)
|
|
144
|
+
return validate_provided!(provided) if provided
|
|
145
|
+
|
|
146
|
+
now = self.now
|
|
147
|
+
operation = Operation.new(
|
|
148
|
+
id: Operation.generate_id, actor_id: context.actor_id, run_id: context.run_id,
|
|
149
|
+
budget: context.budget, state: :reserved, created_at: now, updated_at: now
|
|
150
|
+
)
|
|
151
|
+
store.reserve(operation)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Accepts a caller-reserved operation only when it belongs to this exact
|
|
155
|
+
# actor/run and uses this context's budget. A mismatched operation is refused
|
|
156
|
+
# before any remote call.
|
|
157
|
+
def validate_provided!(operation)
|
|
158
|
+
raise InvalidInput, "reserved operation must be a RubyLLM::Browser::Operation" unless operation.is_a?(Operation)
|
|
159
|
+
unless operation.actor_id == context.actor_id && operation.run_id == context.run_id
|
|
160
|
+
raise NotAuthorized, "reserved operation belongs to a different actor or run"
|
|
161
|
+
end
|
|
162
|
+
unless operation.budget == context.budget
|
|
163
|
+
raise InvalidInput, "reserved operation budget does not match the context budget"
|
|
164
|
+
end
|
|
165
|
+
raise InvalidInput, "operation must be reserved" unless operation.state == :reserved
|
|
166
|
+
|
|
167
|
+
operation
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Records one provider round-trip, refusing once a limit is reached.
|
|
171
|
+
def next_step(operation)
|
|
172
|
+
timestamp = now
|
|
173
|
+
raise BudgetExhausted, "operation wall-clock budget exhausted" if operation.expired?(timestamp)
|
|
174
|
+
|
|
175
|
+
store.update(operation.consume_step!(now: timestamp))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ok_result(operation, data, session:, locator:, max_bytes:)
|
|
179
|
+
raise BudgetExhausted, "operation wall-clock budget exhausted" if operation.expired?(now)
|
|
180
|
+
limit = [ max_bytes || policy.max_result_bytes, policy.max_result_bytes, operation.remaining_bytes ].min
|
|
181
|
+
raise BudgetExhausted, "operation byte budget exhausted" unless limit.positive?
|
|
182
|
+
bounded, truncated = policy.authorize_result!(
|
|
183
|
+
data, max_bytes: limit, secrets: context.secrets
|
|
184
|
+
)
|
|
185
|
+
stored = store.update(operation.add_bytes!(Redactor.canonical_json(bounded).bytesize, now: now))
|
|
186
|
+
Result.ok(
|
|
187
|
+
data: bounded, operation_id: stored.id, final_url: safe_url(session.url.to_s),
|
|
188
|
+
captured_at: now, locator: locator.merge("url" => safe_url(session.url.to_s)),
|
|
189
|
+
digest: Redactor.digest(bounded), truncated: truncated
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def failure_result(error, attempt)
|
|
194
|
+
status = status_for(error)
|
|
195
|
+
recorded =
|
|
196
|
+
if status == :error
|
|
197
|
+
safe_transition(attempt.operation, attempt.operation.state, error_code: error.code)
|
|
198
|
+
else
|
|
199
|
+
safe_transition(attempt.operation, status, error_code: error.code)
|
|
200
|
+
end
|
|
201
|
+
attempt.operation = recorded
|
|
202
|
+
Result.failure(
|
|
203
|
+
status, error_code: error.code, message: Redactor.scrub_string(error.message, secrets: context.secrets),
|
|
204
|
+
operation_id: recorded.id, final_url: safe_url(attempt.uri.to_s)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def finish(attempt, outcome)
|
|
209
|
+
closing = safe_transition(attempt.operation, :closing)
|
|
210
|
+
closed =
|
|
211
|
+
begin
|
|
212
|
+
backend.end_session(attempt.session, operation: closing, now: now)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if closed
|
|
218
|
+
begin
|
|
219
|
+
store.confirm_closed(closing, session_id: attempt.session.id, now: now)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
return cleanup_failure(attempt)
|
|
222
|
+
end
|
|
223
|
+
outcome
|
|
224
|
+
else
|
|
225
|
+
safe_transition(closing, :cleanup_pending, error_code: CleanupPending.code)
|
|
226
|
+
cleanup_failure(attempt)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def cleanup_failure(attempt)
|
|
231
|
+
Result.failure(:cleanup_pending, error_code: CleanupPending.code,
|
|
232
|
+
message: "browser session cleanup could not be confirmed",
|
|
233
|
+
operation_id: attempt.operation.id, final_url: safe_url(attempt.uri.to_s))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Navigates the freshly started session to the requested URL and re-checks the
|
|
237
|
+
# final destination against policy. A browser-initiated redirect may only land
|
|
238
|
+
# inside the approved destinations.
|
|
239
|
+
def navigate(attempt)
|
|
240
|
+
session = backend.navigate(attempt.session, url: attempt.uri.to_s,
|
|
241
|
+
operation: attempt.operation, now: now)
|
|
242
|
+
ensure_within_policy!(session, attempt.uri)
|
|
243
|
+
session
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def ensure_within_policy!(session, uri)
|
|
247
|
+
final = session.url.to_s
|
|
248
|
+
raise ProviderError, "browser did not report a destination URL" if final.empty?
|
|
249
|
+
return if final == uri.to_s
|
|
250
|
+
|
|
251
|
+
policy.authorize_destination!(final, actor_id: context.actor_id)
|
|
252
|
+
rescue NotAuthorized, InvalidInput => e
|
|
253
|
+
raise NotAuthorized, "the browser left the approved destinations: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def ensure_no_redirect!(attempt)
|
|
257
|
+
return if attempt.session.url.to_s == attempt.uri.to_s
|
|
258
|
+
|
|
259
|
+
raise ApprovalStale, "the page changed to a different URL; the approval no longer applies"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def consume_approval!(approval_id, attempt:, action:, page_revision:)
|
|
263
|
+
context.approval_store.consume_once(
|
|
264
|
+
approval_id,
|
|
265
|
+
actor_id: context.actor_id, run_id: context.run_id, url: attempt.uri.to_s,
|
|
266
|
+
action_digest: Approval.action_digest(action), page_revision: page_revision,
|
|
267
|
+
operation_id: attempt.operation.id, session_id: attempt.session.id, now: now
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def require_approval_store!
|
|
272
|
+
return unless context.approval_store.nil?
|
|
273
|
+
|
|
274
|
+
raise ApprovalRequired, "Act requires a configured approval store"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def transition(operation, target, session_id: :keep, error_code: nil)
|
|
278
|
+
updated = operation.advance(target, now: now, session_id: session_id,
|
|
279
|
+
error_code: error_code.nil? ? :keep : error_code)
|
|
280
|
+
store.update(updated)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A transition that tolerates a store refusing the write.
|
|
284
|
+
#
|
|
285
|
+
# When a host cancels an operation or a newer worker takes the lease (fencing),
|
|
286
|
+
# the store rejects this worker's bookkeeping by design. Cleanup must still be
|
|
287
|
+
# attempted, so bookkeeping failure is logged and the last known operation is
|
|
288
|
+
# kept; the durable reaper reconciles the row.
|
|
289
|
+
def safe_transition(operation, target, session_id: :keep, error_code: nil)
|
|
290
|
+
transition(operation, target, session_id: session_id, error_code: error_code)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
context.logger&.warn { "browser bookkeeping write rejected" }
|
|
293
|
+
operation
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def safe_url(url)
|
|
297
|
+
Redactor.url(url, secrets: context.secrets)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def status_for(error)
|
|
301
|
+
case error
|
|
302
|
+
when SessionUnknown then :unknown
|
|
303
|
+
when CleanupPending then :cleanup_pending
|
|
304
|
+
else :error
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def current_url(attempt)
|
|
309
|
+
attempt.session.url.to_s
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def optional_positive_integer!(value, name)
|
|
313
|
+
return nil if value.nil?
|
|
314
|
+
raise InvalidInput, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
315
|
+
|
|
316
|
+
value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def backend = context.backend
|
|
320
|
+
def policy = context.policy
|
|
321
|
+
def store = context.operation_store
|
|
322
|
+
def capabilities = context.capabilities
|
|
323
|
+
def now = context.now
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|