portage-cli 0.7.5 → 0.9.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 +4 -4
- data/CHANGELOG.md +616 -0
- data/README.md +302 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_opener.rb +38 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +559 -29
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +103 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +930 -43
- metadata +67 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "categorize"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module BrowserImport
|
|
6
|
+
# Writes an approved Importer#plan into the user's own index — only
|
|
7
|
+
# ever called after BrowserImport::Confirm said :save. A kept domain
|
|
8
|
+
# becomes an Index::Store entry with `sources: ["history"]`/
|
|
9
|
+
# `["bookmark"]`; one already in the index just gains those labels
|
|
10
|
+
# and the new category weights (its capabilities and last_verified
|
|
11
|
+
# are left for `index refresh` to re-check, same as Index::Builder's
|
|
12
|
+
# #update_existing). A domain kept because the published
|
|
13
|
+
# known-stores list already had it keeps that list's own
|
|
14
|
+
# last_verified — this import never probed it. A kept product page becomes an
|
|
15
|
+
# Index::ProductStore entry with the same `sources` labels, so
|
|
16
|
+
# Index::Exporter never publishes it.
|
|
17
|
+
class Saver
|
|
18
|
+
def initialize(stores:, products:, now: Time.now)
|
|
19
|
+
@stores = stores
|
|
20
|
+
@products = products
|
|
21
|
+
@now = now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash] stores:, products: — how many entries were written.
|
|
25
|
+
def save(plan)
|
|
26
|
+
kept = Array(plan[:kept])
|
|
27
|
+
products = Array(plan[:products])
|
|
28
|
+
kept.each { |entry| save_store(entry) }
|
|
29
|
+
products.each { |product| save_product(product) }
|
|
30
|
+
{ stores: kept.length, products: products.length }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def save_store(entry)
|
|
36
|
+
existing = @stores.find(entry[:origin])
|
|
37
|
+
fields = { sources: (Array(existing && existing["sources"]) + entry[:sources]).uniq,
|
|
38
|
+
categories: merge_categories(existing && existing["categories"], entry[:categories]) }
|
|
39
|
+
fields.merge!(new_store_fields(entry)) unless existing
|
|
40
|
+
@stores.upsert(entry[:origin], **fields)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def new_store_fields(entry)
|
|
44
|
+
{ platform: nil, capabilities: Array(entry[:capabilities]), webmcp_preset: entry[:webmcp_preset],
|
|
45
|
+
last_verified: entry[:last_verified] || @now.to_i, handoff_only: entry[:handoff_only] ? true : false }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def merge_categories(existing, fresh)
|
|
49
|
+
tally = Hash.new(0)
|
|
50
|
+
[existing, fresh].each { |cats| Hash(cats).each { |id, weight| tally[id.to_s] += weight.to_i } }
|
|
51
|
+
tally.sort_by { |_id, weight| -weight }.first(Categorize::TOP_CATEGORIES).to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def save_product(product)
|
|
55
|
+
@products.upsert(product[:key], origin: product[:origin], seen_at: @now.to_i, title: product[:title],
|
|
56
|
+
brand: nil, gtin: nil, category: product[:category],
|
|
57
|
+
sources: [product[:source]])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
module BrowserImport
|
|
9
|
+
# Reads a browser's SQLite history database without a native gem:
|
|
10
|
+
# the database (plus its `-wal` file, when the browser left one — it
|
|
11
|
+
# holds the most recent visits until the next checkpoint) is copied
|
|
12
|
+
# into a private tmpdir first, since a running browser holds it
|
|
13
|
+
# locked, and the copy is queried with the system `sqlite3` CLI in
|
|
14
|
+
# `-readonly -json` mode. The copy is deleted before this returns,
|
|
15
|
+
# success or not. The original file is only ever read by the copy.
|
|
16
|
+
#
|
|
17
|
+
# `sqlite3` ships with macOS and every mainstream Linux distro; when
|
|
18
|
+
# it's missing, Unavailable says so rather than falling back to
|
|
19
|
+
# anything else.
|
|
20
|
+
class Sqlite
|
|
21
|
+
class Unavailable < StandardError; end
|
|
22
|
+
|
|
23
|
+
# Raised when macOS refuses to let this process read the file at
|
|
24
|
+
# all (Safari's History.db without Full Disk Access). Never worked
|
|
25
|
+
# around — see Importer's own handling.
|
|
26
|
+
class PermissionDenied < StandardError; end
|
|
27
|
+
|
|
28
|
+
# @param command [String] the sqlite3 executable — injectable so a
|
|
29
|
+
# spec can prove the missing-binary path without uninstalling it.
|
|
30
|
+
def initialize(command: "sqlite3")
|
|
31
|
+
@command = command
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Copies `path` once and yields a query proc, so one copy serves
|
|
35
|
+
# several queries (Firefox's history and bookmarks both live in
|
|
36
|
+
# places.sqlite).
|
|
37
|
+
# @yieldparam query [Proc] `query.call(sql)` => Array<Hash>
|
|
38
|
+
def with_copy(path)
|
|
39
|
+
Dir.mktmpdir("portage-browser-import") do |dir|
|
|
40
|
+
copy = copy_database(path, dir)
|
|
41
|
+
yield ->(sql) { run(copy, sql) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def copy_database(path, dir)
|
|
48
|
+
copy = File.join(dir, "copy.sqlite")
|
|
49
|
+
FileUtils.cp(path, copy)
|
|
50
|
+
FileUtils.cp("#{path}-wal", "#{copy}-wal") if File.exist?("#{path}-wal")
|
|
51
|
+
copy
|
|
52
|
+
rescue Errno::EPERM, Errno::EACCES => e
|
|
53
|
+
raise PermissionDenied, e.message
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run(copy, sql)
|
|
57
|
+
out, err, status = Open3.capture3(@command, "-readonly", "-json", copy, sql)
|
|
58
|
+
raise Unavailable, "sqlite3 failed: #{err.strip}" unless status.success?
|
|
59
|
+
|
|
60
|
+
text = out.dup.force_encoding("UTF-8").scrub
|
|
61
|
+
text.strip.empty? ? [] : JSON.parse(text)
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
raise Unavailable, "the sqlite3 command isn't installed (it ships with macOS and most Linux distros)"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative "browser_import/filter"
|
|
2
|
+
require_relative "browser_import/profiles"
|
|
3
|
+
require_relative "browser_import/sqlite"
|
|
4
|
+
require_relative "browser_import/plist_xml"
|
|
5
|
+
require_relative "browser_import/readers"
|
|
6
|
+
require_relative "browser_import/prober"
|
|
7
|
+
require_relative "browser_import/importer"
|
|
8
|
+
require_relative "browser_import/confirm"
|
|
9
|
+
|
|
10
|
+
module Portage
|
|
11
|
+
module Cli
|
|
12
|
+
# `portage browser import` — bookmarks and history as index seeds
|
|
13
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 3, Tier A). Opt-in,
|
|
14
|
+
# local-only, reduced to shop domains, and shown to the user before
|
|
15
|
+
# anything is saved. Never reads a browser's password, cookie or
|
|
16
|
+
# autofill store (see Profiles::ALLOWED_FILES), never attaches to or
|
|
17
|
+
# drives the browser, and never sends anything about the user's
|
|
18
|
+
# history anywhere except one `/.well-known/ucp` probe per unknown
|
|
19
|
+
# domain (Prober).
|
|
20
|
+
module BrowserImport
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# The one shell-out that opens a URL in the shopper's browser, shared by
|
|
4
|
+
# CheckoutHandoff (a dead-end checkout's auto-open) and ProductPage
|
|
5
|
+
# (docs/plans/human-pick-and-approve.md Phase 2's "view the product
|
|
6
|
+
# page"). Mixed in rather than called as a module function so `system`
|
|
7
|
+
# stays the including object's own — specs stub it per instance.
|
|
8
|
+
#
|
|
9
|
+
# No new gem for the actual open — every other shell-out in this repo
|
|
10
|
+
# (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
|
|
11
|
+
# `system` rather than pulling in launchy for something the OS already
|
|
12
|
+
# provides. `system(cmd, url)` (array form, never an interpolated
|
|
13
|
+
# string) so a merchant-controlled URL can't inject into a shell.
|
|
14
|
+
# Whether a URL is safe to open at all is the caller's check.
|
|
15
|
+
module BrowserOpener
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] whether the browser was actually opened.
|
|
19
|
+
def open_browser(url)
|
|
20
|
+
command = platform_command
|
|
21
|
+
return false unless command
|
|
22
|
+
|
|
23
|
+
!!system(command, url)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn "portage: couldn't open #{url} (#{e.message})"
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def platform_command
|
|
30
|
+
case RbConfig::CONFIG["host_os"]
|
|
31
|
+
when /darwin/i then "open"
|
|
32
|
+
when /linux|bsd/i then "xdg-open"
|
|
33
|
+
when /mswin|mingw|cygwin/i then "start"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_relative "../handoff_only"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module BrowserProfile
|
|
6
|
+
# The domain allowlist Phase 6 requires: driving the profile browser
|
|
7
|
+
# (evaluating WebMCP tool calls, filling checkout fields, navigating)
|
|
8
|
+
# is limited to the store being bought from, plus its checkout host
|
|
9
|
+
# once one is known. Anything else stops the run — see Bridge and
|
|
10
|
+
# DomainNotAllowedError.
|
|
11
|
+
#
|
|
12
|
+
# Reuses HandoffOnly's own host normalization/matching (a base host
|
|
13
|
+
# matches itself or any subdomain, "www." stripped, a full URL or a
|
|
14
|
+
# bare host both accepted) rather than a second copy of that logic.
|
|
15
|
+
class Allowlist
|
|
16
|
+
def initialize(hosts: [])
|
|
17
|
+
@hosts = hosts.filter_map { |h| HandoffOnly.normalize_entry(h) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def hosts = @hosts.dup
|
|
21
|
+
|
|
22
|
+
def allowed?(host) = HandoffOnly.matches_any?(host, @hosts)
|
|
23
|
+
|
|
24
|
+
# Adds a host once it's known to be exactly what Portage itself is
|
|
25
|
+
# deliberately navigating to (the checkout URL a hand-off is about
|
|
26
|
+
# to open) — never called for a navigation a driven page made on
|
|
27
|
+
# its own; that's what #allowed? guards against.
|
|
28
|
+
# @return [String, nil] the normalized host that was added (or
|
|
29
|
+
# already present), nil when `host_or_url` didn't parse to one.
|
|
30
|
+
def permit!(host_or_url)
|
|
31
|
+
normalized = HandoffOnly.normalize_entry(host_or_url)
|
|
32
|
+
return nil unless normalized
|
|
33
|
+
|
|
34
|
+
@hosts << normalized unless @hosts.include?(normalized)
|
|
35
|
+
normalized
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "allowlist"
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
module BrowserProfile
|
|
8
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: the WebMCP
|
|
9
|
+
# bridge `Cli.run_buy` hands to `Buy.new(webmcp_bridge:)` when
|
|
10
|
+
# `--handoff-target profile` — the Portage browser profile's own
|
|
11
|
+
# page target, reached over CdpSocket, wrapped so every call stays
|
|
12
|
+
# inside the domain allowlist. Everything WebMCP-shaped (#list_tools,
|
|
13
|
+
# #execute_tool, #autofill) is delegated to a real
|
|
14
|
+
# Portage::Ucp::WebMcp::Bridges::ScriptEvaluator built from this
|
|
15
|
+
# bridge's own #evaluate — this class exists to add exactly two
|
|
16
|
+
# things ScriptEvaluator doesn't have: the allowlist check on every
|
|
17
|
+
# call, and #navigate (Portage's own deliberate navigation to a
|
|
18
|
+
# checkout URL, as opposed to a page-driven one).
|
|
19
|
+
#
|
|
20
|
+
# Requires `portage-ucp-webmcp` to already be loaded — only ever
|
|
21
|
+
# built by `Cli.run_buy` after `Portage::Cli::Webmcp.available?`,
|
|
22
|
+
# same guard every other WebMCP call site in this gem already uses
|
|
23
|
+
# (see Buy#webmcp_flow) — so nothing here calls `require` itself.
|
|
24
|
+
#
|
|
25
|
+
# #headless? is always false: this is, by construction, a headed
|
|
26
|
+
# browser the shopper can see and pay in (WebMcp::Autofill's own
|
|
27
|
+
# `headless?(bridge)` check — see docs/plans/
|
|
28
|
+
# webmcp-universal-outbound.md Phase 3 — never returns
|
|
29
|
+
# :needs_headed_browser for it).
|
|
30
|
+
class Bridge
|
|
31
|
+
def initialize(socket:, allowlist:)
|
|
32
|
+
@socket = socket
|
|
33
|
+
@allowlist = allowlist
|
|
34
|
+
@page_enabled = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def headless? = false
|
|
38
|
+
|
|
39
|
+
def location
|
|
40
|
+
raw_evaluate("Promise.resolve(window.location.href)")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Portage's own navigation — to the checkout URL a hand-off is
|
|
44
|
+
# about to show the shopper. Permits that URL's host first (this
|
|
45
|
+
# is exactly "the store's checkout host" the allowlist is meant to
|
|
46
|
+
# grow to include, per the plan), then navigates.
|
|
47
|
+
def navigate(url)
|
|
48
|
+
host = URI.parse(url.to_s).host
|
|
49
|
+
@allowlist.permit!(host)
|
|
50
|
+
ensure_page_enabled!
|
|
51
|
+
@socket.call("Page.navigate", "url" => url.to_s)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def list_tools = script_evaluator.list_tools
|
|
56
|
+
def execute_tool(name, input) = script_evaluator.execute_tool(name, input)
|
|
57
|
+
def autofill(fields, selectors: {}) = script_evaluator.autofill(fields, selectors: selectors)
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def script_evaluator
|
|
62
|
+
@script_evaluator ||= Portage::Ucp::WebMcp::Bridges::ScriptEvaluator.new(evaluate: method(:evaluate),
|
|
63
|
+
headless: false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Every driven call (a WebMCP tool call, an autofill attempt)
|
|
67
|
+
# checks the page's *current* location against the allowlist
|
|
68
|
+
# before running — including a navigation a previous call already
|
|
69
|
+
# caused (e.g. a preset's handoff_checkout tool navigating to
|
|
70
|
+
# checkout): that navigation's own evaluate call already ran
|
|
71
|
+
# before the page moved, so this is the first following call that
|
|
72
|
+
# can actually see where it ended up. That's the plan's "stops the
|
|
73
|
+
# run" in practice — a driven page that jumps somewhere unexpected
|
|
74
|
+
# is caught on its very next use, not mid-navigation (there's no
|
|
75
|
+
# navigation-event hook in this minimal a CDP client).
|
|
76
|
+
def evaluate(expression)
|
|
77
|
+
enforce_allowlist!
|
|
78
|
+
raw_evaluate(expression)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enforce_allowlist!
|
|
82
|
+
host = current_host
|
|
83
|
+
return if @allowlist.allowed?(host)
|
|
84
|
+
|
|
85
|
+
raise DomainNotAllowedError,
|
|
86
|
+
"the profile browser navigated to #{host.inspect}, outside the allowed domains " \
|
|
87
|
+
"(#{@allowlist.hosts.join(', ')}) — stopping"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_host
|
|
91
|
+
URI.parse(raw_evaluate("Promise.resolve(window.location.href)").to_s).host
|
|
92
|
+
rescue URI::InvalidURIError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raw_evaluate(expression)
|
|
97
|
+
response = @socket.call("Runtime.evaluate", "expression" => expression, "awaitPromise" => true,
|
|
98
|
+
"returnByValue" => true, "userGesture" => true)
|
|
99
|
+
raise_on_exception!(response)
|
|
100
|
+
response.dig("result", "value")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def raise_on_exception!(response)
|
|
104
|
+
details = response["exceptionDetails"]
|
|
105
|
+
return unless details
|
|
106
|
+
|
|
107
|
+
text = details.dig("exception", "description") || details["text"] || "script evaluation failed"
|
|
108
|
+
raise Portage::Ucp::WebMcp::BridgeError, text
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ensure_page_enabled!
|
|
112
|
+
return if @page_enabled
|
|
113
|
+
|
|
114
|
+
@socket.call("Page.enable", {})
|
|
115
|
+
@page_enabled = true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require_relative "../browser_import/profiles"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module BrowserProfile
|
|
6
|
+
# Where to find a Chromium-family browser's own executable, and
|
|
7
|
+
# which family is supported at all (docs/plans/
|
|
8
|
+
# buy-skill-and-local-browser.md Phase 6). Firefox/Safari are out of
|
|
9
|
+
# scope for driving (see the plan's Phase 6 section) even though
|
|
10
|
+
# BrowserImport::Profiles already knows their profile roots — this
|
|
11
|
+
# module only ever launches one of CHROMIUM.
|
|
12
|
+
module Browsers
|
|
13
|
+
CHROMIUM = BrowserImport::Profiles::CHROMIUM
|
|
14
|
+
|
|
15
|
+
# macOS app bundle executables. The first existing path wins.
|
|
16
|
+
MAC_APPS = {
|
|
17
|
+
"chrome" => "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
18
|
+
"edge" => "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
19
|
+
"brave" => "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
20
|
+
"arc" => "/Applications/Arc.app/Contents/MacOS/Arc"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Linux binary names, tried on PATH. Arc has no Linux build.
|
|
24
|
+
LINUX_BINARIES = {
|
|
25
|
+
"chrome" => %w[google-chrome google-chrome-stable chromium chromium-browser],
|
|
26
|
+
"edge" => %w[microsoft-edge microsoft-edge-stable],
|
|
27
|
+
"brave" => %w[brave-browser brave-browser-stable],
|
|
28
|
+
"arc" => []
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# @param path [String] PATH-shaped, injectable so a spec can point
|
|
32
|
+
# ".which" at a fixture directory instead of mutating ENV.
|
|
33
|
+
# @return [String, nil] the first launchable binary for `browser`
|
|
34
|
+
# on this machine, or nil when it isn't installed.
|
|
35
|
+
def self.binary_for(browser, darwin: RUBY_PLATFORM.include?("darwin"), path: ENV.fetch("PATH", ""))
|
|
36
|
+
mac_binary(browser, darwin) || linux_binary(browser, path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.mac_binary(browser, darwin)
|
|
40
|
+
return nil unless darwin
|
|
41
|
+
|
|
42
|
+
path = MAC_APPS[browser]
|
|
43
|
+
path if path && File.exist?(path)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :mac_binary
|
|
46
|
+
|
|
47
|
+
def self.linux_binary(browser, path)
|
|
48
|
+
LINUX_BINARIES.fetch(browser, []).filter_map { |name| which(name, path) }.first
|
|
49
|
+
end
|
|
50
|
+
private_class_method :linux_binary
|
|
51
|
+
|
|
52
|
+
def self.which(cmd, path)
|
|
53
|
+
path.split(File::PATH_SEPARATOR).map { |dir| File.join(dir, cmd) }
|
|
54
|
+
.find { |candidate| File.file?(candidate) && File.executable?(candidate) }
|
|
55
|
+
end
|
|
56
|
+
private_class_method :which
|
|
57
|
+
|
|
58
|
+
# @return [String, nil] the first CHROMIUM browser that's actually
|
|
59
|
+
# installed on this machine, reusing BrowserImport::Profiles's
|
|
60
|
+
# own "does this browser's default profile root exist" check
|
|
61
|
+
# purely as an installed-or-not signal — nothing under that root
|
|
62
|
+
# is ever read here.
|
|
63
|
+
def self.detect(home: Dir.home)
|
|
64
|
+
CHROMIUM.find { |b| BrowserImport::Profiles.default_root(b, home: home) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "portage/ucp/support/connection"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
module BrowserProfile
|
|
9
|
+
# The plain-HTTP half of Chrome DevTools Protocol: the
|
|
10
|
+
# `http://127.0.0.1:<port>/json/*` endpoints every Chromium browser
|
|
11
|
+
# exposes once started with `--remote-debugging-port`. Used for
|
|
12
|
+
# "is the profile running, and what tab is it on" (`.version`/
|
|
13
|
+
# `.list`) and to open a tab already navigated to a URL (`.new_tab`)
|
|
14
|
+
# without needing the WebSocket layer (CdpSocket) at all for that.
|
|
15
|
+
#
|
|
16
|
+
# Every call is read-only against the local debugging port, GET/PUT
|
|
17
|
+
# requests only, `route: :probe` (same route category ProbeCache's
|
|
18
|
+
# own `/.well-known/ucp` probes use) so it's subject to whatever
|
|
19
|
+
# proxy config a `--no-proxy` list already exempts localhost from.
|
|
20
|
+
# A failure (not running, timeout, bad JSON) returns nil rather than
|
|
21
|
+
# raising — callers (Profile) decide what that means.
|
|
22
|
+
module Cdp
|
|
23
|
+
TIMEOUT = 3
|
|
24
|
+
HOST = "127.0.0.1".freeze
|
|
25
|
+
|
|
26
|
+
def self.version(port:, host: HOST) = get(host, port, "/json/version")
|
|
27
|
+
|
|
28
|
+
def self.list(port:, host: HOST) = Array(get(host, port, "/json/list"))
|
|
29
|
+
|
|
30
|
+
# Opens a new tab already navigated to `url` — Chrome's own
|
|
31
|
+
# `/json/new?<url>` endpoint, a PUT since it creates something
|
|
32
|
+
# server-side.
|
|
33
|
+
def self.new_tab(port:, url:, host: HOST)
|
|
34
|
+
get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `/json/close/<id>` answers plain text ("Target is closing"), not
|
|
38
|
+
# JSON — checked by success status only, never parsed as JSON.
|
|
39
|
+
def self.close_tab(port:, id:, host: HOST)
|
|
40
|
+
uri = URI("http://#{host}:#{port}/json/close/#{id}")
|
|
41
|
+
fetch(uri, :get).is_a?(Net::HTTPSuccess)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.get(host, port, path, method: :get)
|
|
47
|
+
uri = URI("http://#{host}:#{port}#{path}")
|
|
48
|
+
response = fetch(uri, method)
|
|
49
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
50
|
+
|
|
51
|
+
JSON.parse(response.body)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
private_class_method :get
|
|
56
|
+
|
|
57
|
+
def self.fetch(uri, method)
|
|
58
|
+
Portage::Ucp::Support::Connection.start(uri, route: :probe, open_timeout: TIMEOUT,
|
|
59
|
+
read_timeout: TIMEOUT) do |http|
|
|
60
|
+
method == :put ? http.request(Net::HTTP::Put.new(uri.request_uri)) : http.get(uri.request_uri)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
private_class_method :fetch
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
module Portage
|
|
10
|
+
module Cli
|
|
11
|
+
module BrowserProfile
|
|
12
|
+
# A minimal, dependency-free WebSocket JSON-RPC client for a Chrome
|
|
13
|
+
# DevTools Protocol target — just enough to call `Runtime.evaluate`/
|
|
14
|
+
# `Page.navigate`/`Page.enable` on a page's own `webSocketDebuggerUrl`
|
|
15
|
+
# synchronously, one request at a time. Not a general WebSocket
|
|
16
|
+
# client: text frames only, no compression extension, and no
|
|
17
|
+
# concurrent in-flight requests — Bridge never has two CDP calls
|
|
18
|
+
# outstanding at once, so `#call` blocks until its own response (by
|
|
19
|
+
# `id`) comes back, discarding any unsolicited event frame in
|
|
20
|
+
# between.
|
|
21
|
+
#
|
|
22
|
+
# `transport:` is injectable (any object answering `#write`/`#read`/
|
|
23
|
+
# `#close`, e.g. a real TCPSocket) so specs never open a real socket
|
|
24
|
+
# — see cdp_socket_spec.rb.
|
|
25
|
+
class CdpSocket
|
|
26
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
|
|
27
|
+
TIMEOUT = 30
|
|
28
|
+
|
|
29
|
+
def self.connect(ws_url, transport: nil, timeout: TIMEOUT)
|
|
30
|
+
uri = URI.parse(ws_url)
|
|
31
|
+
transport ||= TCPSocket.new(uri.host, uri.port)
|
|
32
|
+
handshake!(transport, uri, timeout: timeout)
|
|
33
|
+
new(transport)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(transport)
|
|
37
|
+
@transport = transport
|
|
38
|
+
@next_id = 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] the CDP "result" object.
|
|
42
|
+
# @raise [RuntimeError] the CDP "error" object's message, when the
|
|
43
|
+
# browser itself rejected the command (bad method/params, no
|
|
44
|
+
# such target) — distinct from an *evaluated script* raising,
|
|
45
|
+
# which comes back as a normal result with `exceptionDetails`.
|
|
46
|
+
def call(method, params = {})
|
|
47
|
+
id = @next_id
|
|
48
|
+
@next_id += 1
|
|
49
|
+
send_frame(JSON.generate(id: id, method: method, params: params))
|
|
50
|
+
await_response(id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def close
|
|
54
|
+
@transport.close
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def await_response(id)
|
|
62
|
+
loop do
|
|
63
|
+
message = JSON.parse(read_message)
|
|
64
|
+
next if message["id"] != id
|
|
65
|
+
|
|
66
|
+
raise message.dig("error", "message").to_s if message["error"]
|
|
67
|
+
|
|
68
|
+
return message["result"] || {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- outbound framing (client -> server frames MUST be masked,
|
|
73
|
+
# RFC 6455 §5.1) ---
|
|
74
|
+
|
|
75
|
+
def send_frame(payload)
|
|
76
|
+
bytes = payload.b
|
|
77
|
+
mask = SecureRandom.random_bytes(4)
|
|
78
|
+
@transport.write(frame_header(0x1, bytes.bytesize) + mask + xor(bytes, mask))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def send_pong(payload)
|
|
82
|
+
bytes = payload.to_s.b
|
|
83
|
+
mask = SecureRandom.random_bytes(4)
|
|
84
|
+
@transport.write(frame_header(0xA, bytes.bytesize) + mask + xor(bytes, mask))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def frame_header(opcode, length)
|
|
88
|
+
first = 0x80 | opcode # FIN=1
|
|
89
|
+
return [first, 0x80 | length].pack("CC") if length <= 125
|
|
90
|
+
return [first, 0x80 | 126, length].pack("CCn") if length <= 0xFFFF
|
|
91
|
+
|
|
92
|
+
[first, 0x80 | 127, length].pack("CCQ>")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def xor(bytes, mask)
|
|
96
|
+
bytes.each_byte.with_index.map { |byte, i| byte ^ mask.getbyte(i % 4) }.pack("C*")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- inbound framing (server -> client frames are never masked)
|
|
100
|
+
# ---
|
|
101
|
+
|
|
102
|
+
# Reads whole logical messages (following any continuation
|
|
103
|
+
# frames), answering pings and dropping pongs, until it has one
|
|
104
|
+
# worth handing to #await_response.
|
|
105
|
+
def read_message
|
|
106
|
+
buffer = +""
|
|
107
|
+
loop do
|
|
108
|
+
fin, opcode, payload = read_one_frame
|
|
109
|
+
case opcode
|
|
110
|
+
when 0x9 then send_pong(payload)
|
|
111
|
+
when 0x8 then raise "CDP socket closed by the browser"
|
|
112
|
+
when 0xA then nil
|
|
113
|
+
else buffer << payload
|
|
114
|
+
end
|
|
115
|
+
return buffer if fin && [0x0, 0x1].include?(opcode)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_one_frame
|
|
120
|
+
b1, b2 = read_exactly(2).unpack("CC")
|
|
121
|
+
fin = b1[7] == 1
|
|
122
|
+
opcode = b1 & 0x0F
|
|
123
|
+
masked = b2[7] == 1
|
|
124
|
+
[fin, opcode, read_payload(b2 & 0x7F, masked)]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def read_payload(length, masked)
|
|
128
|
+
length = read_exactly(2).unpack1("n") if length == 126
|
|
129
|
+
length = read_exactly(8).unpack1("Q>") if length == 127
|
|
130
|
+
mask = read_exactly(4) if masked
|
|
131
|
+
payload = length.positive? ? read_exactly(length) : +""
|
|
132
|
+
mask ? xor(payload, mask) : payload
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_exactly(length)
|
|
136
|
+
return +"" if length <= 0
|
|
137
|
+
|
|
138
|
+
Timeout.timeout(TIMEOUT) do
|
|
139
|
+
data = +""
|
|
140
|
+
while data.bytesize < length
|
|
141
|
+
chunk = @transport.read(length - data.bytesize)
|
|
142
|
+
raise "CDP socket closed by the browser" if chunk.nil?
|
|
143
|
+
|
|
144
|
+
data << chunk
|
|
145
|
+
end
|
|
146
|
+
data
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- handshake (RFC 6455 §4) ---
|
|
151
|
+
|
|
152
|
+
def self.handshake!(transport, uri, timeout:)
|
|
153
|
+
key = SecureRandom.base64(16)
|
|
154
|
+
Timeout.timeout(timeout) { transport.write(handshake_request(uri, key)) }
|
|
155
|
+
headers = Timeout.timeout(timeout) { read_headers(transport) }
|
|
156
|
+
verify_handshake!(headers, key)
|
|
157
|
+
end
|
|
158
|
+
private_class_method :handshake!
|
|
159
|
+
|
|
160
|
+
def self.handshake_request(uri, key)
|
|
161
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
162
|
+
path += "?#{uri.query}" if uri.query
|
|
163
|
+
"GET #{path} HTTP/1.1\r\nHost: #{uri.host}:#{uri.port}\r\nUpgrade: websocket\r\n" \
|
|
164
|
+
"Connection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
165
|
+
end
|
|
166
|
+
private_class_method :handshake_request
|
|
167
|
+
|
|
168
|
+
def self.read_headers(transport)
|
|
169
|
+
data = +""
|
|
170
|
+
data << transport.read(1) until data.end_with?("\r\n\r\n")
|
|
171
|
+
data
|
|
172
|
+
end
|
|
173
|
+
private_class_method :read_headers
|
|
174
|
+
|
|
175
|
+
def self.verify_handshake!(headers, key)
|
|
176
|
+
raise "CDP handshake failed: #{headers.lines.first}" unless headers.start_with?("HTTP/1.1 101")
|
|
177
|
+
|
|
178
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
179
|
+
accept = headers[/Sec-WebSocket-Accept:\s*(\S+)/i, 1]
|
|
180
|
+
raise "CDP handshake failed: unexpected Sec-WebSocket-Accept" unless accept == expected
|
|
181
|
+
end
|
|
182
|
+
private_class_method :verify_handshake!
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|