portage-cli 0.7.5 → 0.8.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 +559 -0
- data/README.md +266 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -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_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 +521 -29
- 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 +96 -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/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/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- 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 +525 -5
- metadata +58 -2
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -4,12 +4,17 @@ require "json"
|
|
|
4
4
|
require "portage/ucp"
|
|
5
5
|
require "portage/ucp/client"
|
|
6
6
|
require "portage/ucp/journal"
|
|
7
|
+
require_relative "agent_profile_url"
|
|
7
8
|
require_relative "payment_methods"
|
|
8
9
|
require_relative "setting"
|
|
9
10
|
require_relative "decisions"
|
|
10
11
|
require_relative "confidence_check"
|
|
11
12
|
require_relative "checkout_handoff"
|
|
12
13
|
require_relative "notifier"
|
|
14
|
+
require_relative "handoff_only"
|
|
15
|
+
require_relative "offer_sources"
|
|
16
|
+
require_relative "handoff_target"
|
|
17
|
+
require_relative "handoff_agents"
|
|
13
18
|
require_relative "user_agent"
|
|
14
19
|
require_relative "homepage_fetch"
|
|
15
20
|
require_relative "permissive_authenticator"
|
|
@@ -17,6 +22,11 @@ require_relative "handoff_reconciler"
|
|
|
17
22
|
require_relative "handoff_spend_mode"
|
|
18
23
|
require_relative "webmcp"
|
|
19
24
|
require_relative "webmcp_checkout_mode"
|
|
25
|
+
require_relative "webmcp_mappings"
|
|
26
|
+
require_relative "webmcp_mapping_confirm"
|
|
27
|
+
require_relative "webmcp_autofill_mode"
|
|
28
|
+
require_relative "webmcp_autofill_fields"
|
|
29
|
+
require_relative "webmcp_autofill_confirm"
|
|
20
30
|
|
|
21
31
|
module Portage
|
|
22
32
|
module Cli
|
|
@@ -47,6 +57,13 @@ module Portage
|
|
|
47
57
|
# webhook URL a dead-end checkout_url is POSTed to — nil (the
|
|
48
58
|
# default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
|
|
49
59
|
# Notifier).
|
|
60
|
+
# @param handoff_target [HandoffTarget, nil] docs/plans/
|
|
61
|
+
# buy-skill-and-local-browser.md Phase 5 — which of default/print/
|
|
62
|
+
# profile/agent:<name> a hand-off dispatches to. nil (the default)
|
|
63
|
+
# builds one from PORTAGE_HANDOFF_TARGET/config.json with no
|
|
64
|
+
# per-invocation override — `Cli.run_buy` builds and validates one
|
|
65
|
+
# from `--handoff-target` up front instead, the same posture as
|
|
66
|
+
# `confidence_check:`.
|
|
50
67
|
# @param confidence_check [ConfidenceCheck, nil] the opt-in confidence
|
|
51
68
|
# gate in front of an unattended completion. nil (the default) builds
|
|
52
69
|
# one from PORTAGE_DECISION_BACKEND / PORTAGE_MIN_CONFIDENCE, which is
|
|
@@ -63,17 +80,48 @@ module Portage
|
|
|
63
80
|
# @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
|
|
64
81
|
# handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
|
|
65
82
|
# Bridge (already pointed at a navigated page) an embedding caller
|
|
66
|
-
# already holds. nil (the default
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
83
|
+
# already holds. nil (the default) skips WebMCP entirely — zero
|
|
84
|
+
# behavior change from before this parameter existed. Given one,
|
|
85
|
+
# attempted after native-UCP discovery finds nothing at this URL
|
|
86
|
+
# and before a platform-adapter fallback (see #webmcp_flow).
|
|
87
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: `portage buy`
|
|
88
|
+
# from the shell now builds one itself — a
|
|
89
|
+
# Portage::Cli::BrowserProfile::Bridge onto the Portage browser
|
|
90
|
+
# profile — whenever `--handoff-target profile` resolves and that
|
|
91
|
+
# profile is running (`Cli.profile_webmcp_bridge`); any other
|
|
92
|
+
# target still leaves this nil, the CLI's original "no browser of
|
|
93
|
+
# its own" posture.
|
|
94
|
+
# @param webmcp_mappings [Portage::Cli::WebmcpMappings, nil] Phase 2's
|
|
95
|
+
# confirmed-mapping store (docs/plans/webmcp-universal-outbound.md).
|
|
96
|
+
# nil (the default) builds the real `~/.portage/webmcp_mappings.json`
|
|
97
|
+
# -backed one lazily, only if #webmcp_matched_session ever runs —
|
|
98
|
+
# injectable so specs don't touch it.
|
|
99
|
+
# @param webmcp_mapping_confirm [Portage::Cli::WebmcpMappingConfirm, nil]
|
|
100
|
+
# Phase 2's confirm-before-mutate gate. nil (the default) builds one
|
|
101
|
+
# from whether stdin is a real TTY and `json:` is off — injectable so
|
|
102
|
+
# a spec can simulate an interactive "y" without a real terminal.
|
|
103
|
+
# @param json [Boolean] whether this run is `--json` — Phase 2's
|
|
104
|
+
# mapping confirmation prompt never fires under it (same posture as
|
|
105
|
+
# "no TTY"), since a caller reading structured stdout has nowhere to
|
|
106
|
+
# put an interactive prompt.
|
|
107
|
+
# @param autofill [Boolean, nil] docs/plans/webmcp-universal-outbound.md
|
|
108
|
+
# Phase 3 — `--autofill`'s value: true when the flag was passed, nil
|
|
109
|
+
# (the default) otherwise. Only ever turns WebmcpAutofillMode on;
|
|
110
|
+
# `PORTAGE_WEBMCP_AUTOFILL=approve`/config.json still work with this
|
|
111
|
+
# left nil. Approving the mode is still only half of Phase 3's
|
|
112
|
+
# opt-in — see #webmcp_autofill_confirm.
|
|
113
|
+
# @param webmcp_autofill_confirm [Portage::Cli::WebmcpAutofillConfirm, nil]
|
|
114
|
+
# Phase 3's per-run "fill exactly these fields?" prompt. nil (the
|
|
115
|
+
# default) builds one from the same interactive? posture as Phase
|
|
116
|
+
# 2's webmcp_mapping_confirm; injectable so a spec can simulate an
|
|
117
|
+
# interactive "y" without a real terminal.
|
|
118
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
|
|
119
|
+
# injectable collaborators, each assigned to its own ivar
|
|
73
120
|
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
|
|
74
|
-
auto_open: nil, notify_webhook: nil,
|
|
75
|
-
max_price: nil, webmcp_bridge: nil
|
|
76
|
-
|
|
121
|
+
auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
|
|
122
|
+
transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
|
|
123
|
+
webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false)
|
|
124
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
77
125
|
raw = url.to_s.strip
|
|
78
126
|
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
79
127
|
@query = query
|
|
@@ -84,9 +132,15 @@ module Portage
|
|
|
84
132
|
@product_id = product_id
|
|
85
133
|
@auto_open = auto_open
|
|
86
134
|
@notify_webhook = notify_webhook
|
|
135
|
+
@handoff_target = handoff_target
|
|
87
136
|
@confidence_check = confidence_check
|
|
88
137
|
@transaction_log = transaction_log
|
|
89
138
|
@max_price = max_price
|
|
139
|
+
@webmcp_mappings = webmcp_mappings
|
|
140
|
+
@webmcp_mapping_confirm = webmcp_mapping_confirm
|
|
141
|
+
@autofill = autofill
|
|
142
|
+
@webmcp_autofill_confirm = webmcp_autofill_confirm
|
|
143
|
+
@json = json
|
|
90
144
|
@webmcp_bridge = webmcp_bridge
|
|
91
145
|
@decisions = {}
|
|
92
146
|
end
|
|
@@ -95,6 +149,8 @@ module Portage
|
|
|
95
149
|
POLICY_LINK_TYPES = /policy|policies|terms|contact|privacy|legal|imprint/i
|
|
96
150
|
|
|
97
151
|
def call
|
|
152
|
+
return handoff_only_report if handoff_only?
|
|
153
|
+
|
|
98
154
|
session = discover(@uri)
|
|
99
155
|
return native_flow(session) if session
|
|
100
156
|
|
|
@@ -115,6 +171,90 @@ module Portage
|
|
|
115
171
|
|
|
116
172
|
private
|
|
117
173
|
|
|
174
|
+
# --- Step 0: hand-off-only hosts (Tier C) ---
|
|
175
|
+
|
|
176
|
+
# Checked before anything else in #call so a hand-off-only host never
|
|
177
|
+
# sees a single request from this process — no UCP probe, no
|
|
178
|
+
# homepage fetch, no cart (docs/plans/buy-skill-and-local-browser.md
|
|
179
|
+
# Phase 5, decision 5). Amazon by default; ~/.portage/config.json's
|
|
180
|
+
# "handoff_only_hosts" is the user's own list (see HandoffOnly).
|
|
181
|
+
def handoff_only?
|
|
182
|
+
@handoff_only ||= HandoffOnly.new
|
|
183
|
+
return true if @handoff_only.host?(@uri.host)
|
|
184
|
+
return true if OfferSources.retail_handoff_host?(@uri.host)
|
|
185
|
+
|
|
186
|
+
etsy_buyer_host?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Phase 7 (docs/plans/buy-skill-and-local-browser.md):
|
|
190
|
+
# portage-ucp-etsy is a *seller*-side adapter — a shop owner with
|
|
191
|
+
# their own ETSY_* credentials set still reaches #adapter_flow
|
|
192
|
+
# unchanged below, the same "only your own store" rule every other
|
|
193
|
+
# adapter already gets (see the class comment at the top of this
|
|
194
|
+
# file). An ordinary buyer with no Etsy credentials of their own
|
|
195
|
+
# gets routed to hand-off here instead of a homepage fetch +
|
|
196
|
+
# platform detection that would just be scraping a stranger's
|
|
197
|
+
# listing — the buyer-side offer OfferSources::EtsyListings hands
|
|
198
|
+
# `find` has nothing this process can check out anyway.
|
|
199
|
+
def etsy_buyer_host?
|
|
200
|
+
HandoffOnly.matches_any?(@uri.host, %w[etsy.com]) && !etsy_adapter_configured?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def etsy_adapter_configured?
|
|
204
|
+
platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
|
|
205
|
+
return false unless platform
|
|
206
|
+
|
|
207
|
+
Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# No checkout was ever built — there's nothing to browse or complete,
|
|
211
|
+
# just a link and why. `checkout_url` is built, never fetched: the
|
|
212
|
+
# product page/cart-add URL when a product id is known (Amazon's own
|
|
213
|
+
# URL shape only — that's the only pattern this phase knows), or the
|
|
214
|
+
# retailer's own search URL for the query, or (a user-added host with
|
|
215
|
+
# no known pattern) the origin's homepage.
|
|
216
|
+
def handoff_only_report
|
|
217
|
+
url = handoff_only_checkout_url
|
|
218
|
+
handoff = @dry_run ? nil : dispatch_handoff_only(url)
|
|
219
|
+
build_report(source: "handoff_only", outcome: "handoff_only", browse: false, checkout: false,
|
|
220
|
+
checkout_url: url, legal_notice: HandoffOnly::LEGAL_NOTICE, handoff: handoff,
|
|
221
|
+
message: "#{@uri.host} restricts automated purchasing agents — Portage opens the page " \
|
|
222
|
+
"and you buy.")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def dispatch_handoff_only(url)
|
|
226
|
+
payload = handoff_notify_payload({}, url, reason: "handoff_only", source: "handoff_only",
|
|
227
|
+
message: "Hand-off-only retailer — no automated checkout " \
|
|
228
|
+
"was attempted.", warnings: [], over_cap: false)
|
|
229
|
+
finalize_handoff(url, dispatch_to_target(url, payload), notified: false, notify_error: nil)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def handoff_only_checkout_url
|
|
233
|
+
return amazon_checkout_url if HandoffOnly.amazon?(@uri.host)
|
|
234
|
+
return @uri.to_s if OfferSources.retail_handoff_host?(@uri.host) || etsy_buyer_host?
|
|
235
|
+
|
|
236
|
+
origin_homepage
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def amazon_checkout_url
|
|
240
|
+
return amazon_cart_add_url if @product_id
|
|
241
|
+
return amazon_search_url unless @query.to_s.strip.empty?
|
|
242
|
+
|
|
243
|
+
origin_homepage
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def amazon_search_url
|
|
247
|
+
"#{origin}/s?k=#{URI.encode_www_form_component(@query.to_s)}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def amazon_cart_add_url
|
|
251
|
+
"#{origin}/gp/aws/cart/add.html?ASIN.1=#{URI.encode_www_form_component(@product_id.to_s)}&Quantity.1=#{@qty}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def origin_homepage = "#{origin}/"
|
|
255
|
+
|
|
256
|
+
def origin = "#{@uri.scheme}://#{@uri.host}"
|
|
257
|
+
|
|
118
258
|
# --- Step 1: native UCP manifest ---
|
|
119
259
|
|
|
120
260
|
def discover(url)
|
|
@@ -242,22 +382,267 @@ module Portage
|
|
|
242
382
|
# That hand-off feeds Phases 1-3 completely unchanged: reason
|
|
243
383
|
# `express_stop` reserves a pending record, notifies, and later
|
|
244
384
|
# reconciles exactly like any other hand-off.
|
|
385
|
+
#
|
|
386
|
+
# `preset_name` is resolved once here (docs/plans/
|
|
387
|
+
# webmcp-universal-outbound.md Phase 1, decision 1) and handed to
|
|
388
|
+
# `connect` explicitly (a nil/Symbol either way skips its own
|
|
389
|
+
# detection) so the same answer also tells this method whether the
|
|
390
|
+
# page's checkout capability came from a real `create_checkout` tool
|
|
391
|
+
# or only from a preset's hand-off-only one — see
|
|
392
|
+
# #webmcp_handoff_checkout_flow.
|
|
393
|
+
#
|
|
394
|
+
# When a plain connect (no preset, no tool_names:) doesn't advertise
|
|
395
|
+
# cart+checkout, and no preset matched, #webmcp_matched_session (Phase
|
|
396
|
+
# 2) is the fallback: a confirmed mapping from a prior run, or a fresh
|
|
397
|
+
# one proposed by `Matcher` and confirmed here. A page that already
|
|
398
|
+
# speaks either a preset's names or the plain UCP action names never
|
|
399
|
+
# reaches it at all — Transport's own tool_names:/bare-action
|
|
400
|
+
# resolution already covers both, with nothing to confirm.
|
|
245
401
|
def webmcp_flow
|
|
246
402
|
return nil unless @webmcp_bridge
|
|
247
403
|
return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
|
|
248
404
|
|
|
249
|
-
|
|
250
|
-
|
|
405
|
+
preset_name = webmcp_preset_name
|
|
406
|
+
session, unresolved = webmcp_session(preset_name)
|
|
407
|
+
return unresolved if unresolved
|
|
408
|
+
return nil unless session
|
|
251
409
|
|
|
252
410
|
return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
|
|
253
411
|
|
|
254
|
-
|
|
412
|
+
run_webmcp_checkout(session, preset_name)
|
|
255
413
|
rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
|
|
256
414
|
Portage::Ucp::Client::ServerError => e
|
|
415
|
+
# A Portage::Cli::BrowserProfile::DomainNotAllowedError (Phase 6's
|
|
416
|
+
# domain allowlist, tripped inside Bridge#evaluate) always surfaces
|
|
417
|
+
# here as a BridgeError — Bridges::ScriptEvaluator#evaluate wraps
|
|
418
|
+
# whatever its injected evaluate: callable raises, StandardError
|
|
419
|
+
# included, so there's no separate rescue clause for it.
|
|
257
420
|
build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
|
|
258
421
|
message: "WebMCP checkout failed: #{e.message}")
|
|
259
422
|
end
|
|
260
423
|
|
|
424
|
+
# Split out of #webmcp_flow just to keep its own branching (bridge
|
|
425
|
+
# given?, gem installed?, cart/checkout capable?, token mode?) from
|
|
426
|
+
# also carrying the Phase 2 fallback fork (Metrics/CyclomaticComplexity)
|
|
427
|
+
# — same reasoning as #run_webmcp_checkout's own split.
|
|
428
|
+
#
|
|
429
|
+
# @return [Array(Session, nil)] a cart/checkout-capable session.
|
|
430
|
+
# @return [Array(nil, nil)] no cart/checkout capability anywhere —
|
|
431
|
+
# #webmcp_flow falls through to adapter detection, same as before
|
|
432
|
+
# Phase 2 existed.
|
|
433
|
+
# @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report.
|
|
434
|
+
def webmcp_session(preset_name)
|
|
435
|
+
session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: preset_name)
|
|
436
|
+
return [session, nil] if webmcp_cart_and_checkout?(session)
|
|
437
|
+
return [nil, nil] if preset_name
|
|
438
|
+
|
|
439
|
+
matched, unresolved = webmcp_matched_session
|
|
440
|
+
return [nil, unresolved] if unresolved
|
|
441
|
+
return [nil, nil] unless webmcp_cart_and_checkout?(matched)
|
|
442
|
+
|
|
443
|
+
[matched, nil]
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def webmcp_cart_and_checkout?(session)
|
|
447
|
+
session&.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Phase 2 fallback (docs/plans/webmcp-universal-outbound.md): a
|
|
451
|
+
# previously confirmed mapping for this exact tool fingerprint is
|
|
452
|
+
# reused with no prompt (decision 3); otherwise `Matcher.propose`
|
|
453
|
+
# builds one and #webmcp_mapping_confirm decides whether it can be
|
|
454
|
+
# used — reads unconditionally, mutating actions only once approved.
|
|
455
|
+
#
|
|
456
|
+
# @return [Array(Session, nil)] a session built with the resolved
|
|
457
|
+
# tool_names:, when there was anything to resolve.
|
|
458
|
+
# @return [Array(nil, nil)] nothing usable — the page has no tools a
|
|
459
|
+
# candidate scored high enough against; #webmcp_flow's own
|
|
460
|
+
# post-connect capability check reports it same as any other
|
|
461
|
+
# cart/checkout-incapable page.
|
|
462
|
+
# @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report, when
|
|
463
|
+
# a mutating action was proposed but couldn't be confirmed.
|
|
464
|
+
def webmcp_matched_session
|
|
465
|
+
tools = @webmcp_bridge.list_tools
|
|
466
|
+
tool_names = webmcp_mappings.lookup(tools)
|
|
467
|
+
|
|
468
|
+
if tool_names.nil?
|
|
469
|
+
proposal = Portage::Ucp::WebMcp::Matcher.propose(tools)
|
|
470
|
+
return [nil, nil] if proposal.empty?
|
|
471
|
+
|
|
472
|
+
tool_names = webmcp_mapping_confirm.call(proposal, tools)
|
|
473
|
+
return [nil, webmcp_mapping_unconfirmed_report(proposal)] if tool_names.nil?
|
|
474
|
+
|
|
475
|
+
webmcp_mappings.confirm!(tools, tool_names: tool_names, origin: @uri.host)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
[Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: nil, tool_names: tool_names), nil]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def webmcp_mappings
|
|
482
|
+
@webmcp_mappings ||= Portage::Cli::WebmcpMappings.load
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def webmcp_mapping_confirm
|
|
486
|
+
@webmcp_mapping_confirm ||= Portage::Cli::WebmcpMappingConfirm.new(interactive: !@json && $stdin.tty?)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def webmcp_mapping_unconfirmed_report(proposal)
|
|
490
|
+
build_report(
|
|
491
|
+
source: "webmcp", outcome: "webmcp_mapping_unconfirmed", browse: false, checkout: false,
|
|
492
|
+
message: "This page's WebMCP tools don't match a known preset. Here's the proposed mapping — " \
|
|
493
|
+
"confirm it and retry, passing it back as tool_names:, or run this interactively (a real " \
|
|
494
|
+
"TTY, no --json) to confirm it now.",
|
|
495
|
+
tool_names_proposal: webmcp_serialize_proposal(proposal)
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def webmcp_serialize_proposal(proposal)
|
|
500
|
+
proposal.transform_values do |match|
|
|
501
|
+
{ tool_name: match.tool, confidence: match.confidence, reason: match.reason }
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def webmcp_preset_name
|
|
506
|
+
Portage::Ucp::WebMcp::Presets.detect(@webmcp_bridge.list_tools)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Split out of #webmcp_flow just to keep that method's own branching
|
|
510
|
+
# (bridge given?, gem installed?, cart/checkout capable?, token mode?)
|
|
511
|
+
# from also carrying this preset-shaped fork (Metrics/CyclomaticComplexity).
|
|
512
|
+
def run_webmcp_checkout(session, preset_name)
|
|
513
|
+
preset = preset_name && Portage::Ucp::WebMcp::Presets.fetch(preset_name)
|
|
514
|
+
return webmcp_handoff_checkout_flow(session, preset) if preset&.handoff_checkout
|
|
515
|
+
|
|
516
|
+
full_buy(session, source: "webmcp", force_handoff: true)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# A preset whose fingerprint has no `create_checkout` tool at all —
|
|
520
|
+
# Shopify's is the only one so far (see Presets::SHOPIFY) — can't run
|
|
521
|
+
# #full_buy's `session.create_checkout`; there's nothing on the page to
|
|
522
|
+
# call it against. Builds a cart, reads it back through the (already
|
|
523
|
+
# cart/checkout-capable, per the gate in #webmcp_flow) session so
|
|
524
|
+
# #reconcile_checkout has a `get_cart`-shaped document to check against
|
|
525
|
+
# (there's no checkout document either), then calls the preset's
|
|
526
|
+
# `handoff_checkout` tool directly on the bridge — after the cart
|
|
527
|
+
# read-back, not before, so any post-mutation "page not ready" gap
|
|
528
|
+
# (see Transport's own retry) has already been waited out by then.
|
|
529
|
+
#
|
|
530
|
+
# The checkout URL comes from whatever the hand-off tool itself
|
|
531
|
+
# returns, or — Shopify's `proceed_to_checkout` is a navigation, not
|
|
532
|
+
# data, and returns nothing url-shaped (README "Shopify storefronts")
|
|
533
|
+
# — from the bridge's own `#location` once the tab has navigated
|
|
534
|
+
# there, when the bridge offers one (Bridges::ScriptEvaluator does).
|
|
535
|
+
#
|
|
536
|
+
# Once the tab has navigated to that checkout page, this is also
|
|
537
|
+
# where Phase 3's autofill runs (#attempt_webmcp_autofill) — the bridge's
|
|
538
|
+
# browser is sitting on the store's own checkout, cart already built,
|
|
539
|
+
# which is exactly the moment (and the only flow) autofill needs: a
|
|
540
|
+
# real checkout DOM, not a UCP checkout wire object #full_buy never
|
|
541
|
+
# navigates a browser to at all.
|
|
542
|
+
def webmcp_handoff_checkout_flow(session, preset)
|
|
543
|
+
products = safe_search(session)
|
|
544
|
+
product = select_product(products)
|
|
545
|
+
unless product
|
|
546
|
+
return build_report(source: "webmcp", outcome: "no_match", browse: true, checkout: true,
|
|
547
|
+
products: products, message: no_match_message)
|
|
548
|
+
end
|
|
549
|
+
return webmcp_handoff_dry_run_report(products, product, preset) if @dry_run
|
|
550
|
+
|
|
551
|
+
created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
|
|
552
|
+
context: buyer_context, meta: agent_meta)
|
|
553
|
+
cart = session.get_cart(cart_id: created["id"], meta: agent_meta)
|
|
554
|
+
warnings = reconcile_checkout(product, cart)
|
|
555
|
+
|
|
556
|
+
result = @webmcp_bridge.execute_tool(preset.handoff_checkout, {})
|
|
557
|
+
autofill = attempt_webmcp_autofill(preset)
|
|
558
|
+
webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), warnings,
|
|
559
|
+
autofill: autofill)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Unlike #full_buy's dry run (which still creates a UCP checkout, since
|
|
563
|
+
# that's a document the store drops on its own), a dry run here stops
|
|
564
|
+
# before `create_cart`: every step after it changes something outside
|
|
565
|
+
# this process — a real cart on the store, the bridge's own tab
|
|
566
|
+
# navigated to checkout, and (with autofill on) typing into that page.
|
|
567
|
+
# None of that is safe to do on a preview run, so this only reports
|
|
568
|
+
# what the run would have done. No checkout_id either, so History
|
|
569
|
+
# records it as a search, not a purchase.
|
|
570
|
+
def webmcp_handoff_dry_run_report(products, product, preset)
|
|
571
|
+
build_report(
|
|
572
|
+
source: "webmcp", outcome: "dry_run", browse: true, checkout: true, products: products, handoff: nil,
|
|
573
|
+
decisions: @decisions.dup,
|
|
574
|
+
would: { line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
|
|
575
|
+
handoff_checkout: preset.handoff_checkout, autofill: webmcp_autofill_approved? },
|
|
576
|
+
message: "Dry run — would add #{product['title'] || line_item_id_of(product)} to the store's cart and " \
|
|
577
|
+
"hand off through #{preset.handoff_checkout}. Nothing was added, and no checkout was opened."
|
|
578
|
+
)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Best-effort: the hand-off tool's own result shape isn't documented
|
|
582
|
+
# anywhere in this repo (see Presets::SHOPIFY's comment on
|
|
583
|
+
# `update_cart_lines`/`proceed_to_checkout`), so this only recognizes
|
|
584
|
+
# the shapes any WebMCP tool result already comes back in — a bare URL
|
|
585
|
+
# string, or a Hash carrying one under `url`/`continue_url` — before
|
|
586
|
+
# falling back to the bridge's own location.
|
|
587
|
+
def url_from_handoff(result)
|
|
588
|
+
from_result = case result
|
|
589
|
+
when String then result if result.start_with?("http")
|
|
590
|
+
when Hash then result["url"] || result["continue_url"]
|
|
591
|
+
end
|
|
592
|
+
from_result || (@webmcp_bridge.location if @webmcp_bridge.respond_to?(:location))
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3. Three gates, in
|
|
596
|
+
# order, any of which means nothing gets typed:
|
|
597
|
+
# 1. WebmcpAutofillMode off (the default) — no attempt at all, same
|
|
598
|
+
# shape the report had before Phase 3 existed (no `autofill:` key).
|
|
599
|
+
# 2. Nothing to fill — no PORTAGE_SHIP_*/PORTAGE_SHIP_EMAIL
|
|
600
|
+
# configured.
|
|
601
|
+
# 3. The shopper doesn't approve the exact fields/values in
|
|
602
|
+
# #webmcp_autofill_confirm's prompt (decision 2) — including
|
|
603
|
+
# "can't even ask", under --json or with no TTY.
|
|
604
|
+
# Past those, Portage::Ucp::WebMcp::Autofill (portage-ucp-webmcp) does
|
|
605
|
+
# the actual work against the checkout page the bridge's browser has
|
|
606
|
+
# already navigated to; its own gates (headless?, a CAPTCHA/challenge
|
|
607
|
+
# on the page) apply independently of these three.
|
|
608
|
+
#
|
|
609
|
+
# @return [Hash, nil] nil when gate 1 or 2 stopped this before ever
|
|
610
|
+
# reaching the shopper — the report gets no `autofill:` key at all,
|
|
611
|
+
# same as before Phase 3. Otherwise a Hash with a stable `outcome:`
|
|
612
|
+
# (`autofill_declined`, `autofill_needs_headed_browser`,
|
|
613
|
+
# `autofill_blocked`, `autofill_unsupported`, `autofill_filled`) plus
|
|
614
|
+
# whatever `filled:`/`unmatched:`/`rate:` Autofill reported.
|
|
615
|
+
def attempt_webmcp_autofill(preset)
|
|
616
|
+
return nil unless webmcp_autofill_approved?
|
|
617
|
+
|
|
618
|
+
fields = Portage::Cli::WebmcpAutofillFields.build
|
|
619
|
+
return nil if fields.empty?
|
|
620
|
+
return { outcome: "autofill_declined" } unless webmcp_autofill_confirm.call(fields)
|
|
621
|
+
|
|
622
|
+
result = webmcp_autofill.call(bridge: @webmcp_bridge, fields: fields, selectors: preset.checkout_selectors)
|
|
623
|
+
{ outcome: "autofill_#{result.outcome}", filled: result.filled, unmatched: result.unmatched,
|
|
624
|
+
rate: result.rate }
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def webmcp_autofill_approved?
|
|
628
|
+
Portage::Cli::WebmcpAutofillMode.approved?(override: @autofill)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def webmcp_autofill_confirm
|
|
632
|
+
@webmcp_autofill_confirm ||= Portage::Cli::WebmcpAutofillConfirm.new(interactive: !@json && $stdin.tty?)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Injectable so a spec can stand in for portage-ucp-webmcp's real
|
|
636
|
+
# Autofill without a real browser (same reasoning as
|
|
637
|
+
# webmcp_mappings/webmcp_mapping_confirm above) — nil by default
|
|
638
|
+
# rather than a constructor kwarg since it's only ever reached once
|
|
639
|
+
# `webmcp_bridge:` and autofill are both already in play, deep enough
|
|
640
|
+
# into #call that a kwarg here would rarely be worth threading through
|
|
641
|
+
# every other spec's Buy.new.
|
|
642
|
+
def webmcp_autofill
|
|
643
|
+
@webmcp_autofill ||= Portage::Ucp::WebMcp::Autofill
|
|
644
|
+
end
|
|
645
|
+
|
|
261
646
|
def webmcp_checkout_mode
|
|
262
647
|
Portage::Cli::WebmcpCheckoutMode.resolve
|
|
263
648
|
end
|
|
@@ -513,11 +898,26 @@ module Portage
|
|
|
513
898
|
# reached here, `buy <url> --max-price` checked out whatever the store
|
|
514
899
|
# ranked first (confirmed live 2026-09-24: a $679.95 board on
|
|
515
900
|
# burton.com under --max-price 600).
|
|
901
|
+
# A --product-id match also checks each product's own variants, not
|
|
902
|
+
# just its top-level id: OfferSources::ShopifyCatalog hands Find an
|
|
903
|
+
# offer whose product_id is the merchant's own variant gid (the
|
|
904
|
+
# catalog's own product id is a global one the merchant doesn't
|
|
905
|
+
# recognise — see that class), so the product carrying it is found by
|
|
906
|
+
# its variant, not its id.
|
|
516
907
|
def select_product(products)
|
|
517
908
|
products = products.select { |product| within_max_price?(product) }
|
|
518
909
|
return products.find { |product| available?(product) } || products.first unless @product_id
|
|
519
910
|
|
|
520
|
-
products.find { |product| product_id_of(product) == @product_id }
|
|
911
|
+
products.find { |product| product_id_of(product) == @product_id || variant_matching(product, @product_id) }
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# nil `id` matches nothing — a product's variants are never searched
|
|
915
|
+
# for a nil id, the same way #select_product's own `unless @product_id`
|
|
916
|
+
# branch never gets here without one.
|
|
917
|
+
def variant_matching(product, id)
|
|
918
|
+
return nil unless id
|
|
919
|
+
|
|
920
|
+
Array(product["variants"]).find { |variant| variant["id"] == id }
|
|
521
921
|
end
|
|
522
922
|
|
|
523
923
|
# Priced the way #reconcile_checkout expects the store to charge: the
|
|
@@ -561,6 +961,9 @@ module Portage
|
|
|
561
961
|
# first variant, for the same reason #select_product skips sold-out
|
|
562
962
|
# products (a live Brooklinen sheet set lists its sold-out size first).
|
|
563
963
|
def line_item_id_of(product)
|
|
964
|
+
matched = variant_matching(product, @product_id)
|
|
965
|
+
return matched["id"] if matched
|
|
966
|
+
|
|
564
967
|
variants = Array(product["variants"])
|
|
565
968
|
(variants.find { |variant| variant_available?(variant) } || variants.first)&.dig("id") ||
|
|
566
969
|
product_id_of(product)
|
|
@@ -773,11 +1176,14 @@ module Portage
|
|
|
773
1176
|
# (and fires the auto-open/webhook side effects) rather than leaving
|
|
774
1177
|
# them at a dead end. `outcome` doubles as the webhook's `reason`, so a
|
|
775
1178
|
# relay and an agent loop branch on the same value.
|
|
776
|
-
|
|
1179
|
+
# @param extra [Hash] merged straight onto the report — Phase 3's
|
|
1180
|
+
# `autofill:` (see #webmcp_handoff_report) is the only caller today.
|
|
1181
|
+
def handoff_report(source, products, checkout, warnings, outcome:, message:, extra: {})
|
|
777
1182
|
handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
|
|
778
1183
|
checkout_report(source, products, checkout, outcome: outcome, message: message,
|
|
779
1184
|
warnings: warnings + Array(@pending_handoff_warning),
|
|
780
|
-
checkout_url: checkout_url_of(checkout), handoff: handoff
|
|
1185
|
+
checkout_url: checkout_url_of(checkout), handoff: handoff,
|
|
1186
|
+
**extra)
|
|
781
1187
|
end
|
|
782
1188
|
|
|
783
1189
|
# docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
|
|
@@ -786,10 +1192,18 @@ module Portage
|
|
|
786
1192
|
# auto-open/notify exactly like any other hand-off (Phases 1-3 apply
|
|
787
1193
|
# unchanged), even though it got here because a mode setting chose to
|
|
788
1194
|
# stop, not because anything was denied or escalated.
|
|
789
|
-
|
|
1195
|
+
#
|
|
1196
|
+
# @param autofill [Hash, nil] docs/plans/webmcp-universal-outbound.md
|
|
1197
|
+
# Phase 3 — #webmcp_handoff_checkout_flow's own autofill attempt
|
|
1198
|
+
# (#attempt_webmcp_autofill), attached as the report's `autofill:`
|
|
1199
|
+
# key when there was one to attach. nil (the default, and always for
|
|
1200
|
+
# #finish_checkout's own force_handoff call) adds no key at all —
|
|
1201
|
+
# the report is byte-for-byte what it was before Phase 3 existed.
|
|
1202
|
+
def webmcp_handoff_report(source, products, checkout, warnings, autofill: nil)
|
|
790
1203
|
message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
|
|
791
1204
|
"on the page."
|
|
792
|
-
|
|
1205
|
+
extra = autofill ? { autofill: autofill } : {}
|
|
1206
|
+
handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message, extra: extra)
|
|
793
1207
|
end
|
|
794
1208
|
|
|
795
1209
|
# Never fires on --dry-run (a dry run creates a real checkout but never
|
|
@@ -816,20 +1230,98 @@ module Portage
|
|
|
816
1230
|
over_cap = precheck_mode? && over_spend_cap?(checkout)
|
|
817
1231
|
record_pending_handoff(checkout, reason: reason)
|
|
818
1232
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1233
|
+
payload = handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
|
|
1234
|
+
warnings: warnings, over_cap: over_cap)
|
|
1235
|
+
dispatch = over_cap ? { target: handoff_target.label, opened: false } : dispatch_to_target(url, payload)
|
|
1236
|
+
error = notifier.call(payload)
|
|
1237
|
+
finalize_handoff(url, dispatch, notified: notifier.enabled? && error.nil?, notify_error: error,
|
|
1238
|
+
over_cap: over_cap)
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# Same payload for both `--notify-webhook` and `agent:<name>`
|
|
1242
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 5, decision 3): the
|
|
1243
|
+
# checkout URL plus the approved cart summary — items, qty (each
|
|
1244
|
+
# item's own `quantity`), total (`totals`) and store. Reused as-is
|
|
1245
|
+
# for #handoff_only_report with `checkout: {}` — no cart was ever
|
|
1246
|
+
# built there, so `checkout_id`/`totals`/`items` come back nil/empty,
|
|
1247
|
+
# never fabricated.
|
|
826
1248
|
def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
|
|
827
1249
|
payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
|
|
828
1250
|
query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
|
|
829
|
-
totals: checkout["totals"], warnings: warnings }
|
|
1251
|
+
totals: checkout["totals"], items: checkout_items(checkout), warnings: warnings }
|
|
830
1252
|
over_cap ? payload.merge(over_cap: true) : payload
|
|
831
1253
|
end
|
|
832
1254
|
|
|
1255
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 5 — routes a
|
|
1256
|
+
# hand-off to whichever target `--handoff-target`/PORTAGE_HANDOFF_TARGET/
|
|
1257
|
+
# config.json resolved to. `payload` is only used by the `agent`
|
|
1258
|
+
# branch; building it unconditionally is cheap and keeps this method
|
|
1259
|
+
# a plain dispatch with no branching on whether it's needed.
|
|
1260
|
+
def dispatch_to_target(url, payload)
|
|
1261
|
+
case handoff_target.kind
|
|
1262
|
+
when "print" then { target: "print", opened: false }
|
|
1263
|
+
when "profile" then dispatch_to_profile(url)
|
|
1264
|
+
when "agent" then dispatch_to_agent(payload)
|
|
1265
|
+
else { target: "default", opened: CheckoutHandoff.new(auto_open: @auto_open).call(url) }
|
|
1266
|
+
end
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: when this run
|
|
1270
|
+
# already has a Portage browser profile bridge attached (`Cli.
|
|
1271
|
+
# profile_webmcp_bridge`, only when `--handoff-target profile`
|
|
1272
|
+
# resolved and the profile was running), navigate that same browser
|
|
1273
|
+
# to the checkout URL — the cart was built there too, when the
|
|
1274
|
+
# WebMCP flow ran, or is about to be paid there for the first time,
|
|
1275
|
+
# for a native-UCP store. `#navigate` permits the URL's own host
|
|
1276
|
+
# first (the "plus its checkout host" half of the allowlist), so
|
|
1277
|
+
# this is never blocked by the allowlist itself; it's the one
|
|
1278
|
+
# deliberate Portage-initiated navigation the allowlist always lets
|
|
1279
|
+
# through. Any other bridge failure (the profile process died mid
|
|
1280
|
+
# run, a stale WebSocket) is reported rather than raised, same
|
|
1281
|
+
# posture as every other hand-off dispatch.
|
|
1282
|
+
def dispatch_to_profile(url)
|
|
1283
|
+
unless @webmcp_bridge.respond_to?(:navigate)
|
|
1284
|
+
return { target: "profile", opened: false,
|
|
1285
|
+
message: "No Portage browser profile is attached — run `portage browser profile open` " \
|
|
1286
|
+
"first, then retry with --handoff-target profile." }
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
@webmcp_bridge.navigate(url)
|
|
1290
|
+
{ target: "profile", opened: true }
|
|
1291
|
+
rescue StandardError => e
|
|
1292
|
+
{ target: "profile", opened: false, message: "Couldn't open the checkout in the Portage browser " \
|
|
1293
|
+
"profile (#{e.message}) — showing the link instead." }
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def dispatch_to_agent(payload)
|
|
1297
|
+
agent = handoff_agents.lookup(handoff_target.agent_name)
|
|
1298
|
+
unless agent
|
|
1299
|
+
return { target: handoff_target.label, opened: false, agent_delivered: false,
|
|
1300
|
+
agent_error: "#{handoff_target.agent_name.inspect} isn't approved in " \
|
|
1301
|
+
"~/.portage/config.json's handoff_agents — showing the link instead." }
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
error = agent.call(payload)
|
|
1305
|
+
{ target: handoff_target.label, opened: false, agent_delivered: error.nil?, agent_error: error }
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def finalize_handoff(url, dispatch, notified:, notify_error:, over_cap: false)
|
|
1309
|
+
result = { url: url, opened: dispatch[:opened], notified: notified, notify_error: notify_error,
|
|
1310
|
+
handoff_target: dispatch[:target] }
|
|
1311
|
+
result[:agent_delivered] = dispatch[:agent_delivered] if dispatch.key?(:agent_delivered)
|
|
1312
|
+
result[:agent_error] = dispatch[:agent_error] if dispatch.key?(:agent_error)
|
|
1313
|
+
result[:target_message] = dispatch[:message] if dispatch[:message]
|
|
1314
|
+
over_cap ? result.merge(over_cap: true) : result
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
def handoff_target
|
|
1318
|
+
@handoff_target ||= HandoffTarget.new
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
def handoff_agents
|
|
1322
|
+
@handoff_agents ||= HandoffAgents.new
|
|
1323
|
+
end
|
|
1324
|
+
|
|
833
1325
|
# Never raises: a record that can't be written is a warning on the
|
|
834
1326
|
# report (surfaced via @pending_handoff_warning, see #handoff_report),
|
|
835
1327
|
# not a reason to fail the hand-off itself — same posture as
|
|
@@ -918,12 +1410,12 @@ module Portage
|
|
|
918
1410
|
# is what the checkout actually holds; `products:` is only what the
|
|
919
1411
|
# search returned, most of which was never bought.
|
|
920
1412
|
def checkout_report(source, products, checkout, outcome:, message:, checkout_url: nil, handoff: nil,
|
|
921
|
-
warnings: [])
|
|
1413
|
+
warnings: [], **extra)
|
|
922
1414
|
build_report(source: source, outcome: outcome, browse: true, checkout: true, products: products,
|
|
923
1415
|
message: message, checkout_url: checkout_url, checkout_id: checkout["id"],
|
|
924
1416
|
checkout_status: checkout["status"], currency: checkout["currency"],
|
|
925
1417
|
totals: checkout["totals"], items: checkout_items(checkout), handoff: handoff,
|
|
926
|
-
warnings: warnings, decisions: @decisions.dup)
|
|
1418
|
+
warnings: warnings, decisions: @decisions.dup, **extra)
|
|
927
1419
|
end
|
|
928
1420
|
|
|
929
1421
|
def checkout_items(checkout)
|
|
@@ -959,7 +1451,7 @@ module Portage
|
|
|
959
1451
|
# loopback path ignores it harmlessly, so it's cheapest to always pass
|
|
960
1452
|
# it rather than branch on which transport `session` happens to be.
|
|
961
1453
|
def agent_meta
|
|
962
|
-
{ agent_profile:
|
|
1454
|
+
{ agent_profile: AgentProfileUrl.resolve }
|
|
963
1455
|
end
|
|
964
1456
|
|
|
965
1457
|
# --- Homepage fetch (used by both the manifest-not-found path and the
|