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
data/docs/index.md ADDED
@@ -0,0 +1,82 @@
1
+ ---
2
+ layout: home
3
+ title: "RubyLLM::Browser"
4
+ description: "An unofficial RubyLLM extension for offline bounded browser inspection and schema-checked extraction."
5
+ permalink: /
6
+ hero:
7
+ name: "RubyLLM::Browser"
8
+ text: "Policy-bound browser tools for RubyLLM"
9
+ tagline: "An unofficial RubyLLM extension for offline bounded browser inspection and schema-checked extraction."
10
+ actions:
11
+ - theme: brand
12
+ text: Get started
13
+ link: /getting-started/
14
+ - theme: alt
15
+ text: View on GitHub
16
+ link: https://github.com/yorzi/ruby_llm-browser
17
+ features:
18
+ - title: "Read-only by default"
19
+ details: "Inspect and Extract do not dispatch browser actions, and page content remains untrusted evidence."
20
+ link: /inspect-extract/
21
+ link_text: Inspect and extract
22
+ - title: "Host-controlled access"
23
+ details: "The host supplies identity, URL policy, schemas, budgets, and consent. Live Stagehand use is disabled."
24
+ link: /source-policy/
25
+ link_text: Control source access
26
+ - title: "Bounded operation state"
27
+ details: "Track lifecycle and cap time, steps, concurrent operations, and retained result bytes."
28
+ link: /operation-lifecycle/
29
+ link_text: Bound operation work
30
+ - title: "Offline action simulation"
31
+ details: "Exercise single-use host approval checks without opening a browser or changing a website."
32
+ link: /offline-act/
33
+ link_text: Simulate an approved action
34
+ ---
35
+
36
+ ## Before You Install
37
+
38
+ The first release is `0.1.0` and supports only the offline `FakeBackend`. The built-in
39
+ Stagehand adapter is unsupported until provider egress and confirmed cleanup are
40
+ qualified. The full live Act protocol is deferred to a later version.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ {% if site.docs_unreleased %}bundle add ruby_llm-browser --git https://github.com/yorzi/ruby_llm-browser --branch main{% else %}bundle add ruby_llm-browser --version "~> {{ site.version }}"{% endif %}
46
+ ```
47
+
48
+ ## A First Look
49
+
50
+ This offline example makes no network request and needs no API key.
51
+
52
+ ```ruby
53
+ require "ruby_llm/browser"
54
+
55
+ class OfflineResolver
56
+ def getaddresses(_host) = [ "93.184.216.34" ]
57
+ end
58
+
59
+ context = RubyLLM::Browser::Context.new(
60
+ actor_id: "demo-actor", run_id: "demo-run",
61
+ backend: RubyLLM::Browser::FakeBackend.new,
62
+ source_policy: RubyLLM::Browser::Policy.new(
63
+ allowed_hosts: [ "docs.example.com" ], resolver: OfflineResolver.new
64
+ ),
65
+ operation_store: RubyLLM::Browser::InMemoryOperationStore.new
66
+ )
67
+ schema = {
68
+ "type" => "object",
69
+ "properties" => { "title" => { "type" => "string" } },
70
+ "required" => [ "title" ], "additionalProperties" => false
71
+ }
72
+
73
+ inspect_tool = RubyLLM::Browser::Inspect.new(context: context)
74
+ extract_tool = RubyLLM::Browser::Extract.new(context: context, schema: schema)
75
+ puts inspect_tool.execute(url: "https://docs.example.com/")[:status] # => ok
76
+ puts extract_tool.execute(url: "https://docs.example.com/")[:data] # => {"title"=>"Example"}
77
+ ```
78
+
79
+ The fake backend proves only that the Ruby API can run offline. It says nothing about a
80
+ real browser provider's egress, session cleanup, or billing. Read [the security model]({% link SECURITY_MODEL.md %}) before evaluating a live backend.
81
+
82
+ [Get started]({% link _getting_started/getting-started.md %}) or read [how the gem works]({% link _getting_started/overview.md %}).
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Offline demo of the read-only tools over the fake backend: no network, no API key.
5
+ #
6
+ # bundle exec ruby examples/plain_ruby.rb
7
+ require "ruby_llm/browser"
8
+
9
+ # A deterministic resolver keeps the demo offline; production uses real DNS.
10
+ class DemoResolver
11
+ def getaddresses(_host) = [ "93.184.216.34" ]
12
+ end
13
+
14
+ policy = RubyLLM::Browser::Policy.new(
15
+ allowed_hosts: [ "docs.example.com" ], resolver: DemoResolver.new
16
+ )
17
+ context = RubyLLM::Browser::Context.new(
18
+ actor_id: "demo-actor",
19
+ run_id: "demo-run",
20
+ source_policy: policy,
21
+ backend: RubyLLM::Browser::FakeBackend.new,
22
+ operation_store: RubyLLM::Browser::InMemoryOperationStore.new,
23
+ budget: RubyLLM::Browser::Budget.new(max_seconds: 30, max_steps: 6)
24
+ )
25
+
26
+ inspect = RubyLLM::Browser::Inspect.new(context: context)
27
+ puts "Inspect: #{inspect.execute(url: "https://docs.example.com/")}"
28
+
29
+ schema = { "type" => "object", "properties" => { "title" => { "type" => "string" } } }
30
+ extract = RubyLLM::Browser::Extract.new(context: context, schema: schema)
31
+ puts "Extract: #{extract.execute(url: "https://docs.example.com/", instruction: "the title")}"
32
+
33
+ blocked = inspect.execute(url: "http://169.254.169.254/latest/meta-data")
34
+ puts "Blocked: #{blocked[:error][:code]}"
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Offline demo of the Rails adapter on in-memory SQLite: no network, no API key.
5
+ #
6
+ # bundle exec ruby examples/rails_adapter.rb
7
+ require "ruby_llm/browser/rails"
8
+ require "active_record"
9
+
10
+ class DemoResolver
11
+ def getaddresses(_host) = [ "93.184.216.34" ]
12
+ end
13
+
14
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
15
+ RubyLLM::Browser::Rails::Schema.create!
16
+
17
+ # `:inline` runs the queued job immediately so the demo is synchronous and deterministic.
18
+ ActiveJob::Base.queue_adapter = :inline
19
+ ActiveJob::Base.logger = Logger.new(IO::NULL)
20
+
21
+ policy = RubyLLM::Browser::Policy.new(
22
+ allowed_hosts: [ "docs.example.com" ], resolver: DemoResolver.new
23
+ )
24
+ budget = RubyLLM::Browser::Budget.new(max_seconds: 30, max_steps: 6)
25
+
26
+ RubyLLM::Browser::Rails.context_factory = lambda do |actor_id:, run_id:|
27
+ RubyLLM::Browser::Context.new(
28
+ actor_id: actor_id, run_id: run_id, source_policy: policy,
29
+ backend: RubyLLM::Browser::FakeBackend.new,
30
+ operation_store: RubyLLM::Browser::Rails.operation_store,
31
+ budget: budget
32
+ )
33
+ end
34
+
35
+ operation = RubyLLM::Browser::Rails::OperationJob.enqueue(
36
+ actor_id: "demo-actor", run_id: "demo-run", kind: :inspect,
37
+ url: "https://docs.example.com/", budget: budget, idempotency_key: "demo-1"
38
+ )
39
+
40
+ puts "operation #{operation.id} is #{RubyLLM::Browser::Rails.operation_store.fetch(operation.id).state}"
41
+
42
+ # A duplicate delivery attaches to the same durable operation.
43
+ again = RubyLLM::Browser::Rails::OperationJob.enqueue(
44
+ actor_id: "demo-actor", run_id: "demo-run", kind: :inspect,
45
+ url: "https://docs.example.com/", budget: budget, idempotency_key: "demo-1"
46
+ )
47
+ puts "duplicate attached: #{again.id == operation.id}"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module RubyLLM
7
+ module Browser
8
+ module Generators
9
+ # Generates the durable tables for the optional Rails adapter.
10
+ #
11
+ # bin/rails generate ruby_llm:browser:install
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ include ::ActiveRecord::Generators::Migration
14
+ namespace "ruby_llm:browser:install"
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Creates the RubyLLM::Browser operations and approvals migration"
19
+
20
+ def create_migration_file
21
+ migration_template(
22
+ "create_ruby_llm_browser_tables.rb.tt",
23
+ "db/migrate/create_ruby_llm_browser_tables.rb"
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Durable tables for the RubyLLM::Browser Rails adapter.
4
+ #
5
+ # These tables hold only Gem-owned bookkeeping: operation identity/lifecycle and
6
+ # single-use approvals. They are not business truth and must not be edited by hand.
7
+ class CreateRubyLlmBrowserTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
8
+ def change
9
+ create_table :ruby_llm_browser_actors, id: :string do |t|
10
+ t.integer :sequence, null: false, default: 0
11
+ end
12
+
13
+ create_table :ruby_llm_browser_operations, id: false do |t|
14
+ t.string :id, null: false, primary_key: true
15
+ t.string :actor_id, null: false
16
+ t.string :run_id, null: false
17
+ t.string :state, null: false
18
+ t.string :session_id
19
+ t.string :idempotency_key
20
+ t.string :request_digest
21
+ t.integer :steps, null: false, default: 0
22
+ t.integer :bytes, null: false, default: 0
23
+ t.integer :fence, null: false, default: 0
24
+ t.string :error_code
25
+ t.boolean :cancel_requested, null: false, default: false
26
+ t.text :budget, null: false
27
+ t.datetime :created_at
28
+ t.datetime :updated_at
29
+ end
30
+
31
+ add_index :ruby_llm_browser_operations, [ :actor_id, :run_id, :idempotency_key ],
32
+ unique: true, name: "index_ruby_llm_browser_operations_on_idempotency",
33
+ where: "idempotency_key IS NOT NULL"
34
+ add_index :ruby_llm_browser_operations, [ :actor_id, :state ],
35
+ name: "index_ruby_llm_browser_operations_on_actor_state"
36
+ add_index :ruby_llm_browser_operations, :state,
37
+ name: "index_ruby_llm_browser_operations_on_state"
38
+
39
+ create_table :ruby_llm_browser_approvals, id: false do |t|
40
+ t.string :id, null: false, primary_key: true
41
+ t.string :actor_id, null: false
42
+ t.string :run_id, null: false
43
+ t.string :url, null: false
44
+ t.string :action_digest, null: false
45
+ t.text :action
46
+ t.string :page_revision
47
+ t.string :operation_id
48
+ t.string :session_id
49
+ t.datetime :issued_at, null: false
50
+ t.datetime :expires_at, null: false
51
+ t.datetime :consumed_at
52
+ t.boolean :revoked, null: false, default: false
53
+ t.datetime :created_at
54
+ t.datetime :updated_at
55
+ end
56
+
57
+ add_index :ruby_llm_browser_approvals, [ :actor_id, :run_id ],
58
+ name: "index_ruby_llm_browser_approvals_on_actor_run"
59
+ end
60
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # A host-issued, single-use authorization for one exact browser action.
6
+ #
7
+ # An approval binds actor, run, optional operation/session, the exact normalized URL,
8
+ # an optional page revision and the exact action digest, with a short expiry. It is
9
+ # consumed atomically once, immediately before dispatch, inside the tool. A bare
10
+ # RubyLLM `Chat#approve` carries none of these bindings and therefore dispatches
11
+ # nothing on its own.
12
+ class Approval
13
+ attr_reader :id, :actor_id, :run_id, :url, :action_digest, :page_revision,
14
+ :operation_id, :session_id, :issued_at, :expires_at, :consumed_at, :action
15
+
16
+ def initialize(id:, actor_id:, run_id:, url:, action_digest:, expires_at:, issued_at:,
17
+ page_revision: nil, operation_id: nil, session_id: nil, consumed_at: nil,
18
+ revoked: false, action: nil)
19
+ @id = presence!(id, :id)
20
+ @actor_id = presence!(actor_id, :actor_id)
21
+ @run_id = presence!(run_id, :run_id)
22
+ @url = presence!(url, :url)
23
+ @action_digest = presence!(action_digest, :action_digest)
24
+ @expires_at = time!(expires_at, :expires_at)
25
+ @issued_at = time!(issued_at, :issued_at)
26
+ @page_revision = Immutable.copy(page_revision)
27
+ @operation_id = Immutable.copy(operation_id)
28
+ @session_id = Immutable.copy(session_id)
29
+ @consumed_at = Immutable.copy(consumed_at)
30
+ @revoked = !!revoked
31
+ @action = Immutable.copy(action)
32
+ freeze
33
+ end
34
+
35
+ # Builds an approval for +action+ with a time-to-live.
36
+ #
37
+ # @param action [Hash] an action already normalized by {Policy#authorize_action!}
38
+ # @param ttl [Numeric] seconds until expiry
39
+ def self.for_action(actor_id:, run_id:, url:, action:, ttl:, now:, id: nil, page_revision: nil,
40
+ operation_id: nil, session_id: nil)
41
+ new(
42
+ id: id || Operation.generate_id(prefix: "apr"),
43
+ actor_id: actor_id, run_id: run_id, url: url,
44
+ action_digest: action_digest(action), action: action,
45
+ page_revision: page_revision, operation_id: operation_id, session_id: session_id,
46
+ issued_at: now, expires_at: now + ttl
47
+ )
48
+ end
49
+
50
+ # The digest a host and the Gem both compute for an action.
51
+ def self.action_digest(action)
52
+ Redactor.digest(action)
53
+ end
54
+
55
+ # The digest a host and the Gem both compute for an observed candidate list.
56
+ def self.page_revision(observed)
57
+ Redactor.digest(observed)
58
+ end
59
+
60
+ def consumed? = !@consumed_at.nil?
61
+ def revoked? = !!@revoked
62
+ def expired?(now) = now >= expires_at
63
+ def active?(now) = !consumed? && !revoked? && !expired?(now)
64
+
65
+ # True when the approval authorizes exactly this actor/run/url/revision/digest.
66
+ def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
67
+ operation_id: nil, session_id: nil)
68
+ return false unless @actor_id == actor_id && @run_id == run_id
69
+ return false unless @url == url
70
+ return false unless @action_digest == action_digest
71
+ return false unless @page_revision.nil? || @page_revision == page_revision
72
+ return false unless @operation_id.nil? || @operation_id == operation_id
73
+ return false unless @session_id.nil? || @session_id == session_id
74
+
75
+ true
76
+ end
77
+
78
+ # Returns a consumed copy.
79
+ def consume!(now:)
80
+ raise ApprovalStale, "approval #{id} was already consumed" if consumed?
81
+
82
+ copy(consumed_at: now)
83
+ end
84
+
85
+ # Returns a revoked copy.
86
+ def revoke!
87
+ copy(revoked: true)
88
+ end
89
+ def to_h
90
+ {
91
+ id: id, actor_id: actor_id, run_id: run_id, url: url,
92
+ action_digest: action_digest, action: action, page_revision: page_revision,
93
+ operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
94
+ consumed_at: consumed_at, revoked: revoked?
95
+ }
96
+ end
97
+
98
+ def digest = Redactor.digest(to_h)
99
+
100
+ def inspect
101
+ "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
102
+ end
103
+
104
+ private
105
+
106
+ def copy(consumed_at: @consumed_at, revoked: @revoked)
107
+ self.class.new(
108
+ id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
109
+ action: action, page_revision: page_revision, operation_id: operation_id,
110
+ session_id: session_id, issued_at: issued_at, expires_at: expires_at,
111
+ consumed_at: consumed_at, revoked: revoked
112
+ )
113
+ end
114
+
115
+ def presence!(value, name)
116
+ raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
117
+
118
+ value.dup.freeze
119
+ end
120
+
121
+ def time!(value, name)
122
+ raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)
123
+
124
+ value.dup.freeze
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # Durable store for host-issued, single-use {Approval}s.
6
+ #
7
+ # The RubyLLM approval resolver may consult the store repeatedly, so reads must be
8
+ # side-effect free. Only {#consume_once} mutates, and it must be atomic so a replayed
9
+ # or duplicated worker cannot dispatch a second action.
10
+ class ApprovalStore
11
+ # Stores a freshly issued approval.
12
+ def issue(_approval)
13
+ raise NotImplementedError, "ApprovalStore#issue must be implemented"
14
+ end
15
+
16
+ # Read-only lookup. Returns an {Approval} or nil.
17
+ def fetch(_id)
18
+ raise NotImplementedError, "ApprovalStore#fetch must be implemented"
19
+ end
20
+
21
+ # Atomically consumes +id+ exactly once if it is active and bound to this
22
+ # actor/run/url/revision/digest.
23
+ #
24
+ # @return [Approval] the consumed approval
25
+ # @raise [ApprovalRequired] no such approval
26
+ # @raise [ApprovalStale] consumed, revoked, expired or bound to different details
27
+ def consume_once(_id, actor_id:, run_id:, url:, action_digest:, now:, page_revision: nil,
28
+ operation_id: nil, session_id: nil)
29
+ raise NotImplementedError, "ApprovalStore#consume_once must be implemented"
30
+ end
31
+
32
+ # Revokes an approval so it can never be consumed.
33
+ def revoke(_id)
34
+ raise NotImplementedError, "ApprovalStore#revoke must be implemented"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # A live browser session handle.
6
+ #
7
+ # A session exposes only what policy and provenance need: an id, the current
8
+ # (possibly redirected) URL and a start time. It deliberately has no reader for the
9
+ # provider's `cdp_url`: that is a full-control DevTools endpoint and must never
10
+ # reach a tool result, model message or log.
11
+ class Session
12
+ attr_reader :id, :url, :started_at
13
+
14
+ def initialize(id:, url: nil, started_at: nil)
15
+ raise InvalidInput, "session id must be a non-empty String" unless id.is_a?(String) && !id.empty?
16
+
17
+ @id = id
18
+ @url = url
19
+ @started_at = started_at
20
+ freeze
21
+ end
22
+
23
+ # Returns a copy with a new current URL.
24
+ def with_url(url)
25
+ self.class.new(id: id, url: url, started_at: started_at)
26
+ end
27
+
28
+ def to_h
29
+ { id: id, url: url, started_at: started_at&.utc&.iso8601 }
30
+ end
31
+
32
+ def inspect = "#<#{self.class} id=#{id} url=#{url.inspect}>"
33
+ end
34
+
35
+ # The contract every browser backend implements.
36
+ #
37
+ # The Gem maps its own bounded operations onto a backend; it never reimplements a
38
+ # browser engine. Implementations must:
39
+ #
40
+ # - never raise an untyped error; use {ProviderError} (or {SessionUnknown} when an
41
+ # effectful call's outcome cannot be confirmed)
42
+ # - issue effectful calls without automatic retries
43
+ # - never return `cdp_url`, cookies or credentials
44
+ # - report honest {Capabilities}
45
+ #
46
+ # {FakeBackend} is the offline reference. {StagehandBackend} wraps the official SDK
47
+ # but is unsupported for live use in the first release.
48
+ class Backend
49
+ # @return [Capabilities]
50
+ def capabilities
51
+ raise NotImplementedError, "Backend#capabilities must be implemented"
52
+ end
53
+
54
+ # Secret literal values (API keys, ...) to scrub from page text and results.
55
+ # @return [Array<String>]
56
+ def secrets = []
57
+
58
+ # Opens a session. Returns a {Session} or raises {SessionUnknown} when the
59
+ # outcome is unconfirmed.
60
+ def start(context:, operation:, now:)
61
+ raise NotImplementedError, "Backend#start must be implemented"
62
+ end
63
+
64
+ # Navigates +session+ to +url+. Returns the updated {Session}.
65
+ def navigate(_session, url:, operation:, now:)
66
+ raise NotImplementedError, "Backend#navigate must be implemented"
67
+ end
68
+
69
+ # @return [Array<Hash>] candidate actions, each with `description`, `method`,
70
+ # `selector` and optional `arguments`
71
+ def observe(_session, instruction:, operation:, now:)
72
+ raise NotImplementedError, "Backend#observe must be implemented"
73
+ end
74
+
75
+ # @return [Hash] structured data matching +schema+
76
+ def extract(_session, schema:, instruction:, operation:, now:)
77
+ raise NotImplementedError, "Backend#extract must be implemented"
78
+ end
79
+
80
+ # Dispatches one already-authorized action. Raises {SessionUnknown} when the
81
+ # outcome after dispatch cannot be confirmed.
82
+ def act(_session, action:, operation:, now:)
83
+ raise NotImplementedError, "Backend#act must be implemented"
84
+ end
85
+
86
+ # Attempts to terminate +session+. Returns true only when closure is confirmed.
87
+ def end_session(_session, operation:, now:)
88
+ raise NotImplementedError, "Backend#end_session must be implemented"
89
+ end
90
+
91
+ # Resolves an operation whose remote outcome is unconfirmed.
92
+ #
93
+ # @return [Symbol] one of :closed, :active, :unknown, :cleanup_pending
94
+ def reconcile(_operation, now:)
95
+ :unknown
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # Immutable limits for a single browser operation.
6
+ #
7
+ # A budget is deliberately conservative. Elapsed time, steps and bytes are separate
8
+ # limits, and a projected or estimated cost is never presented as a provider-enforced
9
+ # hard billing cap. When a limit is unknown or exhausted the Gem refuses more work
10
+ # rather than guessing.
11
+ class Budget
12
+ DEFAULT_MAX_SECONDS = 60
13
+ DEFAULT_MAX_STEPS = 12
14
+ DEFAULT_MAX_BYTES = 262_144 # 256 KiB of page/result bytes
15
+ DEFAULT_MAX_CONCURRENT = 1
16
+
17
+ attr_reader :max_seconds, :max_steps, :max_bytes, :max_concurrent, :max_cost_usd
18
+
19
+ # @param max_seconds [Numeric] wall-clock ceiling for the operation
20
+ # @param max_steps [Integer] maximum provider round-trips (navigate/observe/...)
21
+ # @param max_bytes [Integer] maximum bytes of page/result content kept per operation
22
+ # @param max_concurrent [Integer] concurrent sessions allowed for the actor
23
+ # @param max_cost_usd [Numeric, nil] advisory cost reservation; nil means unknown
24
+ def initialize(max_seconds: DEFAULT_MAX_SECONDS, max_steps: DEFAULT_MAX_STEPS,
25
+ max_bytes: DEFAULT_MAX_BYTES, max_concurrent: DEFAULT_MAX_CONCURRENT,
26
+ max_cost_usd: nil)
27
+ @max_seconds = positive_number!(max_seconds, :max_seconds)
28
+ @max_steps = positive_integer!(max_steps, :max_steps)
29
+ @max_bytes = positive_integer!(max_bytes, :max_bytes)
30
+ @max_concurrent = positive_integer!(max_concurrent, :max_concurrent)
31
+ @max_cost_usd = max_cost_usd.nil? ? nil : non_negative_number!(max_cost_usd, :max_cost_usd)
32
+
33
+ freeze
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ max_seconds: max_seconds,
39
+ max_steps: max_steps,
40
+ max_bytes: max_bytes,
41
+ max_concurrent: max_concurrent,
42
+ max_cost_usd: max_cost_usd
43
+ }
44
+ end
45
+
46
+ def digest
47
+ Redactor.digest(to_h)
48
+ end
49
+
50
+ def ==(other)
51
+ other.is_a?(Budget) && to_h == other.to_h
52
+ end
53
+ alias eql? ==
54
+
55
+ def hash
56
+ to_h.hash
57
+ end
58
+
59
+ def inspect
60
+ "#<#{self.class} seconds=#{max_seconds} steps=#{max_steps} bytes=#{max_bytes} concurrent=#{max_concurrent}>"
61
+ end
62
+
63
+ private
64
+
65
+ def positive_number!(value, name)
66
+ unless value.is_a?(Numeric) && value.finite? && value.positive?
67
+ raise InvalidInput, "#{name} must be a positive number"
68
+ end
69
+
70
+ value
71
+ end
72
+
73
+ def non_negative_number!(value, name)
74
+ unless value.is_a?(Numeric) && value.finite? && value >= 0
75
+ raise InvalidInput, "#{name} must be a non-negative number"
76
+ end
77
+
78
+ value
79
+ end
80
+
81
+ def positive_integer!(value, name)
82
+ unless value.is_a?(Integer) && value.positive?
83
+ raise InvalidInput, "#{name} must be a positive integer"
84
+ end
85
+
86
+ value
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # What a backend can actually guarantee.
6
+ #
7
+ # Capabilities are the Gem's fail-closed gate. Policy refuses to start a session
8
+ # unless a backend claims *enforced* egress isolation; a URL allowlist in Ruby can
9
+ # never substitute for it. Local mode needs a host-pinned server binary, and the
10
+ # Stagehand SDK always merges +STAGEHAND_CUSTOM_HEADERS+, so those headers must be
11
+ # host-approved before any session starts.
12
+ class Capabilities
13
+ # Supported egress-isolation postures.
14
+ #
15
+ # - `:enforced` every initiated request stays within approved destinations and
16
+ # public address ranges at connection time
17
+ # - `:unverified` not proven; the Gem treats this as unusable for live sessions
18
+ EGRESS_ISOLATION = %i[enforced unverified].freeze
19
+
20
+ # Server/topology modes.
21
+ SERVER_MODES = %i[fake remote local].freeze
22
+
23
+ attr_reader :server_mode, :egress_isolation
24
+
25
+ def initialize(server_mode: :remote, egress_isolation: :unverified,
26
+ local_binary_pinned: false, custom_headers_present: false,
27
+ custom_headers_approved: false, act_supported: false)
28
+ @server_mode = symbolize!(server_mode, SERVER_MODES, :server_mode)
29
+ @egress_isolation = symbolize!(egress_isolation, EGRESS_ISOLATION, :egress_isolation)
30
+ @local_binary_pinned = !!local_binary_pinned
31
+ @custom_headers_present = !!custom_headers_present
32
+ @custom_headers_approved = !!custom_headers_approved
33
+ @act_supported = !!act_supported
34
+ freeze
35
+ end
36
+
37
+ def local_mode? = server_mode == :local
38
+ def enforced_egress? = egress_isolation == :enforced
39
+ def local_binary_pinned? = @local_binary_pinned
40
+ def custom_headers_present? = @custom_headers_present
41
+ def custom_headers_approved? = @custom_headers_approved
42
+ def act_supported? = @act_supported
43
+
44
+ def to_h
45
+ {
46
+ server_mode: server_mode,
47
+ egress_isolation: egress_isolation,
48
+ local_binary_pinned: @local_binary_pinned,
49
+ custom_headers_present: @custom_headers_present,
50
+ custom_headers_approved: @custom_headers_approved,
51
+ act_supported: @act_supported
52
+ }
53
+ end
54
+
55
+ def digest = Redactor.digest(to_h)
56
+
57
+ def ==(other)
58
+ other.is_a?(Capabilities) && to_h == other.to_h
59
+ end
60
+ alias eql? ==
61
+
62
+ def hash = to_h.hash
63
+
64
+ def inspect = "#<#{self.class} #{to_h.map { |k, v| "#{k}=#{v}" }.join(" ")}>"
65
+
66
+ private
67
+
68
+ def symbolize!(value, allowed, name)
69
+ symbol = value.to_sym
70
+ return symbol if allowed.include?(symbol)
71
+
72
+ raise InvalidInput, "#{name} must be one of #{allowed.inspect}"
73
+ end
74
+ end
75
+ end
76
+ end