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,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