portage-cli 0.11.0 → 0.13.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +88 -18
  4. data/lib/portage/cli/adapter_session.rb +34 -0
  5. data/lib/portage/cli/browser_import/importer.rb +1 -1
  6. data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
  7. data/lib/portage/cli/browser_import/sqlite.rb +19 -25
  8. data/lib/portage/cli/browser_profile/cdp.rb +0 -9
  9. data/lib/portage/cli/buy.rb +217 -39
  10. data/lib/portage/cli/compare.rb +4 -3
  11. data/lib/portage/cli/confidence_check.rb +27 -7
  12. data/lib/portage/cli/confidence_state.rb +109 -0
  13. data/lib/portage/cli/find.rb +66 -9
  14. data/lib/portage/cli/generate/agent_profile.rb +1 -1
  15. data/lib/portage/cli/handoff_agents.rb +7 -21
  16. data/lib/portage/cli/handoff_reconciler.rb +2 -22
  17. data/lib/portage/cli/homepage_fetch.rb +5 -3
  18. data/lib/portage/cli/index/builder.rb +2 -11
  19. data/lib/portage/cli/index/known_cache.rb +9 -2
  20. data/lib/portage/cli/index/sources/stores_file.rb +2 -11
  21. data/lib/portage/cli/index/sources/wikidata.rb +2 -11
  22. data/lib/portage/cli/install_doctor.rb +2 -1
  23. data/lib/portage/cli/notifier.rb +16 -12
  24. data/lib/portage/cli/offer_sources.rb +3 -14
  25. data/lib/portage/cli/payment_methods.rb +7 -30
  26. data/lib/portage/cli/reconcile_notifier.rb +2 -5
  27. data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
  28. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
  29. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
  30. data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
  31. data/lib/portage/cli/version.rb +1 -1
  32. data/lib/portage/cli.rb +25 -173
  33. metadata +5 -3
  34. data/lib/portage/cli/known_stores_url.rb +0 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4200c871324b9f35de00e58c32177ec087c9922b3adda2ae45aa1b4673478f1
4
- data.tar.gz: e719be3b212a547be84ff5fa1232b08303c96288b7acf60f0fd9c2f1982fa0ef
3
+ metadata.gz: 7c58c0040e6d1f58b5cf9dbbe64c91273c198a4cfc8119477e1fcbbf04c4c4dd
4
+ data.tar.gz: ba48da2d241487885a286eb8044e493f6727b7e7d4b96f3b9dc7d748b1644f67
5
5
  SHA512:
6
- metadata.gz: 3bb580b0b6ac745462ecc241b4e151fa16c2a8651ac32aa6d53c84aca0b910da6095182680b6a832b3431539904a697332279de9b9340fb491581821eccf6519
7
- data.tar.gz: 7a75983b229489dc8519caa7a176122b00c8f41f56922f4b373c551a9ab044dace860f7ecdd6ba93d63feddf84177391d3ae92f13a6280acd138e704590ea4f3
6
+ metadata.gz: 388d5ea84ba8ba1279862b57b07cf36db2164cec50752638ae3eb2d924fd81c56a569ea2d107994fc56f3d1989ab0d5b567fed3e0a12e9b3e7c65a291b95e198
7
+ data.tar.gz: fd18e9aa2c798935143742e961936c359ff68722a3f8bad2f20489322456df3467f042ef9c653a4f72f167fc4a765a08bd81680e01ec6c2a0b26fb0ce934b477
data/CHANGELOG.md CHANGED
@@ -6,6 +6,103 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.0] - 2026-10-05
10
+
11
+ - **Fix: `payment_methods` adapter fallback no longer silently fails.** `PaymentMethods#adapter_session` referenced `Buy::PermissiveAuthenticator`, which doesn't exist (the class is `Portage::Cli::PermissiveAuthenticator`); the resulting `NameError` was swallowed by the method's `rescue StandardError`, so enrolling against your own adapter-backed store always reported no session. It now requires and uses the right constant. Patch-level bug fix.
12
+
13
+ - Documentation only, no code change. Comments and changelog entries that cited `docs/plans/homebrew-distribution.md`, which is retired, now cite a permalink at commit d51200f. Patch-level.
14
+
15
+ - **Removed `Portage::Cli::BrowserProfile::Cdp.close_tab`.** Nothing in the CLI called it. It is a public class method, so this is **breaking** for any caller using it directly; minor-level for a pre-1.0 gem.
16
+
17
+ - Internal: the Shipping, SearchKeys and RetailerKeys setup-wizard steps now share one `SetupWizard::Steps::EnvKeysStep` base class instead of three copies of the same prompt-and-save body. No behaviour change.
18
+
19
+ - **`portage browser import` no longer needs the `sqlite3` command-line tool.** It reads the copied Chromium, Firefox and Safari history databases through the `sqlite3` gem, which `portage-cli` already depends on for the local index, instead of shelling out to the system `sqlite3` CLI. The copy-to-tmpdir step (with the `-wal` file) and the UTF-8 scrub are unchanged. `error: "reader_unavailable"` now means a SQLite error reading the copy (for example a corrupt or non-SQLite file) or, for Safari bookmarks, a missing `plutil`; `docs/api/cli-json.md` and the `buy` skill's `references/outcomes.md` say so. `BrowserImport::Sqlite.new` no longer takes a `command:` keyword (it only existed for a spec), which is **breaking** for any caller passing it; minor-level for a pre-1.0 gem.
20
+
21
+ - **Fix: `payment_methods` enroll now finds your own store when its homepage redirects** (for example apex to `www`). The adapter fallback's homepage fetch followed no redirects; it now follows up to five, like `buy`'s, and every hop stays on the `payment` proxy route (forced direct unless a proxy is named for it). Internally, `PaymentMethods` and `HandoffReconciler` now share one `Cli::AdapterSession` for that fallback, and `HomepageFetch.call` takes a `route:` keyword (default `:store`). Patch-level.
22
+
23
+ - Internal: `HandoffAgents::Webhook` and `Notifier` now make their JSON POST through one `Notifier.post_json` class method instead of two copies. Timeouts, the agent webhook's https-only check and the error messages are unchanged, and the agent webhook still never falls back to `PORTAGE_NOTIFY_WEBHOOK_URL`. No behaviour change.
24
+
25
+ - Internal: `BrowserImport::PlistXml` decodes entities with `CGI.unescapeHTML` (from the default `cgi/escape` library) instead of its own table. The same for the five named entities and decimal and hex character references; an uppercase `A` now decodes (it used to become NUL) and an out-of-range code point is left as written (it used to raise `RangeError`). No gemspec change.
26
+
27
+ - Internal: the private `Cli.format_amount` wrapper is gone; its four callers in `cli.rb` call `Cli::Money.format_amount` directly. No behaviour change.
28
+
29
+ ## [0.12.0] - 2026-10-01
30
+
31
+ - **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
32
+
33
+ - **`find --store URL --query Q [--max-price N]` searches one store, live.** The index-search hint, the `buy` skill and the docs already told agents to re-check an index hit this way, but `find` had no `--store`. It now skips the search backends and retailer offer sources and probes and searches only that store's catalogue (read-only: never a cart or checkout), with offers, `offer_ref`, `search_id` and the history entry shaped like any find, so `pick` and `buy --offer` work on them. A hand-off-only host is never fetched and is reported as such; a store without UCP is reported as having no catalogue; a non-http(s) URL is a usage error. The index-search hint now reads `find --store URL --query ...`.
34
+
35
+ - **Security: a priced line nobody asked for is a checkout mismatch.** `buy` requests exactly one
36
+ line, but only checked that line, so a store that added an upsell, a "shipping protection"
37
+ add-on or a second copy of the item (or, on a WebMCP cart, whatever was already in the store's
38
+ cart) still went through. Any other line now stops a real run with `checkout_mismatch` ("Store
39
+ added ... to checkout, which wasn't requested."), and a dry run flags it with
40
+ `checkout_mismatch: true`. An extra line that costs nothing (its own total, or its unit price
41
+ times quantity, is 0) is allowed, so a free gift or $0 sample doesn't block a purchase; one
42
+ whose cost can't be read counts as priced.
43
+
44
+ - **Security: the confidence check sends an allowlisted summary, and compares against the
45
+ approved quote.** The state sent to the decision backend (`PORTAGE_DECISION_BACKEND`, e.g.
46
+ `jev`, TypeSafe's hosted API) used to be a slice of the raw checkout hash, so whatever a store
47
+ nested under `line_items` or `totals` went along with it. It is now built field by field by the
48
+ new `Portage::Cli::ConfidenceState`: the request (query, store host, quantity, picked item id
49
+ and title), on a `buy --quote` run the approved quote (store, product id, title, quantity,
50
+ total, currency), and the checkout's status, currency, per-line item id, title, unit price,
51
+ quantity, totals and whether it's the requested line, the totals (shipping, tax, fees included),
52
+ applied discounts' titles and amounts and the selected shipping option's title and price, plus
53
+ `warnings`. Never the payment token, the address, the buyer's name, phone or email, ids, links,
54
+ discount codes or environment values; store strings are cut to 200 characters. The question
55
+ now asks the model to say yes only when the checkout matches the request and the approved
56
+ quote. `Buy.new` takes `quote_store:` and `quote_title:`, which `buy --quote` passes from the
57
+ saved quote. The check stays additive: it only sees a checkout the mismatch check, the quote
58
+ cap and the spend policy let through, and can hold it but never let through one they stop.
59
+
60
+ - **Security: the non-preset WebMCP hand-off runs the confidence check too.** A page whose WebMCP
61
+ tools build a checkout (no preset, so `buy` ends in `express_stop` through `finish_checkout`)
62
+ applied the quote cap and the mismatch stop but not the opt-in confidence check. With a decision
63
+ backend enabled it now runs before the hand-off, after those two. A hold reports `low_confidence`
64
+ with the store's `/cart` page as `checkout_url`, as the preset flow's does, and nothing is handed
65
+ to the checkout (a `profile` target is not navigated there). A backend error holds the same way.
66
+ No backend enabled: unchanged.
67
+
68
+ - **Security: the WebMCP hand-off flow runs the quote cap and the confidence check.** Against a
69
+ page whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy`
70
+ now checks, before that tool runs and before autofill: a `--quote` run's cap (`quote_changed`,
71
+ never handed off; this flow never checked the quote before), the mismatch check, then, when a
72
+ decision backend is enabled, the confidence check. A hold reports `low_confidence` with the
73
+ store's `/cart` page as `checkout_url`; checkout isn't opened and nothing is autofilled. A
74
+ backend error holds the same way.
75
+
76
+ - **Security: a quote with no total refuses instead of buying uncapped.** `buy --quote` capped the
77
+ checkout at the quote's total only when the quote had one. A quote saved from a dry run with no
78
+ priced total (a WebMCP preset dry run never has one) set no cap at all, so its `--yes` run
79
+ bought at whatever the store asked. It now ends in `quote_changed`, saying the quote has no
80
+ total, and nothing is bought or handed off.
81
+
82
+ - **Security: a checkout mismatch always stops the purchase.** `buy` used to stop on a checkout
83
+ that didn't match the request (the item dropped, another quantity, another unit price) only under
84
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`. Without it the mismatch was a `warnings` entry and the
85
+ purchase went ahead, so a real `--yes` run could pay for the wrong checkout and report
86
+ `purchased` (reported by ClawHub's security audit of the `portage-buy` skill). Now every
87
+ mismatch on a run that isn't `--dry-run` escalates (`decisions.escalation.reason: "mismatch"`)
88
+ and ends in `checkout_mismatch` before the payment token, policy and completion are reached. The
89
+ quote it ran under is spent, so the person dry-runs again for a new one. There is no opt-out:
90
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored, whatever its value. A
91
+ `--dry-run` keeps its `dry_run` outcome and `warnings`, and now adds `checkout_mismatch: true`
92
+ and says in `message` that a real run would stop. The check also compares currency: a checkout
93
+ in another currency than the catalog's price is a mismatch. The quoted total and currency are
94
+ still `--quote`'s `quote_changed` check, unchanged.
95
+
96
+ - **Security: the WebMCP hand-off flow stops on a mismatched cart too.** Against a WebMCP page
97
+ whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy` read
98
+ the cart back and checked it, but only put a mismatch in `warnings`: it still called the hand-off
99
+ tool, sending the browser tab to the store's checkout, and ran autofill there when it was
100
+ approved. Now any mismatch stops the run before that tool, with outcome `checkout_mismatch` and
101
+ `decisions.escalation.reason: "mismatch"`. Nothing is autofilled, and `checkout_url` is the
102
+ store's `/cart` page, so the shopper can look at the cart that was built. A matching cart hands
103
+ off as `express_stop`, as before. The dry run of this flow builds no cart, so it can't check one
104
+ and never carries `checkout_mismatch: true`.
105
+
9
106
  ## [0.11.0] - 2026-10-01
10
107
 
11
108
  - **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
@@ -708,7 +805,7 @@ pre-1.0, so APIs may still shift between minor versions.
708
805
  ## [0.7.4] - 2026-09-25
709
806
 
710
807
  - **`portage doctor` reports how it was installed, and warns when another
711
- `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
808
+ `portage` shadows it** ([plan](https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md) Phase 4).
712
809
  New findings, all offline and without shelling out to `brew`:
713
810
  - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
714
811
  lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
data/README.md CHANGED
@@ -29,6 +29,20 @@ portage find --query "burton snowboard" --max-price 400
29
29
 
30
30
  `portage buy` with no URL runs that search and then buys the offer you pick.
31
31
 
32
+ Already know the store? `portage find --store URL --query "..."` skips the search
33
+ backends and offer sources and searches only that store's catalogue, live and
34
+ read-only (catalogue search only, never a cart or checkout). Use it to re-check
35
+ an index hit or an earlier offer before quoting its price or stock. The report
36
+ has the same shape as a normal find (`offer_ref`, `search_id`, a history entry),
37
+ so `pick` and `buy --offer` work on its offers. A hand-off-only host is never
38
+ fetched and is reported as such; a store that doesn't speak UCP is reported as
39
+ having no catalogue, with exit code 1 (as for any find with no offers). `--store`
40
+ must be an http(s) URL (a bare host is read as https).
41
+
42
+ ```bash
43
+ portage find --store https://shop.example --query "cold brew" --json
44
+ ```
45
+
32
46
  Already have the item and want to know where else it's sold? `portage compare`
33
47
  resolves a product you name by URL + product id, then runs the same
34
48
  find pipeline against its title and ranks the results by how confident the
@@ -131,6 +145,7 @@ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
145
  portage buy --quote QUOTE_ID --yes [--json] ...
132
146
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
133
147
  portage find --query "..." [--max-price N] [--limit N] [--json]
148
+ portage find --store URL --query "..." [--max-price N] [--json]
134
149
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
135
150
  [--max-price N] [--json]
136
151
  portage check <url> [--json]
@@ -497,7 +512,7 @@ probes per run), throttled, with progress output. Sources:
497
512
  | --- | --- | --- |
498
513
  | `shopify_catalog` | Merchant origins and product identities, one query per top-level taxonomy node, from `catalog.shopify.com`'s open catalog | on |
499
514
  | `stores_file` | Your own `~/.portage/stores.yml` | on |
500
- | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | on, but yields nothing unless you've run `browser import` |
515
+ | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | opt-in (`--sources browser`); yields nothing unless you've run `browser import` |
501
516
  | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
502
517
  | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
503
518
  | `storefront_products` | Each indexed Shopify store's own `/products.json`: title, brand, handle, URL, first image, options and variant ids, mapped through the UCP `Product` shape with price and availability dropped | opt-in (`--sources storefront_products`, or `index add URL --crawl`) |
@@ -811,6 +826,30 @@ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
811
826
  product search: nothing is added to the store's cart, the tab isn't sent to
812
827
  checkout and nothing is autofilled. The `dry_run` report carries a `would:`
813
828
  key with the line item, the hand-off tool and whether autofill would run.
829
+ Because it builds no cart, it can't check one, so it never carries
830
+ `checkout_mismatch: true`.
831
+
832
+ A real run against that kind of page reads the cart back after adding to it
833
+ and checks it the same way `buy` checks any checkout (item, quantity, unit
834
+ price, currency, extra lines). Any mismatch stops the run there, with outcome
835
+ `checkout_mismatch` and `decisions.escalation.reason: "mismatch"`: the
836
+ hand-off tool isn't called, so the tab never goes to checkout, and nothing is
837
+ autofilled. The cart is already on the store, so `checkout_url` is the
838
+ store's `/cart` page, for the shopper to look at. Items already sitting in
839
+ the store's cart count as extra lines, so they stop the run too.
840
+
841
+ The same run then applies the other gates a checkout gets before anything
842
+ leaves the cart: a `--quote` run's cap (`quote_changed`), and, when a
843
+ decision backend is enabled, the confidence check (see "Decisions"). A hold
844
+ from the confidence check reports `low_confidence` with the `/cart` page as
845
+ `checkout_url`; again the tab isn't sent to checkout and nothing is
846
+ autofilled.
847
+
848
+ A page whose WebMCP tools build a checkout (no preset) gets the same confidence
849
+ check before its `express_stop` hand-off, after the quote cap and the mismatch
850
+ stop. A hold reports `low_confidence` with the `/cart` page as `checkout_url`
851
+ and no navigation to the checkout, so a `profile` hand-off target is never sent
852
+ there. With no decision backend enabled, nothing changes.
814
853
 
815
854
  Once the flow hands off to the store's own checkout page, the shopper can
816
855
  opt into having it pre-filled: `--autofill`, or
@@ -842,7 +881,9 @@ gem install portage-ucp-decision # only needed for the confidence gate
842
881
  ```
843
882
 
844
883
  The confidence gate is the one feature that needs the gem, because the
845
- model backends live there.
884
+ model backends live there. Use `portage-ucp-decision` 0.1.2 or newer: it
885
+ rejects a backend answer that isn't a probability between 0 and 1, where
886
+ 0.1.1 let an answer above 1 clear any threshold.
846
887
 
847
888
  Every `portage buy` report carries an `outcome`, so a script or agent loop
848
889
  can branch on data rather than on the message text. The text output leads
@@ -852,12 +893,12 @@ with the same value, as `[outcome]`.
852
893
  | --- | --- | --- |
853
894
  | `purchased` | Completed. | no |
854
895
  | `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
855
- | `dry_run` | Checkout created, `--dry-run` stopped it. | no |
896
+ | `dry_run` | Checkout created, `--dry-run` stopped it. `checkout_mismatch: true` when a real run would stop on a mismatch. | no |
856
897
  | `requires_escalation` | The store wants the shopper to finish. | yes |
857
- | `checkout_mismatch` | Checkout differs from the request (`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`). | yes |
898
+ | `checkout_mismatch` | Checkout differs from the request (item, quantity, unit price, currency, or a priced line nobody asked for); stopped before payment. | yes |
858
899
  | `no_payment_token` | No `--payment-token` and no default payment method. | yes |
859
900
  | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
860
- | `low_confidence` | The confidence gate held it. | yes |
901
+ | `low_confidence` | The confidence gate held it (before a `--yes` completion, or before a WebMCP hand-off to checkout, preset or not). | yes |
861
902
  | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
862
903
  | `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
863
904
  | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
@@ -884,9 +925,15 @@ Every verdict has a `reason`: `null` when the gate let the purchase through,
884
925
  otherwise a string naming why it stopped it.
885
926
 
886
927
  - **escalation** — `Support::Escalation`. A `requires_escalation` checkout
887
- always escalates (`reason: "requires_escalation"`). A checkout that doesn't
888
- match the request escalates (`reason: "mismatch"`) only under
889
- `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`; otherwise it's reported as `warnings`.
928
+ always escalates (`reason: "requires_escalation"`). So does a checkout that
929
+ doesn't match the request (`reason: "mismatch"`), on every run but
930
+ `--dry-run`, where the mismatch is reported in `warnings` and flagged with
931
+ `checkout_mismatch: true` instead. Nothing turns this off:
932
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored. A mismatch
933
+ is the requested line missing, a different quantity, a different unit
934
+ price or currency than the store's own catalog, or any extra line the
935
+ request didn't include. An extra line that costs nothing (a free gift, a
936
+ $0 sample) is allowed; one whose cost can't be read counts as priced.
890
937
  - **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
891
938
  before any `--yes` completion. `reason` is the guard's own
892
939
  (`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
@@ -901,16 +948,39 @@ otherwise a string naming why it stopped it.
901
948
  when the store answers that it's purchased.
902
949
  - **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
903
950
  `PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
904
- completion it asks the backend whether the checkout matches the request
905
- and is safe to complete unattended. It sends the query, merchant,
906
- quantity, line items, totals and warnings, never the payment token. The
907
- gate fails closed, so three things hold the purchase: a score below the
908
- threshold (`reason: "below_threshold"`), a backend that can't answer
909
- (`"backend_error"`), and naming a backend without `portage-ucp-decision`
910
- installed (`"not_installed"`). For the last two, `error` says what went
911
- wrong. `jev` needs `JEV_API_KEY`; `laya` needs `LAYA_BRIDGE_SCRIPT` (see
912
- `portage-ucp-decision`'s README). `portage doctor` flags whichever of
913
- these is missing for the selected backend.
951
+ completion, and before a WebMCP preset flow sends the browser to the
952
+ store's checkout (and autofills it), it asks the backend whether the
953
+ checkout matches the request, and the approved quote on a `--quote` run,
954
+ and is safe to complete unattended. It is additive only: it runs after
955
+ the mismatch check, the quote cap and the spend policy, on a checkout all
956
+ of them let through, so it can hold a purchase but never let through one
957
+ they would stop. The gate fails closed, so three things hold the
958
+ purchase: a score below the threshold (`reason: "below_threshold"`), a
959
+ backend that can't answer or answers with something that isn't a
960
+ probability (`"backend_error"`), and naming a backend without
961
+ `portage-ucp-decision` installed (`"not_installed"`). For the last two,
962
+ `error` says what went wrong. `jev` needs `JEV_API_KEY`; `laya` needs
963
+ `LAYA_BRIDGE_SCRIPT` (see `portage-ucp-decision`'s README). `portage
964
+ doctor` flags whichever of these is missing for the selected backend.
965
+
966
+ `jev` is TypeSafe's hosted API, so what it's sent is kept to a minimal,
967
+ allowlisted summary (`Portage::Cli::ConfidenceState`); nothing else on
968
+ the checkout is read:
969
+
970
+ - `request`: the search query, the store's host, the quantity, and the
971
+ picked item's id and title.
972
+ - `approved_quote` (`--quote` runs only): the quote's store, product id,
973
+ title, quantity, total and currency.
974
+ - `checkout`: status, currency; per line, item id, title, unit price,
975
+ quantity, line totals and whether it's the requested line; the totals
976
+ (subtotal, shipping, tax, fees, total — whatever the store lists);
977
+ applied discounts' titles and amounts; the selected shipping option's
978
+ title and price.
979
+ - `warnings`.
980
+
981
+ Never sent: the payment token, the shipping address, the buyer's name,
982
+ phone or email, checkout ids, links and URLs, discount codes, or anything
983
+ from your environment. Store-supplied strings are cut to 200 characters.
914
984
  - **policy** also denies a checkout with no `total` line as
915
985
  `total_unknown` whenever a spend cap is set, rather than skipping the cap.
916
986
 
@@ -0,0 +1,34 @@
1
+ require "portage/ucp"
2
+ require "portage/ucp/client"
3
+ require_relative "homepage_fetch"
4
+ require_relative "permissive_authenticator"
5
+
6
+ module Portage
7
+ module Cli
8
+ # The own-store loopback fallback HandoffReconciler and PaymentMethods
9
+ # share, mirroring Buy's own (#adapter_flow): sniff the platform from
10
+ # the store's homepage and, when that platform's env vars are set,
11
+ # build its adapter and wrap it in a loopback client. A later process
12
+ # must not assume an earlier one's session or credentials still exist,
13
+ # only that the env vars are still set.
14
+ module AdapterSession
15
+ # @param route [Symbol] the Support::Connection route the homepage
16
+ # fetch goes over (PaymentMethods passes :payment).
17
+ # @return [Portage::Ucp::Client::Session, nil] nil when the platform
18
+ # isn't recognised, its env is incomplete, or anything fails.
19
+ def self.call(uri, route: :store)
20
+ body, headers = HomepageFetch.call(uri, route: route)
21
+ platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
22
+ return nil unless platform
23
+
24
+ env = Portage::Ucp::Resolver.env_for(platform)
25
+ return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
26
+
27
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
28
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -88,7 +88,7 @@ module Portage
88
88
  # @return [Hash] the proposal: counts, `kept` (domains to save) and
89
89
  # `products` (only with include_product_pages) — or `error:` /
90
90
  # `message:` when the browser's files couldn't be read (Safari
91
- # without Full Disk Access, no sqlite3, no profile found).
91
+ # without Full Disk Access, a SQLite error, no plutil, no profile found).
92
92
  def plan(options)
93
93
  profiles = Profiles.locate(options.browser, root: options.root)
94
94
  return no_profile(options) if profiles.empty?
@@ -1,4 +1,5 @@
1
1
  require "strscan"
2
+ require "cgi/escape"
2
3
 
3
4
  module Portage
4
5
  module Cli
@@ -8,7 +9,8 @@ module Portage
8
9
  # form into XML. `plutil -convert json` would be simpler but refuses
9
10
  # any plist holding a <date> (Safari's Reading List entries carry
10
11
  # them), and a real XML library (rexml) isn't a runtime dependency of
11
- # this gem — this is ~50 lines instead of a new one.
12
+ # this gem — this is ~50 lines instead of a new one. Entities go
13
+ # through CGI.unescapeHTML from `cgi/escape`, a default library.
12
14
  #
13
15
  # Dates, data and numbers come back as their raw text; bookmark
14
16
  # import only ever reads strings out of the tree.
@@ -16,7 +18,6 @@ module Portage
16
18
  class ParseError < StandardError; end
17
19
 
18
20
  SCALARS = "string|key|data|date|integer|real".freeze
19
- ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
20
21
 
21
22
  def self.parse(xml)
22
23
  scanner = StringScanner.new(xml.to_s)
@@ -31,7 +32,7 @@ module Portage
31
32
  elsif scanner.scan(%r{<(true|false)\s*/>}) then scanner[1] == "true"
32
33
  elsif scanner.scan("<dict>") then dict(scanner)
33
34
  elsif scanner.scan("<array>") then array(scanner)
34
- elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then unescape(scanner[2])
35
+ elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then CGI.unescapeHTML(scanner[2])
35
36
  else raise ParseError, "unexpected plist content at #{scanner.pos}"
36
37
  end
37
38
  end
@@ -57,15 +58,7 @@ module Portage
57
58
  { "dict" => {}, "array" => [] }.fetch(tag, "")
58
59
  end
59
60
 
60
- def self.unescape(text)
61
- text.gsub(/&(#x?)?(\w+);/) do
62
- if Regexp.last_match(1) == "#x" then [Regexp.last_match(2).to_i(16)].pack("U")
63
- elsif Regexp.last_match(1) == "#" then [Regexp.last_match(2).to_i].pack("U")
64
- else ENTITIES.fetch(Regexp.last_match(2), Regexp.last_match(0))
65
- end
66
- end
67
- end
68
- private_class_method :value, :dict, :array, :empty, :unescape
61
+ private_class_method :value, :dict, :array, :empty
69
62
  end
70
63
  end
71
64
  end
@@ -1,22 +1,21 @@
1
- require "json"
2
- require "open3"
1
+ require "sqlite3"
3
2
  require "tmpdir"
4
3
  require "fileutils"
5
4
 
6
5
  module Portage
7
6
  module Cli
8
7
  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.
8
+ # Reads a browser's SQLite history database: the database (plus its
9
+ # `-wal` file, when the browser left one — it holds the most recent
10
+ # visits until the next checkpoint) is copied into a private tmpdir
11
+ # first, since a running browser holds it locked, and the copy is
12
+ # queried read-only through the sqlite3 gem. The `-shm` index is not
13
+ # copied: SQLite rebuilds it from the `-wal` in the (writable) tmpdir.
14
+ # The copy is deleted before this returns, success or not. The
15
+ # original file is only ever read by the copy.
16
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.
17
+ # Any SQLite error (a corrupt or non-SQLite file, a schema that
18
+ # doesn't match the query) is Unavailable, never a fallback.
20
19
  class Sqlite
21
20
  class Unavailable < StandardError; end
22
21
 
@@ -25,12 +24,6 @@ module Portage
25
24
  # around — see Importer's own handling.
26
25
  class PermissionDenied < StandardError; end
27
26
 
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
27
  # Copies `path` once and yields a query proc, so one copy serves
35
28
  # several queries (Firefox's history and bookmarks both live in
36
29
  # places.sqlite).
@@ -54,14 +47,15 @@ module Portage
54
47
  end
55
48
 
56
49
  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)"
50
+ db = SQLite3::Database.new(copy, readonly: true, results_as_hash: true)
51
+ db.execute(sql).map { |row| row.transform_values { |v| v.is_a?(String) ? scrub(v) : v } }
52
+ rescue SQLite3::Exception => e
53
+ raise Unavailable, "SQLite error: #{e.message}"
54
+ ensure
55
+ db&.close
64
56
  end
57
+
58
+ def scrub(text) = text.dup.force_encoding("UTF-8").scrub
65
59
  end
66
60
  end
67
61
  end
@@ -34,15 +34,6 @@ module Portage
34
34
  get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
35
35
  end
36
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
37
  def self.get(host, port, path, method: :get)
47
38
  uri = URI("http://#{host}:#{port}#{path}")
48
39
  response = fetch(uri, method)