class RubyLLM::Browser::Approval

A host-issued, single-use authorization for one exact browser action.

An approval binds actor, run, optional operation/session, the exact normalized URL, an optional page revision and the exact action digest, with a short expiry. It is consumed atomically once, immediately before dispatch, inside the tool. A bare RubyLLM ‘Chat#approve` carries none of these bindings and therefore dispatches nothing on its own.

Attributes

action[R]
action_digest[R]
actor_id[R]
consumed_at[R]
expires_at[R]
id[R]
issued_at[R]
operation_id[R]
page_revision[R]
run_id[R]
session_id[R]
url[R]

Public Class Methods

action_digest(action) click to toggle source

The digest a host and the Gem both compute for an action.

# File lib/ruby_llm/browser/approval.rb, line 51
def self.action_digest(action)
  Redactor.digest(action)
end
for_action(actor_id:, run_id:, url:, action:, ttl:, now:, id: nil, page_revision: nil, operation_id: nil, session_id: nil) click to toggle source

Builds an approval for action with a time-to-live.

@param action [Hash] an action already normalized by {Policy#authorize_action!} @param ttl [Numeric] seconds until expiry

# File lib/ruby_llm/browser/approval.rb, line 39
def self.for_action(actor_id:, run_id:, url:, action:, ttl:, now:, id: nil, page_revision: nil,
                    operation_id: nil, session_id: nil)
  new(
    id: id || Operation.generate_id(prefix: "apr"),
    actor_id: actor_id, run_id: run_id, url: url,
    action_digest: action_digest(action), action: action,
    page_revision: page_revision, operation_id: operation_id, session_id: session_id,
    issued_at: now, expires_at: now + ttl
  )
end
new(id:, actor_id:, run_id:, url:, action_digest:, expires_at:, issued_at:, page_revision: nil, operation_id: nil, session_id: nil, consumed_at: nil, revoked: false, action: nil) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 16
def initialize(id:, actor_id:, run_id:, url:, action_digest:, expires_at:, issued_at:,
               page_revision: nil, operation_id: nil, session_id: nil, consumed_at: nil,
               revoked: false, action: nil)
  @id = presence!(id, :id)
  @actor_id = presence!(actor_id, :actor_id)
  @run_id = presence!(run_id, :run_id)
  @url = presence!(url, :url)
  @action_digest = presence!(action_digest, :action_digest)
  @expires_at = time!(expires_at, :expires_at)
  @issued_at = time!(issued_at, :issued_at)
  @page_revision = Immutable.copy(page_revision)
  @operation_id = Immutable.copy(operation_id)
  @session_id = Immutable.copy(session_id)
  @consumed_at = Immutable.copy(consumed_at)
  @revoked = !!revoked
  @action = Immutable.copy(action)
  freeze
end
page_revision(observed) click to toggle source

The digest a host and the Gem both compute for an observed candidate list.

# File lib/ruby_llm/browser/approval.rb, line 56
def self.page_revision(observed)
  Redactor.digest(observed)
end

Public Instance Methods

active?(now) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 63
    def active?(now) = !consumed? && !revoked? && !expired?(now)

    # True when the approval authorizes exactly this actor/run/url/revision/digest.
    def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
                         operation_id: nil, session_id: nil)
      return false unless @actor_id == actor_id && @run_id == run_id
      return false unless @url == url
      return false unless @action_digest == action_digest
      return false unless @page_revision.nil? || @page_revision == page_revision
      return false unless @operation_id.nil? || @operation_id == operation_id
      return false unless @session_id.nil? || @session_id == session_id

      true
    end

    # Returns a consumed copy.
    def consume!(now:)
      raise ApprovalStale, "approval #{id} was already consumed" if consumed?

      copy(consumed_at: now)
    end

    # Returns a revoked copy.
    def revoke!
      copy(revoked: true)
    end
    def to_h
      {
        id: id, actor_id: actor_id, run_id: run_id, url: url,
        action_digest: action_digest, action: action, page_revision: page_revision,
        operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
        consumed_at: consumed_at, revoked: revoked?
      }
    end

    def digest = Redactor.digest(to_h)

    def inspect
      "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
    end

    private

    def copy(consumed_at: @consumed_at, revoked: @revoked)
      self.class.new(
        id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
        action: action, page_revision: page_revision, operation_id: operation_id,
        session_id: session_id, issued_at: issued_at, expires_at: expires_at,
        consumed_at: consumed_at, revoked: revoked
      )
    end

    def presence!(value, name)
      raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

      value.dup.freeze
    end

    def time!(value, name)
      raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

      value.dup.freeze
    end
  end
end
binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil, operation_id: nil, session_id: nil) click to toggle source

True when the approval authorizes exactly this actor/run/url/revision/digest.

# File lib/ruby_llm/browser/approval.rb, line 66
def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
                     operation_id: nil, session_id: nil)
  return false unless @actor_id == actor_id && @run_id == run_id
  return false unless @url == url
  return false unless @action_digest == action_digest
  return false unless @page_revision.nil? || @page_revision == page_revision
  return false unless @operation_id.nil? || @operation_id == operation_id
  return false unless @session_id.nil? || @session_id == session_id

  true
end
consume!(now:) click to toggle source

Returns a consumed copy.

# File lib/ruby_llm/browser/approval.rb, line 79
def consume!(now:)
  raise ApprovalStale, "approval #{id} was already consumed" if consumed?

  copy(consumed_at: now)
end
consumed?(= !@consumed_at.nil?) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 60
  def consumed? = !@consumed_at.nil?
  def revoked? = !!@revoked
  def expired?(now) = now >= expires_at
  def active?(now) = !consumed? && !revoked? && !expired?(now)

  # True when the approval authorizes exactly this actor/run/url/revision/digest.
  def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
                       operation_id: nil, session_id: nil)
    return false unless @actor_id == actor_id && @run_id == run_id
    return false unless @url == url
    return false unless @action_digest == action_digest
    return false unless @page_revision.nil? || @page_revision == page_revision
    return false unless @operation_id.nil? || @operation_id == operation_id
    return false unless @session_id.nil? || @session_id == session_id

    true
  end

  # Returns a consumed copy.
  def consume!(now:)
    raise ApprovalStale, "approval #{id} was already consumed" if consumed?

    copy(consumed_at: now)
  end

  # Returns a revoked copy.
  def revoke!
    copy(revoked: true)
  end
  def to_h
    {
      id: id, actor_id: actor_id, run_id: run_id, url: url,
      action_digest: action_digest, action: action, page_revision: page_revision,
      operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
      consumed_at: consumed_at, revoked: revoked?
    }
  end

  def digest = Redactor.digest(to_h)

  def inspect
    "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
  end

  private

  def copy(consumed_at: @consumed_at, revoked: @revoked)
    self.class.new(
      id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
      action: action, page_revision: page_revision, operation_id: operation_id,
      session_id: session_id, issued_at: issued_at, expires_at: expires_at,
      consumed_at: consumed_at, revoked: revoked
    )
  end

  def presence!(value, name)
    raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

    value.dup.freeze
  end

  def time!(value, name)
    raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

    value.dup.freeze
  end
end
copy(consumed_at: @consumed_at, revoked: @revoked) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 106
def copy(consumed_at: @consumed_at, revoked: @revoked)
  self.class.new(
    id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
    action: action, page_revision: page_revision, operation_id: operation_id,
    session_id: session_id, issued_at: issued_at, expires_at: expires_at,
    consumed_at: consumed_at, revoked: revoked
  )
end
digest(= Redactor.digest(to_h)) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 98
  def digest = Redactor.digest(to_h)

  def inspect
    "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
  end

  private

  def copy(consumed_at: @consumed_at, revoked: @revoked)
    self.class.new(
      id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
      action: action, page_revision: page_revision, operation_id: operation_id,
      session_id: session_id, issued_at: issued_at, expires_at: expires_at,
      consumed_at: consumed_at, revoked: revoked
    )
  end

  def presence!(value, name)
    raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

    value.dup.freeze
  end

  def time!(value, name)
    raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

    value.dup.freeze
  end
end
expired?(now) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 62
      def expired?(now) = now >= expires_at
      def active?(now) = !consumed? && !revoked? && !expired?(now)

      # True when the approval authorizes exactly this actor/run/url/revision/digest.
      def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
                           operation_id: nil, session_id: nil)
        return false unless @actor_id == actor_id && @run_id == run_id
        return false unless @url == url
        return false unless @action_digest == action_digest
        return false unless @page_revision.nil? || @page_revision == page_revision
        return false unless @operation_id.nil? || @operation_id == operation_id
        return false unless @session_id.nil? || @session_id == session_id

        true
      end

      # Returns a consumed copy.
      def consume!(now:)
        raise ApprovalStale, "approval #{id} was already consumed" if consumed?

        copy(consumed_at: now)
      end

      # Returns a revoked copy.
      def revoke!
        copy(revoked: true)
      end
      def to_h
        {
          id: id, actor_id: actor_id, run_id: run_id, url: url,
          action_digest: action_digest, action: action, page_revision: page_revision,
          operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
          consumed_at: consumed_at, revoked: revoked?
        }
      end

      def digest = Redactor.digest(to_h)

      def inspect
        "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
      end

      private

      def copy(consumed_at: @consumed_at, revoked: @revoked)
        self.class.new(
          id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
          action: action, page_revision: page_revision, operation_id: operation_id,
          session_id: session_id, issued_at: issued_at, expires_at: expires_at,
          consumed_at: consumed_at, revoked: revoked
        )
      end

      def presence!(value, name)
        raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

        value.dup.freeze
      end

      def time!(value, name)
        raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

        value.dup.freeze
      end
    end
  end
end
inspect() click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 100
def inspect
  "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
end
presence!(value, name) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 115
def presence!(value, name)
  raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

  value.dup.freeze
end
revoke!() click to toggle source

Returns a revoked copy.

# File lib/ruby_llm/browser/approval.rb, line 86
def revoke!
  copy(revoked: true)
end
revoked?(= !!@revoked) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 61
    def revoked? = !!@revoked
    def expired?(now) = now >= expires_at
    def active?(now) = !consumed? && !revoked? && !expired?(now)

    # True when the approval authorizes exactly this actor/run/url/revision/digest.
    def binding_matches?(actor_id:, run_id:, url:, action_digest:, page_revision: nil,
                         operation_id: nil, session_id: nil)
      return false unless @actor_id == actor_id && @run_id == run_id
      return false unless @url == url
      return false unless @action_digest == action_digest
      return false unless @page_revision.nil? || @page_revision == page_revision
      return false unless @operation_id.nil? || @operation_id == operation_id
      return false unless @session_id.nil? || @session_id == session_id

      true
    end

    # Returns a consumed copy.
    def consume!(now:)
      raise ApprovalStale, "approval #{id} was already consumed" if consumed?

      copy(consumed_at: now)
    end

    # Returns a revoked copy.
    def revoke!
      copy(revoked: true)
    end
    def to_h
      {
        id: id, actor_id: actor_id, run_id: run_id, url: url,
        action_digest: action_digest, action: action, page_revision: page_revision,
        operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
        consumed_at: consumed_at, revoked: revoked?
      }
    end

    def digest = Redactor.digest(to_h)

    def inspect
      "#<#{self.class} id=#{id} actor=#{actor_id} expires_at=#{expires_at&.getutc&.iso8601}>"
    end

    private

    def copy(consumed_at: @consumed_at, revoked: @revoked)
      self.class.new(
        id: id, actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
        action: action, page_revision: page_revision, operation_id: operation_id,
        session_id: session_id, issued_at: issued_at, expires_at: expires_at,
        consumed_at: consumed_at, revoked: revoked
      )
    end

    def presence!(value, name)
      raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?

      value.dup.freeze
    end

    def time!(value, name)
      raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

      value.dup.freeze
    end
  end
end
time!(value, name) click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 121
def time!(value, name)
  raise InvalidInput, "#{name} must be a Time" unless value.is_a?(Time)

  value.dup.freeze
end
to_h() click to toggle source
# File lib/ruby_llm/browser/approval.rb, line 89
def to_h
  {
    id: id, actor_id: actor_id, run_id: run_id, url: url,
    action_digest: action_digest, action: action, page_revision: page_revision,
    operation_id: operation_id, session_id: session_id, issued_at: issued_at, expires_at: expires_at,
    consumed_at: consumed_at, revoked: revoked?
  }
end