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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. 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