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